anchor-pki 0.2.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/puma/dsl.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Extend the ::Puma::DSL module with the configuration options we want for
5
+ # autocert
6
+ #
7
+
8
+ require 'puma/dsl'
9
+
10
+ module Puma
11
+ # Extend the ::Puma::DSL module with the configuration options we want
12
+ class DSL
13
+ def auto_cert_name(name = nil)
14
+ @options[:auto_cert_name] = name if name
15
+ @options[:auto_cert_name]
16
+ end
17
+
18
+ def auto_cert_port(port = nil)
19
+ @options[:auto_cert_port] = port if port
20
+ @options[:auto_cert_port]
21
+ end
22
+
23
+ def auto_cert_check_every(check_every = nil)
24
+ @options[:auto_cert_check_every] = check_every if check_every
25
+ @options[:auto_cert_check_every]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../dsl'
4
+ module Puma
5
+ class Plugin
6
+ # This is a plugin for Puma that will automatically renew a certificate
7
+ #
8
+ # This module is here in order to communicate plugin configuration options
9
+ # to the plugin since the plugin is created dynamically and it is loaded and
10
+ # initialized without any configuration options.
11
+ module AutoCert
12
+ class << self
13
+ def ssl_bind_options(managed_certificate:)
14
+ {
15
+ cert: managed_certificate.cert_path,
16
+ key: managed_certificate.key_path
17
+ }
18
+ end
19
+ end
20
+
21
+ # Instance methods that are included in the dynamic Puma Plugin class when
22
+ # a plugin is created
23
+ module PluginInstanceMethods
24
+ attr_accessor :managed_certificate
25
+
26
+ def config(dsl)
27
+ port = dsl.auto_cert_port || ENV.fetch('HTTPS_PORT', nil)
28
+ name = dsl.auto_cert_name || ENV.fetch('AUTO_CERT_NAME', 'default')
29
+ configuration = ::Anchor::AutoCert::Registry.fetch(name)
30
+ identifiers = configuration.allow_identifiers
31
+ manager = ::Anchor::AutoCert::Manager.new(configuration: configuration)
32
+
33
+ @managed_certificate = manager.managed_certificate(identifiers: identifiers)
34
+
35
+ options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: @managed_certificate)
36
+
37
+ dsl.ssl_bind '[::]', port, options
38
+ rescue StandardError
39
+ @managed_certificate = nil
40
+ end
41
+
42
+ def start(launcher)
43
+ @launcher = launcher
44
+ unless managed_certificate&.enabled?
45
+ log_writer.log 'AutoCert >> Not enabled - skipping certificate renewal process'
46
+ return
47
+ end
48
+
49
+ log_writer.log "AutoCert >> Configured for #{managed_certificate.identifiers.join(', ')}"
50
+ check_every = launcher.config.options[:auto_cert_check_every] ||
51
+ ENV.fetch('AUTO_CERT_CHECK_EVERY', nil) ||
52
+ ::Anchor::AutoCert::RenewalBusyWait::ONE_HOUR
53
+
54
+ in_background do
55
+ Anchor::AutoCert::RenewalBusyWait.wait_for_it(managed_certificate: managed_certificate,
56
+ check_every: check_every) do
57
+ dump_cert_info
58
+
59
+ # if ssl server is up, then it has already read the local working
60
+ # files, which means we can purge them - if there's a disk cache, those still
61
+ # probably exist
62
+ ssl_server = launcher.binder.ios.find { |io| io.instance_of?(Puma::MiniSSL::Server) }
63
+ managed_certificate.purge_working_files if ssl_server
64
+
65
+ true
66
+ end
67
+ log_writer.log 'AutoCert >> Restarting Puma in order to renew certificate'
68
+ @launcher.restart
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def dump_cert_info
75
+ log_writer.debug "AutoCert >> Bound cert : #{managed_certificate.hex_serial}"
76
+ log_writer.debug "AutoCert >> common name : #{managed_certificate.common_name}"
77
+ log_writer.debug "AutoCert >> identifiers : #{managed_certificate.identifiers.join(', ')}"
78
+ log_writer.debug "AutoCert >> not before : #{managed_certificate.not_before}"
79
+ log_writer.debug "AutoCert >> not after : #{managed_certificate.not_after}"
80
+ end
81
+
82
+ def log_writer
83
+ if Gem::Version.new(Puma::Const::PUMA_VERSION) >= Gem::Version.new(6)
84
+ @launcher.log_writer
85
+ else
86
+ @launcher.events
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ # This is the entry point for the plugin
95
+ Puma::Plugin.create do
96
+ include Puma::Plugin::AutoCert::PluginInstanceMethods
97
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anchor-pki
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
- - Anchor
7
+ - Anchor Security, Inc
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-18 00:00:00.000000000 Z
11
+ date: 2023-06-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
@@ -24,18 +24,143 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: 2.0.13
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.14'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.14'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.9'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.50'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.50'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.22'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.22'
97
+ - !ruby/object:Gem::Dependency
98
+ name: vcr
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '6.1'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '6.1'
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.8'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.8'
27
125
  description: Anchor is a hosted PKI platform for your internal organization.
