anchor-pki 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc0b9b7fe9893fc5d4f23157bbb2a18c4a15066e929024c197b610ef5f6ffd80
4
- data.tar.gz: 5cc3e63d7acbc16531c9abf60d04d205fe5ed0b638e595653a8aed74d8abc49a
3
+ metadata.gz: 342dadd5c28835816da2c07cdd1d4b7b546cc2a35ca80738cc98707d2a048cd4
4
+ data.tar.gz: 97cb3d7c9c9bbb770c8a3a52eae0690bc7f18cadd5f3a31689d6d00239f5a523
5
5
  SHA512:
6
- metadata.gz: 7d5d6bb50ee250800df59aa17ecca780cddc08293ff4bb609a4b944fcc611ef76ec916d5d6b75026694b0740addeabf592533c4117ea90cd3a1032c21c816f5e
7
- data.tar.gz: 4e2c6f2a836c59319a6cab2a6f48204d75ae7e8fb1b721e6177c2ec75be57c1f8271a18fef02afc847dc822b1db8e70c228c4cbd90ec023fd54bd8ecd620d87c
6
+ metadata.gz: 06b0f93d4bc1962f60a4122bd2f4a046818e36c49779eae9255ff24d6cbb7ba9bd9029bb4f72f516b96c5fa8aef98cea7fbbd6f29ae3b69e42e6cb31e5990ade
7
+ data.tar.gz: f220a29a0c170f74b8926a1ae804f65d1719a4f9956edc04c6844ac27b2404f15c56908cf5b608a35fec1fe10679b74442ace2b3f6034af3eab814d564da670d
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.4.0] - 2023-06-06
4
+
5
+ - add a puma plugin and configuration dsl for better integration
6
+ - auto restart of puma when a certificate is renewed
7
+ - improve tests
8
+ - internal refactor to support the puma plugin
9
+
10
+ ## [0.3.0] - 2023-06-05
11
+
12
+ - improve gem packaging
13
+ - extract out a configuration class for Acme
14
+ - add tests
15
+
16
+ ## [0.2.0] - 2023-04-18
17
+
18
+ -
19
+
20
+ ## [0.1.0] - 2021-11-05
21
+
22
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in anchor-pki.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,94 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ anchor-pki (0.2.1)
5
+ acme-client (~> 2.0.13)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ acme-client (2.0.13)
11
+ faraday (>= 1.0, < 3.0.0)
12
+ faraday-retry (>= 1.0, < 3.0.0)
13
+ addressable (2.8.4)
14
+ public_suffix (>= 2.0.2, < 6.0)
15
+ ast (2.4.2)
16
+ crack (0.4.5)
17
+ rexml
18
+ diff-lcs (1.5.0)
19
+ faraday (2.7.5)
20
+ faraday-net_http (>= 2.0, < 3.1)
21
+ ruby2_keywords (>= 0.0.4)
22
+ faraday-net_http (3.0.2)
23
+ faraday-retry (2.2.0)
24
+ faraday (~> 2.0)
25
+ hashdiff (1.0.1)
26
+ json (2.6.3)
27
+ minitest (5.18.0)
28
+ parallel (1.23.0)
29
+ parser (3.2.2.1)
30
+ ast (~> 2.4.1)
31
+ public_suffix (5.0.1)
32
+ rainbow (3.1.1)
33
+ rake (13.0.6)
34
+ regexp_parser (2.8.0)
35
+ rexml (3.2.5)
36
+ rspec (3.12.0)
37
+ rspec-core (~> 3.12.0)
38
+ rspec-expectations (~> 3.12.0)
39
+ rspec-mocks (~> 3.12.0)
40
+ rspec-core (3.12.2)
41
+ rspec-support (~> 3.12.0)
42
+ rspec-expectations (3.12.3)
43
+ diff-lcs (>= 1.2.0, < 2.0)
44
+ rspec-support (~> 3.12.0)
45
+ rspec-mocks (3.12.5)
46
+ diff-lcs (>= 1.2.0, < 2.0)
47
+ rspec-support (~> 3.12.0)
48
+ rspec-support (3.12.0)
49
+ rubocop (1.51.0)
50
+ json (~> 2.3)
51
+ parallel (~> 1.10)
52
+ parser (>= 3.2.0.0)
53
+ rainbow (>= 2.2.2, < 4.0)
54
+ regexp_parser (>= 1.8, < 3.0)
55
+ rexml (>= 3.2.5, < 4.0)
56
+ rubocop-ast (>= 1.28.0, < 2.0)
57
+ ruby-progressbar (~> 1.7)
58
+ unicode-display_width (>= 2.4.0, < 3.0)
59
+ rubocop-ast (1.28.1)
60
+ parser (>= 3.2.1.0)
61
+ rubocop-capybara (2.18.0)
62
+ rubocop (~> 1.41)
63
+ rubocop-factory_bot (2.23.1)
64
+ rubocop (~> 1.33)
65
+ rubocop-rspec (2.22.0)
66
+ rubocop (~> 1.33)
67
+ rubocop-capybara (~> 2.17)
68
+ rubocop-factory_bot (~> 2.22)
69
+ ruby-progressbar (1.13.0)
70
+ ruby2_keywords (0.0.5)
71
+ unicode-display_width (2.4.2)
72
+ vcr (6.1.0)
73
+ webmock (3.18.1)
74
+ addressable (>= 2.8.0)
75
+ crack (>= 0.3.2)
76
+ hashdiff (>= 0.4.0, < 2.0.0)
77
+
78
+ PLATFORMS
79
+ aarch64-linux
80
+ arm64-darwin-21
81
+ x86_64-darwin-22
82
+
83
+ DEPENDENCIES
84
+ anchor-pki!
85
+ minitest (~> 5.14)
86
+ rake (~> 13.0)
87
+ rspec (~> 3.9)
88
+ rubocop (~> 1.50)
89
+ rubocop-rspec (~> 2.22)
90
+ vcr (~> 6.1)
91
+ webmock (~> 3.8)
92
+
93
+ BUNDLED WITH
94
+ 2.3.26
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Anchor Security, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Anchor
2
+
3
+ Ruby client for Anchor PKI. See https://anchor.dev/ for details.
4
+
5
+ ## Configuration
6
+
7
+ The Following environment variables are available to configure the default
8
+ [`AutoCert::Manager`](./lib/anchor/auto_cert/manager.rb).
9
+
10
+ * `HTTPS_PORT` - the TCP numerical port to bind SSL to.
11
+ * `ACME_ALLOW_IDENTIFIERS` - A comma separated list of hostnames for provisioning certs
12
+ * `ACME_DIRECTORY_URL` - the ACME provider's directory
13
+ * `ACME_KID` - your External Account Binding (EAB) KID for authenticating with the ACME directory above with an
14
+ * `ACME_HMAC_KEY` - your EAB HMAC_KEY for authenticating with the ACME directory above
15
+ * `ACME_RENEW_BEFORE_SECONDS` - **optional** Start a renewal this number number of seconds before the cert expires. This defaults to 30 days (2592000 seconds)
16
+ * `ACME_RENEW_BEFORE_FRACTION` - **optional** Start the renewal when this fraction of a cert's valid window is left. This defaults to 0.5, which means when the cert is in the last 50% of its lifespan a renewal is attempted.
17
+ * `AUTO_CERT_CHECK_EVERY` - **optional** the number of seconds to wait between checking if the certificate has expired. This defaults to 1 hour (3600 seconds)
18
+ * `AUTO_CERT_NAME` - **optional** the name to use to lookup the default `AutoCert::Configuration` in the `AutoCert::Registry`. This is `default` by default
19
+
20
+ If both `ACME_RENEW_BEFORE_SECONDS` and `ACME_RENEW_BEFORE_FRACTION` are set,
21
+ the one that causes the renewal to take place earlier is used.
22
+
23
+ Example:
24
+
25
+ * Cert start (not_before) moment is : `2023-05-24 20:53:11 UTC`
26
+ * Cert expiration (not_after) moment is : `2023-06-21 20:53:10 UTC`
27
+ * `ACME_RENEW_BEFORE_SECONDS` is `1209600` (14 days)
28
+ * `ACME_RENEW_BEFORE_FRACTION` is `0.25` - which equates to a before seconds value of `604799` (~7 days)
29
+
30
+ The possible moments to start renewing are:
31
+
32
+ * 14 days before expiration moment - `2023-06-07 20:53:10 UTC`
33
+ * when 25% of the valid time is left - `2023-06-14 20:53:11 UTC`
34
+
35
+ Currently the `AutoCert::Manager` will use whichever is earlier.
36
+
37
+ ### Example configuration
38
+
39
+ ```sh
40
+ HTTPS_PORT=44300
41
+ ACME_ALLOW_IDENTIFIERS=my.lcl.host,*.my.lcl.host
42
+ ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
43
+ ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
44
+ ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
45
+ ```
46
+
47
+ ## License
48
+
49
+ The gem is available as open source under the terms of the [MIT
50
+ License](./LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'minitest/test_task'
9
+ Minitest::TestTask.create(:test)
10
+
11
+ task default: %i[spec test]
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ # AutoCert Configuration provides a way to configure the AutoCert Manager.
6
+ #
7
+ class Configuration
8
+ DEFAULT_BEFORE_SECONDS = 60 * 60 * 24 * 30 # 30 days in seconds
9
+ DEFAULT_BEFORE_FRACTION = 0.5 # when in the last 50% of the validity window, renew
10
+
11
+ # Note - although it is possible to set change the name of a config, it is
12
+ # not recommended. The name is used as the key in the Registry, and if a
13
+ # Configuration is in the Registry, and its name is changed, it does not
14
+ # change its registry key.
15
+ attr_accessor :name,
16
+ :allow_identifiers,
17
+ :cache,
18
+ :contact,
19
+ :directory_url,
20
+ :external_account_binding,
21
+ :renew_before_fraction,
22
+ :renew_before_seconds,
23
+ :tos_acceptors,
24
+ :work_dir
25
+
26
+ # rubocop:disable Metrics/ParameterLists
27
+ # Data defined classes have all required parameters in the initializer, so
28
+ # override the default initializer to allow for optional parameters and
29
+ # to pull in the defaults form the environment
30
+ #
31
+ def initialize(name:,
32
+ allow_identifiers: nil,
33
+ cache: nil,
34
+ contact: nil,
35
+ directory_url: nil,
36
+ external_account_binding: nil,
37
+ renew_before_fraction: DEFAULT_BEFORE_FRACTION,
38
+ renew_before_seconds: DEFAULT_BEFORE_SECONDS,
39
+ tos_acceptors: nil,
40
+ work_dir: nil)
41
+
42
+ @name = name
43
+
44
+ @allow_identifiers = allow_identifiers
45
+ @cache = cache
46
+ @contact = contact
47
+ @directory_url = directory_url
48
+ @external_account_binding = external_account_binding
49
+ @renew_before_fraction = renew_before_fraction
50
+ @renew_before_seconds = renew_before_seconds
51
+ @tos_acceptors = tos_acceptors
52
+ @work_dir = work_dir
53
+ end
54
+ # rubocop:enable Metrics/ParameterLists
55
+
56
+ def account
57
+ {
58
+ contact: contact,
59
+ external_account_binding: external_account_binding
60
+ }
61
+ end
62
+
63
+ # Enabled just means that the configuration is valid
64
+ def enabled?
65
+ validate!
66
+ true
67
+ rescue ConfigurationError => _e
68
+ false
69
+ end
70
+
71
+ def validate!
72
+ @allow_identifiers = prepare_allow_identifiers(@allow_identifiers)
73
+ @cache = prepare_cache(@cache)
74
+ @directory_url = prepare_directory_url(@directory_url)
75
+ @external_account_binding = prepare_external_account_binding(@external_account_binding)
76
+ @renew_before_fraction = prepare_renew_before_fraction(@renew_before_fraction)
77
+ @renew_before_seconds = prepare_renew_before_seconds(@renew_before_seconds)
78
+ @tos_acceptors = prepare_tos_acceptors(@tos_acceptors)
79
+ @work_dir = prepare_work_dir(@work_dir)
80
+
81
+ self
82
+ end
83
+
84
+ private
85
+
86
+ def prepare_allow_identifiers(allow_identifiers)
87
+ prepared = case allow_identifiers
88
+ when Array
89
+ allow_identifiers
90
+ when String
91
+ allow_identifiers.split(',')
92
+ when nil
93
+ ENV.fetch('ACME_ALLOW_IDENTIFIERS', nil)&.split(',')
94
+ end
95
+
96
+ if prepared.nil? || prepared.empty?
97
+ raise ConfigurationError,
98
+ "The '#{name}' #{self.class} instance has a misconfigured `allow_identifiers` value. " \
99
+ 'Set it to a string, or an array of strings, ' \
100
+ 'or set the ACME_ALLOW_IDENTIFIERS environment variable to a comma separated list of identifiers.'
101
+ end
102
+
103
+ prepared
104
+ end
105
+
106
+ def prepare_cache(cache)
107
+ return nil if cache.nil?
108
+
109
+ unless cache.respond_to?(:read) && cache.respond_to?(:write) && cache.respond_to?(:fetch)
110
+ raise ConfigurationError,
111
+ "The '#{name}' #{self.class} instance has a misconfigured `cache` value. " \
112
+ 'It must be set to an object that responds to `read`, `write`, and `fetch`.'
113
+ end
114
+
115
+ cache
116
+ end
117
+
118
+ def prepare_directory_url(directory_url)
119
+ directory_url ||= ENV.fetch('ACME_DIRECTORY_URL', nil)
120
+ if directory_url.nil?
121
+ raise ConfigurationError,
122
+ "The '#{name}' #{self.class} instance has a misconfigured `directory_url` value. " \
123
+ 'It must be set to a string, or set the ACME_DIRECTORY_URL environment variable.'
124
+ end
125
+
126
+ directory_url
127
+ end
128
+
129
+ def prepare_external_account_binding(external_account_binding)
130
+ kid = ENV.fetch('ACME_KID', nil)
131
+ hmac_key = ENV.fetch('ACME_HMAC_KEY', nil)
132
+
133
+ if kid && hmac_key
134
+ external_account_binding = {
135
+ kid: kid,
136
+ hmac_key: hmac_key
137
+ }
138
+ end
139
+ external_account_binding
140
+ end
141
+
142
+ def prepare_renew_before_seconds(renew_before_seconds)
143
+ renew_before_seconds ||= ENV.fetch('ACME_RENEW_BEFORE_SECONDS', nil)
144
+ if renew_before_seconds
145
+ renew_before_seconds = renew_before_seconds.to_i
146
+ unless renew_before_seconds.positive?
147
+ raise ConfigurationError,
148
+ "The '#{name}' #{self.class} instance has a misconfigured `before_seconds` value. " \
149
+ 'It must be set to an integer > 0, or set the ACME_RENEW_BEFORE_SECONDS environment variable.'
150
+ end
151
+ end
152
+ renew_before_seconds
153
+ end
154
+
155
+ def prepare_renew_before_fraction(renew_before_fraction)
156
+ renew_before_fraction ||= ENV.fetch('ACME_RENEW_BEFORE_FRACTION', nil)
157
+ if renew_before_fraction
158
+ renew_before_fraction = renew_before_fraction.to_f
159
+ unless (0..1).cover?(renew_before_fraction)
160
+ raise ConfigurationError,
161
+ "The '#{name}' #{self.class} instance has a misconfigured `before_fraction` value. " \
162
+ 'It must be set to a float > 0 and < 1, or set the ACME_RENEW_BEFORE_FRACTION environment variable.'
163
+ end
164
+ end
165
+ renew_before_fraction
166
+ end
167
+
168
+ def prepare_tos_acceptors(tos_acceptors)
169
+ tos_acceptors = Array(tos_acceptors)
170
+
171
+ if tos_acceptors.empty? || tos_acceptors.any? { |tos| !tos.respond_to?(:accept?) }
172
+ raise ConfigurationError,
173
+ "The '#{name}' #{self.class} instance has a misconfigured `tos_acceptors` value. " \
174
+ 'It must be set to an object or an array of objects that respond to `accept?`.'
175
+ end
176
+
177
+ tos_acceptors
178
+ end
179
+
180
+ def prepare_work_dir(work_dir)
181
+ return nil if work_dir.nil?
182
+
183
+ work_dir = Pathname.new(work_dir) unless work_dir.is_a?(Pathname)
184
+
185
+ begin
186
+ work_dir.mkpath
187
+ rescue StandardError => e
188
+ raise ConfigurationError, "#{self.class}#work_dir : #{e.message}"
189
+ end
190
+
191
+ unless work_dir.directory? && work_dir.writable?
192
+ raise ConfigurationError, "#{self.class}#work_dir '#{work_dir}' must be a writable directory."
193
+ end
194
+
195
+ work_dir
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ #
6
+ # IdentifierPolicy is a class used to check that the identifiers used in
7
+ # certs would be valid.
8
+ #
9
+ # Each IdentierPolicy is initialized with a 'policy_description' which is used to
10
+ # derive the policy check.
11
+ #
12
+ # Current Policy Checks are:
13
+ # - ForHostname - checks that the identifier matches hostname exactly
14
+ # - ForWildcardHostname - checks that the identifier matches hostname with a wildcard prefix
15
+ # - ForIpAddress - checks that the identifier matches an IP address or subnet
16
+ #
17
+ class IdentifierPolicy
18
+ attr_reader :description, :check
19
+
20
+ # Given an individual, or an array of IdentifierPolicy or Strings build
21
+ # IdentifierPolicy objects
22
+ def self.build(policy_descriptions)
23
+ Array(policy_descriptions).map do |description|
24
+ IdentifierPolicy.new(description)
25
+ end
26
+ end
27
+
28
+ def self.new(description)
29
+ return description if description.is_a?(IdentifierPolicy)
30
+
31
+ super
32
+ end
33
+
34
+ # The list of policy checks that are available, the ordering here is
35
+ # important as the first one that matches is the one that is used for the
36
+ # check. So if a policy description would be matched by multiple checks,
37
+ # the one that it should match should be first.
38
+ def self.policy_checks
39
+ @policy_checks ||= [
40
+ PolicyCheck::ForIPAddr,
41
+ PolicyCheck::ForHostname,
42
+ PolicyCheck::ForWildcardHostname
43
+ ]
44
+ end
45
+
46
+ def initialize(description)
47
+ check_klass = self.class.policy_checks.find do |klass|
48
+ klass.handles?(description)
49
+ end
50
+ raise UnknownPolicyCheckError, "Unable to create a policy check based upon '#{description}'" if check_klass.nil?
51
+
52
+ @description = description
53
+ @check = check_klass.new(description)
54
+ end
55
+
56
+ def allow?(identifier)
57
+ raise ArgumentError, 'identifier must be a String' unless identifier.is_a?(String)
58
+
59
+ @check.allow?(identifier)
60
+ end
61
+
62
+ def deny?(identifier)
63
+ !allow?(identifier)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ require_relative 'policy_check'
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Anchor
6
+ module AutoCert
7
+ # ManagedCertificate is a class that represents a certificate and its manager
8
+ # for renewal
9
+ class ManagedCertificate
10
+ attr_reader :cert_pem, :cert_path, :key_pem, :key_path, :key, :manager, :x509
11
+
12
+ extend Forwardable
13
+ def_delegators :@manager, :enabled?
14
+ def_delegators :@x509, :not_after, :not_before, :serial
15
+
16
+ def self.from(manager:, cert_path:, key_path:)
17
+ cert_pem = File.read(cert_path)
18
+ key_pem = File.read(key_path)
19
+ new(manager: manager, cert_pem: cert_pem, key_pem: key_pem)
20
+ end
21
+
22
+ def initialize(manager:, cert_pem:, key_pem:)
23
+ @manager = manager
24
+ @cert_pem = cert_pem
25
+ @key_pem = key_pem
26
+ @x509 = OpenSSL::X509::Certificate.new(cert_pem)
27
+
28
+ hex_serial_basename = hex_serial('-')
29
+ @cert_path = manager.work_dir / "#{hex_serial_basename}.crt"
30
+ @key_path = manager.work_dir / "#{hex_serial_basename}.key"
31
+
32
+ write_working_files
33
+ end
34
+
35
+ def hex_serial(joiner = ':')
36
+ x509.serial.to_s(16).scan(/.{2}/).join(joiner)
37
+ end
38
+
39
+ def needs_renewal?(now = Time.now.utc)
40
+ manager.needs_renewal?(cert: x509, now: now)
41
+ end
42
+
43
+ def identifiers
44
+ alt_names = x509&.extensions&.find { |ext| ext.oid == 'subjectAltName' }&.value&.split(', ') || []
45
+ alt_names.map { |name| name.sub(/^DNS:/, '') }
46
+ end
47
+
48
+ def common_name
49
+ x509.subject.to_a.find { |name, _, _| name == 'CN' }[1]
50
+ end
51
+
52
+ def write_working_files
53
+ cert_path.open('w') { |f| f << cert_pem }
54
+ key_path.open('w') { |f| f << key_pem }
55
+ end
56
+
57
+ def purge_working_files
58
+ cert_path.delete if cert_path.exist?
59
+ key_path.delete if key_path.exist?
60
+ end
61
+ end
62
+ end
63
+ end