vagrant-docker-certificates-manager 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: 023a4ac2fc48aacbf0de2d60bfa2b91a8890318b623c2169538ea748db884367
4
- data.tar.gz: 8b709c9e9c8a637e80df487b3ca0381e9ff1cd0c2334941024819a842050e4b6
3
+ metadata.gz: 7205874687ebbce5453b9267447c8a007d949416b370e67d141b4e633193ce1b
4
+ data.tar.gz: 5970d32848bbb46f63f4d3b1dd0c12823350937e69363dbd1c4e1d7e7cf1ab16
5
5
  SHA512:
6
- metadata.gz: 2cdba816ec1797ad404199e153bdb3e25b9b105bc62ea872d1068efbdaf0649e9e80ea8b1044c46f966b19e19cda29c85fb09c80af5632cf1acbe794e3152fc0
7
- data.tar.gz: 8697e75304b157574141dc5b7ebeef2d81ffcc4c6bb27d23277f45f25418a40bb8c1e3de045f8b7bb418adfff601d3eb7058b2af15df97d4ecf16e92f324274e
6
+ metadata.gz: da2f09201144ccaa74456e248c84d3c89297be73764c7d7cd2cc5a5fda5547c08b03d1eb9099fdab2808bf7a51e392faab5496ed3033ef86a4e96c9121388e88
7
+ data.tar.gz: 8a8f01b14910172242e72b62bd9dd889fcc79f56e0449f2d48ccffd763c91f5dab7a887ca52b214efdd8038d976c7e0c9464d4069b95c03ecd226fa1f30ebefd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/julienpoirou/vagrant-docker-certificates-manager/compare/v0.3.0...v0.4.0) (2026-07-03)
4
+
5
+
6
+ ### Fonctionnalités ✨
7
+
8
+ * Improve plugin quality and publish RubyDoc ([278298b](https://github.com/julienpoirou/vagrant-docker-certificates-manager/commit/278298b614ac9e4b0797d806bed3e0015d7fdb1f))
9
+
10
+
11
+ ### Corrections 🐛
12
+
13
+ * Resolve rubocop offenses and enable pages for RubyDoc deploy ([227c596](https://github.com/julienpoirou/vagrant-docker-certificates-manager/commit/227c5967c1480a2771413cf5738d2ac1f030d495))
14
+
15
+ ## [0.3.0](https://github.com/julienpoirou/vagrant-docker-certificates-manager/compare/v0.2.0...v0.3.0) (2026-03-09)
16
+
17
+
18
+ ### Fonctionnalités ✨
19
+
20
+ * **firefox:** Adding certificate management to Firefox ([6e067c3](https://github.com/julienpoirou/vagrant-docker-certificates-manager/commit/6e067c3a99f7963aa27b8cfc7b1e481a443af4ec))
21
+
22
+
23
+ ### Corrections 🐛
24
+
25
+ * **rubocop:** Migrate rubocop-rspec from require to plugins ([9a22642](https://github.com/julienpoirou/vagrant-docker-certificates-manager/commit/9a226421d98aab0d781fd1ac3d0845e5b1c43ff4))
26
+ * **rubocop:** Restore explicit *args for Ruby 3.1 compatibility ([595438d](https://github.com/julienpoirou/vagrant-docker-certificates-manager/commit/595438d061e2f1aafb7616b91110935ef4718a5c))
27
+
3
28
  ## [0.2.0](https://github.com/julienpoirou/vagrant-docker-certificates-manager/compare/v0.1.0...v0.2.0) (2025-08-20)
4
29
 
5
30
 
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.4.0
@@ -24,6 +24,15 @@ module VagrantDockerCertificatesManager
24
24
  @app.call(env)
25
25
  end
26
26
 
27
+ # Installs a configured certificate into the current host trust store.
28
+ #
29
+ # The operation is idempotent: when the fingerprint is already tracked,
30
+ # the current machine adopts the existing registry entry instead of
31
+ # reinstalling the certificate.
32
+ #
33
+ # @param cfg [#cert_path, #cert_name] Vagrant certificate configuration.
34
+ # @param env [Hash, nil] Vagrant environment hash.
35
+ # @return [Hash] Normalized result with code, status, data, or error.
27
36
  def self.perform_install(cfg, env)
28
37
  unless File.file?(cfg.cert_path)
29
38
  return { code: 1, status: "error",
@@ -32,9 +41,11 @@ error: UiHelpers.t("errors.invalid_path", path: cfg.cert_path) }
32
41
 
33
42
  name = cfg.cert_name.to_s.strip.empty? ? Cert.default_name_from(cfg.cert_path) : cfg.cert_name
34
43
  fp = Cert.sha1(cfg.cert_path)
44
+ mid = env && env[:machine]&.id
45
+
35
46
  if Registry.all.key?(fp)
36
- return { code: 1, status: "error",
37
- error: UiHelpers.t("errors.already_present", name: name) }
47
+ Registry.adopt(fp, mid) if mid
48
+ return { code: 0, status: "success", data: { os: OS.detect, cert: name, already: true } }
38
49
  end
39
50
 
40
51
  os = OS.detect
@@ -51,7 +62,8 @@ firefox: cfg.manage_firefox)
51
62
  "path" => File.expand_path(cfg.cert_path),
52
63
  "name" => name,
53
64
  "nickname" => Cert.nickname_for(name),
54
- "os" => os.to_s
65
+ "os" => os.to_s,
66
+ "owners" => [mid].compact
55
67
  })
56
68
  { code: 0, status: "success", data: { os: os, cert: name } }
57
69
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require_relative "../util/os"
4
5
  require_relative "../util/ui"
5
6
  require_relative "../util/cert"
7
+ require_relative "../util/generator"
6
8
  require_relative "../util/registry"
7
9
  require_relative "../helpers"
8
10
 
@@ -12,7 +14,7 @@ module VagrantDockerCertificatesManager
12
14
  def initialize(app, env); @app = app; @env = env; end
13
15
 
14
16
  def call(env)
15
- cfg = env[:machine].config.docker_certs
17
+ cfg = env[:machine].config.docker_certificates
16
18
  UiHelpers.set_locale!(cfg.locale || "en")
17
19
  if cfg.remove_on_destroy
18
20
  Ui.say(env, :info, "uninstall.start", name: cfg.cert_name)
@@ -21,22 +23,61 @@ module VagrantDockerCertificatesManager
21
23
  result[:status] == "success" ? "uninstall.success" : "uninstall.fail",
22
24
  name: cfg.cert_name)
23
25
  end
26
+ purge_generated(cfg, env)
24
27
  @app.call(env)
25
28
  end
26
29
 
27
- def self.perform_uninstall(cfg, _env)
30
+ def purge_generated(cfg, env)
31
+ # Generated material is purged only through explicit env flags to avoid
32
+ # deleting a CA/server certificate that may be shared outside this action.
33
+ files = []
34
+ files += [Generator::CA_CERT, Generator::CA_KEY, Generator::CRL] if env_flag?("VDCM_PURGE_CA_ON_DESTROY")
35
+ files += [Generator::SRV_CRT, Generator::SRV_KEY] if env_flag?("VDCM_PURGE_SERVER_ON_DESTROY")
36
+ return if files.empty?
37
+
38
+ dir = cfg.cert_dir
39
+ files.uniq.each do |name|
40
+ path = File.join(dir, name)
41
+ next unless File.exist?(path)
42
+
43
+ File.delete(path)
44
+ Ui.say(env, :info, "purge.removed", path: path)
45
+ rescue StandardError => e
46
+ Ui.say(env, :warn, "purge.failed", path: path, error: e.message)
47
+ end
48
+ end
49
+
50
+ def env_flag?(name)
51
+ ENV[name].to_s == "1"
52
+ end
53
+
54
+ # Removes a configured certificate when the current machine is the last owner.
55
+ #
56
+ # Shared certificates stay installed until every machine recorded in the
57
+ # registry has released ownership.
58
+ #
59
+ # @param cfg [#cert_path, #cert_name] Vagrant certificate configuration.
60
+ # @param env [Hash, nil] Vagrant environment hash.
61
+ # @return [Hash] Normalized result with code, status, data, or error.
62
+ def self.perform_uninstall(cfg, env)
28
63
  fp_entry = Registry.find_by_path(cfg.cert_path)
29
64
  unless fp_entry
30
65
  return({ code: 1, status: "error",
31
66
  error: UiHelpers.t("errors.not_found_for_remove", path: cfg.cert_path) })
32
67
  end
33
68
  fp, rec = fp_entry
69
+ mid = env && env[:machine]&.id
70
+
71
+ if mid && Registry.release(fp, mid)
72
+ return({ code: 0, status: "success", data: { kept: true, cert: rec["name"] } })
73
+ end
74
+
34
75
  os = OS.detect
35
76
  ok = case os
36
77
  when :mac then OS.mac_remove_by_fp(fp)
37
78
  when :linux then OS.linux_uninstall_cert(rec["name"], nss: cfg.manage_nss_browsers,
38
79
  firefox: cfg.manage_firefox)
39
- when :windows then OS.win_remove_by_fp(fp)
80
+ when :windows then OS.win_remove_by_fp(fp, disable_firefox: !Registry.others_for_os?(fp, "windows"))
40
81
  else return({ code: 2, status: "error", error: UiHelpers.t("errors.os_unsupported") })
41
82
  end
42
83
  Registry.untrack(fp) if ok
@@ -1,38 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VagrantDockerCertificatesManager
4
+ # Vagrant configuration for certificate generation, installation, and cleanup.
5
+ #
6
+ # @!attribute cert_path
7
+ # @return [String] Path to the CA certificate installed into the trust store.
8
+ # @!attribute cert_name
9
+ # @return [String] Friendly certificate name used in host trust stores.
10
+ # @!attribute install_on_up
11
+ # @return [Boolean] Whether to install the certificate during `vagrant up`.
12
+ # @!attribute remove_on_destroy
13
+ # @return [Boolean] Whether to remove the certificate during `vagrant destroy`.
14
+ # @!attribute manage_firefox
15
+ # @return [Boolean] Whether Firefox stores should be managed where supported.
16
+ # @!attribute manage_nss_browsers
17
+ # @return [Boolean] Whether NSS browser stores should be managed where supported.
18
+ # @!attribute generate_on_up
19
+ # @return [Boolean] Whether local certificate material should be generated during `vagrant up`.
4
20
  class Config < Vagrant.plugin("2", :config)
5
21
  attr_accessor :cert_path, :cert_name, :install_on_up, :remove_on_destroy,
6
22
  :manage_firefox, :manage_nss_browsers, :locale, :verbose,
7
- :container_name
23
+ :container_name,
24
+ :generate_on_up, :ca_cn, :ca_days, :server_domain, :crl_url
8
25
 
9
26
  def initialize
10
- @cert_path = "certs/rootca.cert.pem"
11
- @cert_name = "local.dev"
12
- @install_on_up = false
13
- @remove_on_destroy = false
14
- @manage_firefox = false
15
- @manage_nss_browsers = true
16
- @locale = "en"
17
- @verbose = false
18
- @container_name = nil
27
+ @cert_path = UNSET_VALUE
28
+ @cert_name = UNSET_VALUE
29
+ @install_on_up = UNSET_VALUE
30
+ @remove_on_destroy = UNSET_VALUE
31
+ @manage_firefox = UNSET_VALUE
32
+ @manage_nss_browsers = UNSET_VALUE
33
+ @locale = UNSET_VALUE
34
+ @verbose = UNSET_VALUE
35
+ @container_name = UNSET_VALUE
36
+ @generate_on_up = UNSET_VALUE
37
+ @ca_cn = UNSET_VALUE
38
+ @ca_days = UNSET_VALUE
39
+ @server_domain = UNSET_VALUE
40
+ @crl_url = UNSET_VALUE
19
41
  end
20
42
 
21
43
  def finalize!
22
- @cert_path = @container_name unless @container_name.to_s.strip.empty?
44
+ @cert_path = "certs/rootca.cert.pem" if @cert_path == UNSET_VALUE
45
+ @cert_name = "local.dev" if @cert_name == UNSET_VALUE
46
+ @install_on_up = false if @install_on_up == UNSET_VALUE
47
+ @remove_on_destroy = false if @remove_on_destroy == UNSET_VALUE
48
+ @manage_firefox = false if @manage_firefox == UNSET_VALUE
49
+ @manage_nss_browsers = true if @manage_nss_browsers == UNSET_VALUE
50
+ @locale = "en" if @locale == UNSET_VALUE
51
+ @verbose = false if @verbose == UNSET_VALUE
52
+ @container_name = nil if @container_name == UNSET_VALUE
53
+ @generate_on_up = false if @generate_on_up == UNSET_VALUE
54
+ @ca_cn = "local-ca" if @ca_cn == UNSET_VALUE
55
+ @ca_days = 3650 if @ca_days == UNSET_VALUE
56
+ @server_domain = nil if @server_domain == UNSET_VALUE
57
+ @crl_url = nil if @crl_url == UNSET_VALUE
58
+
23
59
  @install_on_up = !!@install_on_up
24
60
  @remove_on_destroy = !!@remove_on_destroy
25
61
  @manage_firefox = !!@manage_firefox
26
62
  @manage_nss_browsers = !!@manage_nss_browsers
27
63
  @verbose = !!@verbose
64
+ @generate_on_up = !!@generate_on_up
28
65
  @locale = (@locale || "en").to_s
66
+ @ca_cn = (@ca_cn || "local-ca").to_s
67
+ @ca_days = @ca_days.to_i
29
68
  end
30
69
 
31
70
  def validate(_machine)
32
71
  errors = []
33
72
  errors << "cert_path must be provided" if @cert_path.to_s.strip.empty?
34
73
  errors << "cert_name must be provided" if @cert_name.to_s.strip.empty?
74
+ unless @locale.is_a?(String) && %w[en fr].include?(@locale.to_s[0, 2].downcase)
75
+ errors << "locale must be 'en' or 'fr'"
76
+ end
77
+ errors << "ca_days must be a positive integer" if @generate_on_up && @ca_days.to_i <= 0
35
78
  { "vagrant-docker-certificates-manager" => errors }
36
79
  end
80
+
81
+ def cert_dir
82
+ d = File.dirname(@cert_path.to_s)
83
+ d.empty? ? "certs" : d
84
+ end
37
85
  end
38
86
  end
@@ -21,7 +21,8 @@ module VagrantDockerCertificatesManager
21
21
  error: "❌",
22
22
  version: "💾",
23
23
  broom: "🧹",
24
- question: "❓"
24
+ question: "❓",
25
+ bug: "🐛"
25
26
  }.freeze
26
27
 
27
28
  module_function
@@ -30,33 +31,34 @@ module VagrantDockerCertificatesManager
30
31
  return if defined?(@i18n_setup) && @i18n_setup
31
32
  ::I18n.enforce_available_locales = false
32
33
  base = File.expand_path("../../locales", __dir__)
33
- ::I18n.load_path |= Dir[File.join(base, "*.yml")]
34
+ paths = Dir[File.join(base, "*.yml")]
35
+ if paths.empty? && File.directory?(base)
36
+ paths = Dir.children(base).grep(/\.ya?ml\z/).map { |file| File.join(base, file) }
37
+ end
38
+ ::I18n.load_path |= paths
34
39
  ::I18n.available_locales = SUPPORTED
35
- default = ((ENV["VDCM_LANG"] || ENV["LANG"] || "en")[0,2] rescue "en").to_sym
40
+ default = ((ENV["VDCM_LANG"] || ENV["LANG"] || "en")[0, 2] rescue "en").to_sym
36
41
  ::I18n.default_locale = SUPPORTED.include?(default) ? default : :en
37
42
  ::I18n.backend.load_translations
38
43
  @i18n_setup = true
39
44
  end
40
45
 
41
46
  def set_locale!(lang, strict: false)
42
- setup_i18n!
43
-
44
- raw = (lang || ENV["VDCM_LANG"] || ENV["LANG"] || "en").to_s
45
- sym = raw[0, 2].to_s.downcase.to_sym
46
- sym = :en if sym.nil? || sym == :""
47
-
48
- unless SUPPORTED.include?(sym)
49
- if strict
50
- raise UnsupportedLocaleError,
51
- "#{e(:error)} Unsupported language: #{sym}. Available: #{SUPPORTED.join(', ')}"
52
- else
53
- sym = :en
54
- end
47
+ setup_i18n!
48
+ raw = (lang || ENV["VDCM_LANG"] || ENV["LANG"] || "en").to_s
49
+ sym = raw[0, 2].to_s.downcase.to_sym
50
+ sym = :en if sym.nil? || sym == :""
51
+ unless SUPPORTED.include?(sym)
52
+ if strict
53
+ raise UnsupportedLocaleError,
54
+ "#{e(:error)} Unsupported language: #{sym}. Available: #{SUPPORTED.join(', ')}"
55
+ else
56
+ sym = :en
55
57
  end
56
-
57
- ::I18n.locale = sym
58
- ::I18n.backend.load_translations
59
- sym
58
+ end
59
+ ::I18n.locale = sym
60
+ ::I18n.backend.load_translations
61
+ sym
60
62
  end
61
63
 
62
64
  def e(key, no_emoji: false)
@@ -88,6 +90,18 @@ module VagrantDockerCertificatesManager
88
90
  v.is_a?(Hash) ? v : {}
89
91
  end
90
92
 
93
+ def exists?(key)
94
+ ::I18n.exists?(ns_key(key), ::I18n.locale)
95
+ end
96
+
97
+ def our_key?(k)
98
+ OUR_SPACES.any? { |ns| k.start_with?("#{NAMESPACE}.#{ns}") || k.start_with?(ns) }
99
+ end
100
+
101
+ def debug_enabled?
102
+ ENV["VDCM_DEBUG"].to_s == "1"
103
+ end
104
+
91
105
  def level_to_emoji(level)
92
106
  case level
93
107
  when :success then :success
@@ -114,8 +128,8 @@ module VagrantDockerCertificatesManager
114
128
  end
115
129
 
116
130
  def debug(env_or_ui, msg)
117
- return unless ENV["VDCM_DEBUG"].to_s == "1"
118
- say(env_or_ui, :info, nil, raw: "#{e(:question)} #{msg}")
131
+ return unless debug_enabled?
132
+ say(env_or_ui, :info, nil, raw: "#{e(:bug)} #{msg}")
119
133
  end
120
134
 
121
135
  def print_general_help(no_emoji: false, ui: nil)
@@ -125,6 +139,10 @@ module VagrantDockerCertificatesManager
125
139
  lines << " #{t('cli.usage')}"
126
140
  t_hash("help.commands").each_value { |line| lines << " #{line}" }
127
141
 
142
+ emit_lines(lines, ui)
143
+ end
144
+
145
+ def emit_lines(lines, ui)
128
146
  if ui
129
147
  lines.each { |ln| ui.info(ln) }
130
148
  else
@@ -137,43 +155,53 @@ module VagrantDockerCertificatesManager
137
155
  topic = (topic || "").to_s.strip.downcase
138
156
  return print_general_help(no_emoji: no_emoji, ui: ui) if topic.empty?
139
157
 
140
- base = "help.topic.#{topic}"
141
- title = t("#{base}.title", default: nil)
142
- usage = t("#{base}.usage", default: nil)
143
- desc = t("#{base}.description", default: nil)
144
- opts = t_hash("#{base}.options")
145
- exs = ::I18n.t("#{base}.examples", default: [])
158
+ fields = topic_help_fields("help.topic.#{topic}")
159
+ return print_general_help(no_emoji: no_emoji, ui: ui) if topic_help_empty?(fields)
146
160
 
147
- if title.nil? && usage.nil? && desc.nil? && opts.empty? && exs.empty?
148
- return print_general_help(no_emoji: no_emoji, ui: ui)
149
- end
161
+ emit_lines(build_topic_help_lines(topic, fields, no_emoji), ui)
162
+ end
163
+
164
+ def topic_help_fields(base)
165
+ {
166
+ title: t("#{base}.title", default: nil),
167
+ usage: t("#{base}.usage", default: nil),
168
+ desc: t("#{base}.description", default: nil),
169
+ opts: t_hash("#{base}.options"),
170
+ exs: ::I18n.t(ns_key("#{base}.examples"), default: [])
171
+ }
172
+ end
173
+
174
+ def topic_help_empty?(fields)
175
+ fields[:title].nil? && fields[:usage].nil? && fields[:desc].nil? &&
176
+ fields[:opts].empty? && fields[:exs].empty?
177
+ end
150
178
 
179
+ def build_topic_help_lines(topic, fields, no_emoji)
151
180
  lines = []
152
- lines << "#{e(:info, no_emoji: no_emoji)} #{title || t('help.topic_fallback_title', topic: topic)}"
181
+ lines << "#{e(:info, no_emoji: no_emoji)} #{fields[:title] || t('help.topic_fallback_title', topic: topic)}"
153
182
  lines << " #{t('help.usage_label')}"
154
- lines << " #{usage || 'vagrant certs help'}"
155
- if desc && !desc.strip.empty?
156
- lines << " #{t('help.description_label')}"
157
- lines << " #{desc}"
158
- end
159
- unless opts.empty?
160
- lines << " #{t('help.options_label')}"
161
- opts.each_value { |line| lines << " #{line}" }
162
- end
163
- if exs.is_a?(Array) && !exs.empty?
164
- lines << " #{t('help.examples_label')}"
165
- exs.each { |ex| lines << " #{ex}" }
183
+ lines << " #{fields[:usage] || 'vagrant certs help'}"
184
+
185
+ desc = fields[:desc]
186
+ append_help_section(lines, t("help.description_label"), [" #{desc}"]) if desc && !desc.strip.empty?
187
+ unless fields[:opts].empty?
188
+ append_help_section(lines, t("help.options_label"), fields[:opts].values.map do |l|
189
+ " #{l}"
190
+ end)
166
191
  end
167
192
 
168
- if ui
169
- lines.each { |ln| ui.info(ln) }
170
- else
171
- lines.each { |ln| puts ln }
193
+ exs = fields[:exs]
194
+ if exs.is_a?(Array) && !exs.empty?
195
+ append_help_section(lines, t("help.examples_label"), exs.map do |ex|
196
+ " #{ex}"
197
+ end)
172
198
  end
199
+ lines
173
200
  end
174
201
 
175
- def our_key?(k)
176
- OUR_SPACES.any? { |ns| k.start_with?("#{NAMESPACE}.#{ns}") || k.start_with?(ns) }
202
+ def append_help_section(lines, label, body_lines)
203
+ lines << " #{label}"
204
+ body_lines.each { |line| lines << line }
177
205
  end
178
206
  end
179
207
  end
@@ -31,5 +31,99 @@ module VagrantDockerCertificatesManager
31
31
  def nickname_for(name)
32
32
  "#{MARKER}:#{name}"
33
33
  end
34
+
35
+ def fingerprint_of(cert)
36
+ OpenSSL::Digest::SHA1.hexdigest(cert.to_der).upcase
37
+ end
38
+
39
+ # Generates a local development certificate authority.
40
+ #
41
+ # The generated CA is intended for local Docker/Vagrant workflows, not as a
42
+ # public browser-trusted certificate authority.
43
+ #
44
+ # @param cn [String] Common name for the CA certificate.
45
+ # @param days [Integer] Validity period in days.
46
+ # @param org [String] Organization attribute written to the subject.
47
+ # @param country [String] Country attribute written to the subject.
48
+ # @return [Array<OpenSSL::X509::Certificate, OpenSSL::PKey::RSA>] Generated certificate and private key.
49
+ def generate_ca(common_name:, days: 3650, org: "VDCM", country: "FR")
50
+ key = OpenSSL::PKey::RSA.generate(2048)
51
+ cert = OpenSSL::X509::Certificate.new
52
+
53
+ cert.version = 2
54
+ cert.serial = 1
55
+ cert.subject = OpenSSL::X509::Name.parse("/CN=#{common_name}/O=#{org}/C=#{country}")
56
+ cert.issuer = cert.subject
57
+ cert.public_key = key.public_key
58
+ cert.not_before = Time.now
59
+ cert.not_after = Time.now + (days * 24 * 60 * 60)
60
+
61
+ ef = OpenSSL::X509::ExtensionFactory.new(cert, cert)
62
+ cert.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true))
63
+ cert.add_extension(ef.create_extension("keyUsage", "keyCertSign,cRLSign", true))
64
+ cert.add_extension(ef.create_extension("subjectKeyIdentifier", "hash"))
65
+ cert.sign(key, OpenSSL::Digest.new("SHA256"))
66
+
67
+ [cert, key]
68
+ end
69
+
70
+ def load_ca(cert_path, key_path)
71
+ [
72
+ OpenSSL::X509::Certificate.new(File.read(cert_path)),
73
+ OpenSSL::PKey::RSA.new(File.read(key_path))
74
+ ]
75
+ end
76
+
77
+ # Generates a server certificate signed by the supplied local CA.
78
+ #
79
+ # Modern clients validate the SAN extension; the CN is kept mainly for
80
+ # readability and compatibility with older tooling.
81
+ #
82
+ # @param ca_cert [OpenSSL::X509::Certificate] CA certificate used as issuer.
83
+ # @param ca_key [OpenSSL::PKey::RSA] CA private key used to sign the certificate.
84
+ # @param domain [String] DNS name written to CN and subjectAltName.
85
+ # @param days [Integer] Validity period in days.
86
+ # @param crl_url [String, nil] Optional CRL distribution point URL.
87
+ # @return [Array<OpenSSL::X509::Certificate, OpenSSL::PKey::RSA>] Generated certificate and private key.
88
+ def generate_server(ca_cert, ca_key, domain:, days: 825, crl_url: nil)
89
+ key = OpenSSL::PKey::RSA.generate(2048)
90
+ cert = OpenSSL::X509::Certificate.new
91
+
92
+ cert.version = 2
93
+ cert.serial = 2
94
+ cert.subject = OpenSSL::X509::Name.parse("/CN=#{domain}")
95
+ cert.issuer = ca_cert.subject
96
+ cert.public_key = key.public_key
97
+ cert.not_before = Time.now
98
+ cert.not_after = Time.now + (days * 24 * 60 * 60)
99
+
100
+ ef = OpenSSL::X509::ExtensionFactory.new(ca_cert, cert)
101
+ cert.add_extension(ef.create_extension("subjectAltName", "DNS:#{domain}"))
102
+ cert.add_extension(ef.create_extension("basicConstraints", "CA:FALSE"))
103
+ cert.add_extension(ef.create_extension("keyUsage", "digitalSignature,keyEncipherment", true))
104
+ cert.add_extension(ef.create_extension("extendedKeyUsage", "serverAuth"))
105
+ unless crl_url.to_s.strip.empty?
106
+ cert.add_extension(ef.create_extension("crlDistributionPoints", "URI:#{crl_url}"))
107
+ end
108
+ cert.sign(ca_key, OpenSSL::Digest.new("SHA256"))
109
+
110
+ [cert, key]
111
+ end
112
+
113
+ # Generates an empty certificate revocation list for the local CA.
114
+ #
115
+ # @param ca_cert [OpenSSL::X509::Certificate] CA certificate used as issuer.
116
+ # @param ca_key [OpenSSL::PKey::RSA] CA private key used to sign the CRL.
117
+ # @param days [Integer] Validity period in days.
118
+ # @return [OpenSSL::X509::CRL] Signed CRL.
119
+ def generate_crl(ca_cert, ca_key, days: 3650)
120
+ crl = OpenSSL::X509::CRL.new
121
+ crl.issuer = ca_cert.subject
122
+ crl.version = 1
123
+ crl.last_update = Time.now
124
+ crl.next_update = Time.now + (days * 24 * 60 * 60)
125
+ crl.sign(ca_key, OpenSSL::Digest.new("SHA256"))
126
+ crl
127
+ end
34
128
  end
35
129
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "base64"
4
5
  require_relative "cert"
5
6
 
6
7
  module VagrantDockerCertificatesManager
@@ -9,50 +10,57 @@ module VagrantDockerCertificatesManager
9
10
 
10
11
  def detect
11
12
  if defined?(Vagrant) && Vagrant.const_defined?(:Util) && Vagrant::Util.const_defined?(:Platform)
12
- return :mac if Vagrant::Util::Platform.darwin?
13
+ return :mac if Vagrant::Util::Platform.darwin?
13
14
  return :windows if Vagrant::Util::Platform.windows?
14
- return :linux if Vagrant::Util::Platform.linux?
15
+ return :linux if Vagrant::Util::Platform.linux?
15
16
  else
16
17
  plat = RbConfig::CONFIG["host_os"].downcase
17
- return :mac if plat.include?("darwin")
18
+ return :mac if plat.include?("darwin")
18
19
  return :windows if plat =~ /mswin|mingw|windows/
19
- return :linux if plat.include?("linux")
20
+ return :linux if plat.include?("linux")
20
21
  end
21
22
  :unknown
22
23
  end
23
24
 
24
- def run(cmd)
25
- out, err, st = Open3.capture3(cmd)
25
+ # Runs an external command safely using the array form (no shell interpolation).
26
+ def run(*args)
27
+ out, err, st = Open3.capture3(*args)
26
28
  [st.success?, out, err]
27
29
  end
28
30
 
29
- def mac_add_trusted_cert(path, name)
30
- ok, *_ = run(%(sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "#{path}"))
31
- ok
31
+ # ── macOS ─────────────────────────────────────────────────────────────────
32
+
33
+ def mac_add_trusted_cert(path, _name)
34
+ run("sudo", "security", "add-trusted-cert",
35
+ "-d", "-r", "trustRoot",
36
+ "-k", "/Library/Keychains/System.keychain",
37
+ path.to_s).first
32
38
  end
33
39
 
34
40
  def mac_has_cert_fingerprint?(fp)
35
- ok, out, _ = run(%(security find-certificate -a -Z /Library/Keychains/System.keychain 2>/dev/null))
36
- return false unless ok
37
- out.include?(fp)
41
+ ok, out, = run("security", "find-certificate", "-a", "-Z",
42
+ "/Library/Keychains/System.keychain")
43
+ ok && out.include?(fp.to_s)
38
44
  end
39
45
 
40
46
  def mac_remove_by_fp(fp)
41
- ok, out, _ = run(%(security find-certificate -a -Z /Library/Keychains/System.keychain 2>/dev/null))
47
+ ok, out, = run("security", "find-certificate", "-a", "-Z",
48
+ "/Library/Keychains/System.keychain")
42
49
  return true unless ok
43
- hash = out.lines.find { |l| l =~ /SHA-1 hash:\s*#{fp}/i } ? fp : nil
50
+ hash = out.lines.find { |l| l =~ /SHA-1 hash:\s*#{Regexp.escape(fp)}/i } ? fp : nil
44
51
  return true unless hash
45
- run(%(sudo security delete-certificate -Z #{hash} /Library/Keychains/System.keychain)).first
52
+ run("sudo", "security", "delete-certificate",
53
+ "-Z", hash.to_s, "/Library/Keychains/System.keychain").first
46
54
  end
47
55
 
56
+ # ── Linux ─────────────────────────────────────────────────────────────────
57
+
48
58
  def linux_install_cert(path, name, nss: true, firefox: false)
49
59
  dest = "/usr/local/share/ca-certificates/#{Cert::MARKER.downcase}-#{name}.crt"
50
- ok1, = run(%(sudo cp "#{path}" "#{dest}"))
51
- ok2, = run("sudo update-ca-certificates")
52
- okn = true
53
- okn &&= linux_nss_install(path, name) if nss
54
- okf = true
55
- okf &&= linux_firefox_install(path, name) if firefox
60
+ ok1, = run("sudo", "cp", path.to_s, dest)
61
+ ok2, = run("sudo", "update-ca-certificates")
62
+ okn = nss ? linux_nss_install(path, name) : true
63
+ okf = firefox ? linux_firefox_install(path, name) : true
56
64
  ok1 && ok2 && okn && okf
57
65
  end
58
66
 
@@ -62,71 +70,149 @@ module VagrantDockerCertificatesManager
62
70
 
63
71
  def linux_uninstall_cert(name, nss: true, firefox: false)
64
72
  dest = "/usr/local/share/ca-certificates/#{Cert::MARKER.downcase}-#{name}.crt"
65
- run(%(sudo rm -f "#{dest}"))
66
- run("sudo update-ca-certificates")
67
- linux_nss_uninstall(name) if nss
73
+ run("sudo", "rm", "-f", dest)
74
+ run("sudo", "update-ca-certificates")
75
+ linux_nss_uninstall(name) if nss
68
76
  linux_firefox_uninstall(name) if firefox
69
77
  true
70
78
  end
71
79
 
72
80
  def linux_nss_install(path, name)
73
- db = %(sql:"$HOME/.pki/nssdb")
74
- run(%(certutil -d #{db} -A -t "C,," -n "#{Cert.nickname_for(name)}" -i "#{path}")).first
81
+ db = "sql:#{File.join(Dir.home, '.pki', 'nssdb')}"
82
+ run("certutil", "-d", db, "-A", "-t", "C,,",
83
+ "-n", Cert.nickname_for(name), "-i", path.to_s).first
75
84
  end
76
85
 
77
86
  def linux_nss_uninstall(name)
78
- db = %(sql:"$HOME/.pki/nssdb")
79
- run(%(certutil -d #{db} -D -n "#{Cert.nickname_for(name)}"))
87
+ db = "sql:#{File.join(Dir.home, '.pki', 'nssdb')}"
88
+ run("certutil", "-d", db, "-D", "-n", Cert.nickname_for(name))
80
89
  true
81
90
  end
82
91
 
83
92
  def linux_firefox_profiles
84
- home = ENV["HOME"]
93
+ home = Dir.home
85
94
  [
86
95
  "#{home}/.mozilla/firefox",
87
96
  "#{home}/.var/app/org.mozilla.firefox/.mozilla/firefox",
88
97
  "#{home}/snap/firefox/common/.mozilla/firefox"
89
- ].select { |d| File.directory?(d) }.flat_map { |base| Dir.glob(File.join(base, "*.default*")) }
98
+ ].select { |d| File.directory?(d) }
99
+ .flat_map { |base| Dir.glob(File.join(base, "*.default*")) }
90
100
  end
91
101
 
92
102
  def linux_firefox_install(path, name)
93
- profiles = linux_firefox_profiles
94
- profiles.all? do |profile|
95
- run(%(certutil -A -n "#{Cert.nickname_for(name)}" -t "C,," -i "#{path}" -d "sql:#{profile}")).first
103
+ linux_firefox_profiles.all? do |profile|
104
+ run("certutil", "-A",
105
+ "-n", Cert.nickname_for(name), "-t", "C,,",
106
+ "-i", path.to_s, "-d", "sql:#{profile}").first
96
107
  end
97
108
  end
98
109
 
99
110
  def linux_firefox_uninstall(name)
100
111
  linux_firefox_profiles.each do |profile|
101
- run(%(certutil -D -n "#{Cert.nickname_for(name)}" -d "sql:#{profile}"))
112
+ run("certutil", "-D", "-n", Cert.nickname_for(name), "-d", "sql:#{profile}")
113
+ end
114
+ true
115
+ end
116
+
117
+ # ── Windows ───────────────────────────────────────────────────────────────
118
+
119
+ # Firefox on Windows does not ship with NSS certutil, so we enable
120
+ # security.enterprise_roots.enabled in each profile's user.js instead.
121
+ # This makes Firefox delegate trust to the Windows system cert store,
122
+ # which already contains the CA installed by win_install_cert.
123
+
124
+ FIREFOX_ENTERPRISE_ROOTS_PREF = 'user_pref("security.enterprise_roots.enabled", true);'
125
+ FIREFOX_ENTERPRISE_ROOTS_KEY = "security.enterprise_roots.enabled"
126
+
127
+ def win_firefox_profiles
128
+ appdata = (ENV["APPDATA"] || File.join(Dir.home, "AppData", "Roaming")).tr("\\", "/")
129
+ base = "#{appdata}/Mozilla/Firefox/Profiles"
130
+ return [] unless File.directory?(base)
131
+ Dir.glob("#{base}/*").select { |d| File.directory?(d) }
132
+ end
133
+
134
+ def win_firefox_enable_enterprise_roots
135
+ win_firefox_profiles.each do |profile|
136
+ user_js = File.join(profile, "user.js")
137
+ content = File.exist?(user_js) ? File.read(user_js) : ""
138
+ next if content.include?(FIREFOX_ENTERPRISE_ROOTS_KEY)
139
+ File.open(user_js, "a") { |f| f.puts FIREFOX_ENTERPRISE_ROOTS_PREF }
140
+ end
141
+ true
142
+ rescue StandardError
143
+ false
144
+ end
145
+
146
+ def win_firefox_disable_enterprise_roots
147
+ win_firefox_profiles.each do |profile|
148
+ user_js = File.join(profile, "user.js")
149
+ next unless File.exist?(user_js)
150
+ updated = File.read(user_js)
151
+ .lines
152
+ .reject { |l| l.include?(FIREFOX_ENTERPRISE_ROOTS_KEY) }
153
+ .join
154
+ File.write(user_js, updated)
102
155
  end
103
156
  true
157
+ rescue StandardError
158
+ false
104
159
  end
105
160
 
106
161
  def win_install_cert(path, name)
107
- ok, out, err = run(%(certutil -addstore -f "ROOT" "#{path}"))
108
- return false unless ok
109
- fp = Cert.sha1(path)
110
- ps = %(
111
- $cert = Get-ChildItem Cert:\\LocalMachine\\Root | Where-Object { $_.Thumbprint -eq "#{fp}" };
112
- if ($cert) { $cert.FriendlyName = "#{Cert.nickname_for(name)}"; }
113
- ).strip
114
- run(%(powershell -NoProfile -NonInteractive -Command "#{ps}"))
162
+ fp = Cert.sha1(path)
163
+ nick = Cert.nickname_for(name).gsub("'", "''")
164
+ abs = File.expand_path(path).tr("/", "\\").gsub("'", "''")
165
+
166
+ ps = <<~PS
167
+ $ErrorActionPreference = 'Stop'
168
+ Import-Certificate -FilePath '#{abs}' -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
169
+ $cert = Get-ChildItem Cert:\\LocalMachine\\Root | Where-Object { $_.Thumbprint -eq '#{fp}' }
170
+ if ($cert) { $cert.FriendlyName = '#{nick}' }
171
+ PS
172
+ encoded = Base64.strict_encode64(ps.encode("UTF-16LE"))
173
+
174
+ # Try non-elevated first (works if already admin)
175
+ ok, = run("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
176
+
177
+ unless ok
178
+ # Elevate via UAC
179
+ elev = "Start-Process PowerShell -Verb RunAs -Wait " \
180
+ "-ArgumentList '-NonInteractive','-NoProfile','-EncodedCommand','#{encoded}'"
181
+ elev_encoded = Base64.strict_encode64(elev.encode("UTF-16LE"))
182
+ ok, = run("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", elev_encoded)
183
+ return false unless ok
184
+ end
185
+
186
+ win_firefox_enable_enterprise_roots
115
187
  true
116
188
  end
117
189
 
118
190
  def win_has_cert_fingerprint?(fp)
119
- ps = %q{
120
- $c = Get-ChildItem Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq "__FP__" };
121
- if ($c) { "YES" } else { "NO" }
122
- }.strip.gsub("__FP__", fp.to_s)
123
-
124
- ok, out, _ = run(%(powershell -NoProfile -NonInteractive -Command "#{ps}"))
125
- ok && out.to_s.strip == "YES"
191
+ ps = "if (Get-ChildItem Cert:\\LocalMachine\\Root | " \
192
+ "Where-Object { $_.Thumbprint -eq '#{fp}' }) { 'YES' } else { 'NO' }"
193
+ ok, out, = run("powershell", "-NoProfile", "-NonInteractive",
194
+ "-EncodedCommand", Base64.strict_encode64(ps.encode("UTF-16LE")))
195
+ ok && out.to_s.strip == "YES"
126
196
  end
127
197
 
128
198
  def win_remove_by_fp(fp)
129
- run(%(certutil -delstore "ROOT" #{fp})).first
199
+ ps = <<~PS
200
+ $ErrorActionPreference = 'Stop'
201
+ Get-ChildItem Cert:\\LocalMachine\\Root |
202
+ Where-Object { $_.Thumbprint -eq '#{fp}' } |
203
+ Remove-Item
204
+ PS
205
+ encoded = Base64.strict_encode64(ps.encode("UTF-16LE"))
206
+
207
+ ok, = run("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
208
+ return true if ok
209
+
210
+ elev = "Start-Process PowerShell -Verb RunAs -Wait " \
211
+ "-ArgumentList '-NonInteractive','-NoProfile','-EncodedCommand','#{encoded}'"
212
+ elev_encoded = Base64.strict_encode64(elev.encode("UTF-16LE"))
213
+ ok = run("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", elev_encoded).first
214
+ win_firefox_disable_enterprise_roots if ok
215
+ ok
130
216
  end
131
217
  end
132
218
  end
@@ -15,11 +15,17 @@ module VagrantDockerCertificatesManager
15
15
  FileUtils.mkdir_p(File.dirname(db_path))
16
16
  end
17
17
 
18
+ # Loads tracked certificates from the best-effort local registry.
19
+ #
20
+ # A corrupt or missing registry must not block Vagrant actions, because the
21
+ # OS trust store remains the source of truth.
22
+ #
23
+ # @return [Hash] Registry data keyed by certificate fingerprint.
18
24
  def load
19
25
  ensure_dir!
20
26
  return {} unless File.exist?(db_path)
21
27
  JSON.parse(File.read(db_path))
22
- rescue
28
+ rescue StandardError
23
29
  {}
24
30
  end
25
31
 
@@ -28,6 +34,11 @@ module VagrantDockerCertificatesManager
28
34
  File.write(db_path, JSON.pretty_generate(data))
29
35
  end
30
36
 
37
+ # Tracks a certificate fingerprint and its ownership metadata.
38
+ #
39
+ # @param fp [String] Certificate fingerprint.
40
+ # @param attrs [Hash] Persisted metadata such as path, name, OS, and owners.
41
+ # @return [void]
31
42
  def track(fp, attrs)
32
43
  data = load
33
44
  data[fp] = attrs
@@ -41,6 +52,42 @@ module VagrantDockerCertificatesManager
41
52
  removed
42
53
  end
43
54
 
55
+ # Adds an owner to an already tracked certificate.
56
+ #
57
+ # @param fp [String] Certificate fingerprint.
58
+ # @param owner [String, #to_s] Vagrant machine id adopting the certificate.
59
+ # @return [Boolean] Whether the certificate was already tracked.
60
+ # rubocop:disable Naming/PredicateMethod -- boolean return kept for symmetry with #release
61
+ def adopt(fp, owner)
62
+ data = load
63
+ return false unless data[fp]
64
+ data[fp]["owners"] = Array(data[fp]["owners"]) | [owner.to_s]
65
+ save(data)
66
+ true
67
+ end
68
+ # rubocop:enable Naming/PredicateMethod
69
+
70
+ # Releases one owner from a tracked certificate.
71
+ #
72
+ # Keep the certificate installed while at least one Vagrant machine still
73
+ # owns it.
74
+ #
75
+ # @param fp [String] Certificate fingerprint.
76
+ # @param owner [String, #to_s] Vagrant machine id releasing the certificate.
77
+ # @return [Boolean] Whether other owners remain after the release.
78
+ # rubocop:disable Naming/PredicateMethod -- boolean return kept for symmetry with #adopt
79
+ def release(fp, owner)
80
+ data = load
81
+ rec = data[fp]
82
+ return false unless rec
83
+ owners = Array(rec["owners"])
84
+ owners.delete(owner.to_s)
85
+ rec["owners"] = owners
86
+ save(data)
87
+ !owners.empty?
88
+ end
89
+ # rubocop:enable Naming/PredicateMethod
90
+
44
91
  def find_by_path(path)
45
92
  data = load
46
93
  data.find { |_fp, v| File.expand_path(v["path"]) == File.expand_path(path) }
@@ -49,5 +96,9 @@ module VagrantDockerCertificatesManager
49
96
  def all
50
97
  load
51
98
  end
99
+
100
+ def others_for_os?(fp, os)
101
+ load.any? { |k, v| k != fp && v["os"].to_s == os.to_s }
102
+ end
52
103
  end
53
104
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VagrantDockerCertificatesManager
4
- VERSION = begin
5
- path = File.expand_path("VERSION", __dir__)
6
- File.exist?(path) ? File.read(path).strip : "0.1.0"
7
- rescue
8
- "0.1.0"
4
+ unless defined?(VERSION)
5
+ VERSION = begin
6
+ path = File.expand_path("VERSION", __dir__)
7
+ File.exist?(path) ? File.read(path).strip : "0.1.0"
8
+ rescue StandardError
9
+ "0.1.0"
10
+ end
9
11
  end
10
12
  end
data/locales/en.yml CHANGED
@@ -80,14 +80,3 @@ en:
80
80
  success: "Certificate %{name} removed."
81
81
  fail: "Failed to remove certificate %{name}."
82
82
  skip: "Remove on destroy disabled; skipping."
83
-
84
- errors:
85
- invalid_path: "Invalid certificate path: %{path}"
86
- missing_path_remove: "You must provide a path for removal."
87
- not_found_for_remove: "No tracked certificate found for path: %{path}"
88
- already_present: "The certificate %{name} already exists."
89
- install_failed: "Certificate installation failed."
90
- uninstall_failed: "Certificate removal failed."
91
- remove_failed: "Remove failed."
92
- os_unsupported: "Unsupported OS for this action."
93
- unknown_command: "Unknown command: %{cmd}"
data/locales/fr.yml CHANGED
@@ -80,14 +80,3 @@ fr:
80
80
  success: "Certificat %{name} supprimé."
81
81
  fail: "Échec de la suppression du certificat %{name}."
82
82
  skip: "Suppression à la destruction désactivée ; on ignore."
83
-
84
- errors:
85
- invalid_path: "Chemin de certificat invalide : %{path}"
86
- missing_path_remove: "Vous devez fournir un chemin à supprimer."
87
- not_found_for_remove: "Aucun certificat suivi pour le chemin : %{path}"
88
- already_present: "Le certificat %{name} existe déjà."
89
- install_failed: "Échec de l'installation du certificat."
90
- uninstall_failed: "Échec de la suppression du certificat."
91
- remove_failed: "Échec de la suppression."
92
- os_unsupported: "Système non pris en charge pour cette action."
93
- unknown_command: "Commande inconnue : %{cmd}"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vagrant-docker-certificates-manager
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
7
  - Julien Poirou
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-20 00:00:00.000000000 Z
11
+ date: 2026-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: i18n