28
- email: support@anchor.dev
126
+ email:
29
127
  executables: []
30
128
  extensions: []
31
- extra_rdoc_files: []
129
+ extra_rdoc_files:
130
+ - LICENSE.txt
131
+ - README.md
132
+ - CHANGELOG.md
32
133
  files:
134
+ - CHANGELOG.md
135
+ - Gemfile
136
+ - Gemfile.lock
137
+ - LICENSE.txt
138
+ - README.md
139
+ - Rakefile
33
140
  - lib/anchor-pki.rb
34
- - lib/anchor-pki/auto_cert.rb
35
- homepage: https://anchor.dev/
141
+ - lib/anchor.rb
142
+ - lib/anchor/auto_cert.rb
143
+ - lib/anchor/auto_cert/configuration.rb
144
+ - lib/anchor/auto_cert/identifier_policy.rb
145
+ - lib/anchor/auto_cert/managed_certificate.rb
146
+ - lib/anchor/auto_cert/manager.rb
147
+ - lib/anchor/auto_cert/policy_check.rb
148
+ - lib/anchor/auto_cert/policy_check/for_hostname.rb
149
+ - lib/anchor/auto_cert/policy_check/for_ipaddr.rb
150
+ - lib/anchor/auto_cert/policy_check/for_wildcard_hostname.rb
151
+ - lib/anchor/auto_cert/railtie.rb
152
+ - lib/anchor/auto_cert/registry.rb
153
+ - lib/anchor/auto_cert/renewal_busy_wait.rb
154
+ - lib/anchor/auto_cert/terms_of_service_acceptor.rb
155
+ - lib/anchor/version.rb
156
+ - lib/puma/dsl.rb
157
+ - lib/puma/plugin/auto_cert.rb
158
+ homepage: https://anchor.dev
36
159
  licenses:
37
160
  - MIT
38
- metadata: {}
161
+ metadata:
162
+ homepage_uri: https://anchor.dev
163
+ rubygems_mfa_required: 'true'
39
164
  post_install_message:
40
165
  rdoc_options: []
41
166
  require_paths:
@@ -44,7 +169,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
44
169
  requirements:
45
170
  - - ">="
46
171
  - !ruby/object:Gem::Version
47
- version: '0'
172
+ version: '2.3'
48
173
  required_rubygems_version: !ruby/object:Gem::Requirement
49
174
  requirements:
50
175
  - - ">="
@@ -1,134 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'acme/client'
4
- require 'uri'
5
-
6
- module Anchor
7
- # AutoCert provides automatic access to certificates from Anchor, but
8
- # theoretically it could work with Let's Encrypt and any other ACME-based CA.
9
- class AutoCert
10
- ACCEPT_ANY_TOS = [//].freeze
11
-
12
- def initialize(name_policies, tos_acceptor, directory, account: {}, **opts)
13
- @name_policies = name_policies
14
- @tos_acceptor = tos_acceptor
15
- @directory_url = URI.parse(directory)
16
-
17
- @renew_before = opts[:renew_before]
18
- @work_dir = Pathname(opts[:work_dir] || Dir.mktmpdir)
19
- @cache = opts[:cache]
20
- @client = opts[:client]
21
-
22
- @client ||= new_client(**account)
23
- @account_opts = account
24
- end
25
-
26
- def certificate(*names, algo: :ecdsa, common_name: names.first, **opts)
27
- if (arr = unmatched_names(names)).size.positive?
28
- raise StandardError, "unallowed names '#{arr.join(',')}'"
29
- end
30
-
31
- key_pem = @cache&.read("#{common_name}+#{algo}")
32
- cert_pem = @cache&.read(common_name)
33
-
34
- if key_pem.nil? || cert_pem.nil? || needs_renewal?(cert_pem)
35
- cert_pem, key_pem = provision(names, algo, common_name, **opts)
36
-
37
- @cache&.write("#{common_name}+#{algo}", key_pem)
38
- @cache&.write(common_name, cert_pem)
39
- end
40
-
41
- cert = (@work_dir / common_name).open('w') { |f| f << cert_pem }.path
42
- key = (@work_dir / "#{common_name}+#{algo}").open('w') { |f| f << key_pem }.path
43
-
44
- [cert, key]
45
- end
46
-
47
- private
48
-
49
- def provision(names, algo, common_name, **opts)
50
- load_or_build_account
51
-
52
- order = @client.new_order(identifiers: names, **opts)
53
-
54
- key_pem ||= new_key(algo).to_pem
55
- csr = Acme::Client::CertificateRequest.new(private_key: parse_key_pem(key_pem), common_name: common_name, names: names)
56
-
57
- order.finalize(csr: csr)
58
- # TODO: loop over order.authorizations and process the challenges
59
-
60
- while order.status == 'processing'
61
- sleep(1)
62
- order.reload
63
- end
64
-
65
- return order.certificate, key_pem
66
- end
67
-
68
- def load_or_build_account
69
- @account ||= build_account(**@account_opts)
70
- end
71
-
72
- def build_account(contact: nil, external_account_binding: nil, terms_of_service_agreed: false, **)
73
- terms_of_service_agreed ||= @tos_acceptor.any? { |a| a.match?(@client.terms_of_service) }
74
-
75
- @client.new_account(contact: contact, terms_of_service_agreed: terms_of_service_agreed, external_account_binding: external_account_binding)
76
- end
77
-
78
- def needs_renewal?(cert_pem)
79
- cert = OpenSSL::X509::Certificate.new(cert_pem)
80
-
81
- (Time.zone.now + @renew_before.to_i) > cert.not_after
82
- end
83
-
84
- def new_client(account_key: nil, contact: nil, **)
85
- account_key ||= fetch_account_key(contact) { new_key(:ecdsa) }
86
-
87
- Acme::Client.new(private_key: account_key, directory: @directory_url)
88
- end
89
-
90
- def new_key(algo)
91
- case algo
92
- when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
93
- else
94
- raise "unknown key algo '#{algo}'"
95
- end
96
- end
97
-
98
- def fetch_account_key(contact)
99
- id = "#{contact || 'default'}+#{@directory_url.host}+key"
100
- parse_key_pem(@cache&.fetch(id) { parse_key_pem(yield.to_pem) } || yield.to_pem)
101
- end
102
-
103
- def parse_key_pem(data)
104
- begin
105
- OpenSSL::PKey::RSA.new(data)
106
- rescue StandardError
107
- nil
108
- end ||
109
- begin
110
- OpenSSL::PKey::EC.new(data)
111
- rescue StandardError
112
- nil
113
- end ||
114
- (raise 'unknown key data format')
115
- end
116
-
117
- def unmatched_names(names)
118
- names.reject do |name|
119
- @name_policies.any? do |policy|
120
- case policy
121
- when String then name == policy
122
- when Regexp then policy.match?(name)
123
- when IPAddr
124
- begin
125
- policy.include?(name)
126
- rescue IPAddr::Error
127
- nil
128
- end
129
- end
130
- end
131
- end
132
- end
133
- end
134
- end