yamine 0.9.3 → 0.10.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 +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/yamine/certs.rb +10 -18
- data/lib/yamine/cli/system.rb +37 -6
- data/lib/yamine/trust.rb +121 -59
- data/lib/yamine/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db8822f4d49064d2cb61c2219c43c7049eafc7e44a2bb4b918981ef7a4bd8d11
|
|
4
|
+
data.tar.gz: 31c30cc196665056aab7f711c092003f775af0d684fc891adcf039da1603a96b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85e04f77e692d458765ef87714bb83dac175f387dec1bba92bb46e4ac405d292d693a0292187bf427b6fc56bbbbf1263635bdcbcbd3426295531235aab51dc67
|
|
7
|
+
data.tar.gz: 226f812a9accf7265fcae4f1ae74328bb6229ee9199af790e2d744085fed517f7445a6c501c94b6ec188d8f0575677f91753831b9ef502349b4bf2f873b5dbf3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.0] — 2026-09-10
|
|
4
|
+
|
|
5
|
+
The ask-local rename is finished: the two user-visible names it left
|
|
6
|
+
behind are now yamine's own, and both migrate existing machines.
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- **The CA is named "Yamine CA"** (was "Ask Local CA", inherited from
|
|
11
|
+
the rename). The old name stays in `Certs::LEGACY_CA_COMMON_NAMES`, so
|
|
12
|
+
a machine carrying a pre-rename CA can still shed it. Because
|
|
13
|
+
`valid_pair?` requires the on-disk CA to carry the current name, the
|
|
14
|
+
first run after upgrading generates a new CA and re-trusts it — once,
|
|
15
|
+
automatically, as part of the normal trust path.
|
|
16
|
+
- **The launchd service is `dev.yamine`** (was `dev.ask.local`). The old
|
|
17
|
+
plist is booted out and deleted during install and uninstall: a label
|
|
18
|
+
is how launchctl addresses a service, so leaving the old one loaded
|
|
19
|
+
would put two root proxies in a race for port 443, the loser
|
|
20
|
+
crash-looping under `KeepAlive`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- `prune_stale` protects the CA a running proxy signs with, identified
|
|
25
|
+
by signature rather than by skipping the prune entirely. A proxy loads
|
|
26
|
+
its CA once at startup and mints host certs from that in-memory copy,
|
|
27
|
+
so it keeps signing with its boot-time CA whatever is on disk —
|
|
28
|
+
removing that certificate would break TLS for every live route until a
|
|
29
|
+
restart. Since superseded CAs share a name with the live one, only a
|
|
30
|
+
signature check can tell them apart.
|
|
31
|
+
|
|
32
|
+
|
|
3
33
|
## [0.9.3] — 2026-09-10
|
|
4
34
|
|
|
5
35
|
### Fixed
|
data/lib/yamine/certs.rb
CHANGED
|
@@ -11,23 +11,15 @@ module Yamine
|
|
|
11
11
|
# through the SNI callback and held in an in-memory LRU
|
|
12
12
|
# (puma-dev ssl.go does the same; portless caches on disk instead).
|
|
13
13
|
module Certs
|
|
14
|
-
# The name is
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
# longer decide what gets removed.
|
|
24
|
-
#
|
|
25
|
-
# CA_COMMON_NAMES is the list of names we have ever generated. Add to
|
|
26
|
-
# it on any future rename so old certificates stay prunable —
|
|
27
|
-
# Trust.prune_stale only ever removes certificates whose subject is
|
|
28
|
-
# one of these.
|
|
29
|
-
CA_COMMON_NAME = "Ask Local CA"
|
|
30
|
-
CA_COMMON_NAMES = [CA_COMMON_NAME].freeze
|
|
14
|
+
# The CA's name is user-visible in the trust store and is what a
|
|
15
|
+
# cleanup keys off, so it belongs to this gem alone. It was inherited
|
|
16
|
+
# from the ask-local rename and had said "Ask Local CA" ever since.
|
|
17
|
+
CA_COMMON_NAME = "Yamine CA"
|
|
18
|
+
# Every name we have ever generated a CA under. Stale certificates
|
|
19
|
+
# under these are prunable (Trust.prune_stale); anything else in the
|
|
20
|
+
# trust store is none of our business. The pre-rename name stays here
|
|
21
|
+
# so machines that trusted an ask-local CA can still shed it.
|
|
22
|
+
LEGACY_CA_COMMON_NAMES = ["Ask Local CA"].freeze
|
|
31
23
|
CA_VALIDITY_DAYS = 3650
|
|
32
24
|
HOST_VALIDITY_DAYS = 825
|
|
33
25
|
CACHE_SIZE = 1024
|
|
@@ -35,7 +27,7 @@ module Yamine
|
|
|
35
27
|
module_function
|
|
36
28
|
|
|
37
29
|
def ca_common_names
|
|
38
|
-
|
|
30
|
+
[CA_COMMON_NAME, *LEGACY_CA_COMMON_NAMES].uniq
|
|
39
31
|
end
|
|
40
32
|
|
|
41
33
|
def state_dir
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -4,6 +4,13 @@ module Yamine
|
|
|
4
4
|
class CLI
|
|
5
5
|
# System commands: proxy, service, hosts, trust, clean, doctor, kamal.
|
|
6
6
|
module SystemCommand
|
|
7
|
+
# The launchd label: the service's identity, and what `launchctl
|
|
8
|
+
# enable/kickstart/bootout` address it by. Renamed from "dev.ask.local"
|
|
9
|
+
# (an ask-local leftover); the old label is cleared on install so the
|
|
10
|
+
# two never coexist and fight over port 443.
|
|
11
|
+
LAUNCHD_LABEL = "dev.yamine"
|
|
12
|
+
LEGACY_LAUNCHD_LABELS = %w[dev.ask.local].freeze
|
|
13
|
+
|
|
7
14
|
module_function
|
|
8
15
|
|
|
9
16
|
def doctor(ctx, args)
|
|
@@ -291,7 +298,7 @@ module Yamine
|
|
|
291
298
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
292
299
|
<plist version="1.0">
|
|
293
300
|
<dict>
|
|
294
|
-
<key>Label</key><string
|
|
301
|
+
<key>Label</key><string>#{LAUNCHD_LABEL}</string>
|
|
295
302
|
<key>ProgramArguments</key>
|
|
296
303
|
<array>
|
|
297
304
|
<string>#{RbConfig.ruby}</string>
|
|
@@ -309,7 +316,8 @@ module Yamine
|
|
|
309
316
|
</dict>
|
|
310
317
|
</plist>
|
|
311
318
|
PLIST
|
|
312
|
-
|
|
319
|
+
remove_legacy_launchd(dir)
|
|
320
|
+
path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
|
|
313
321
|
File.write(path, plist)
|
|
314
322
|
File.chmod(0o644, path)
|
|
315
323
|
# launchd requires /Library/LaunchDaemons plists to be
|
|
@@ -354,14 +362,34 @@ module Yamine
|
|
|
354
362
|
unless Command.run("launchctl", "bootstrap", "system", path)
|
|
355
363
|
raise Error, "launchctl bootstrap failed — check the plist at #{path}"
|
|
356
364
|
end
|
|
357
|
-
Command.run("launchctl", "enable", "system
|
|
358
|
-
Command.run("launchctl", "kickstart", "-k", "system
|
|
365
|
+
Command.run("launchctl", "enable", "system/#{LAUNCHD_LABEL}")
|
|
366
|
+
Command.run("launchctl", "kickstart", "-k", "system/#{LAUNCHD_LABEL}")
|
|
359
367
|
end
|
|
360
368
|
|
|
361
369
|
def launchctl_bootout(path)
|
|
362
370
|
Command.run("launchctl", "bootout", "system", path)
|
|
363
371
|
end
|
|
364
372
|
|
|
373
|
+
# The launchd label, and the pre-rename one that must be cleared.
|
|
374
|
+
# A label is the service's identity: installing the new one while
|
|
375
|
+
# the old plist is still loaded would leave TWO root proxies
|
|
376
|
+
# fighting over port 443 (whichever booted last wins, and the loser
|
|
377
|
+
# crash-loops under KeepAlive).
|
|
378
|
+
#
|
|
379
|
+
# Boot out and delete any plist from a previous label. Idempotent:
|
|
380
|
+
# bootout on a service that was never loaded prints an error and
|
|
381
|
+
# exits non-zero, which is expected and quiet.
|
|
382
|
+
def remove_legacy_launchd(dir = "/Library/LaunchDaemons")
|
|
383
|
+
LEGACY_LAUNCHD_LABELS.each do |label|
|
|
384
|
+
path = File.join(dir, "#{label}.plist")
|
|
385
|
+
next unless File.file?(path)
|
|
386
|
+
|
|
387
|
+
Command.run("launchctl", "bootout", "system", path, out: File::NULL, err: File::NULL)
|
|
388
|
+
FileUtils.rm_f(path)
|
|
389
|
+
puts " Removed legacy launchd service #{label}."
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
365
393
|
# Pure unit-file builder (testable without root). Binds 80/443 at
|
|
366
394
|
# boot; the proxy runs with the invoking user's state dir.
|
|
367
395
|
def systemd_unit
|
|
@@ -406,9 +434,12 @@ module Yamine
|
|
|
406
434
|
end
|
|
407
435
|
case RUBY_PLATFORM
|
|
408
436
|
when /darwin/
|
|
409
|
-
path = "/Library/LaunchDaemons
|
|
410
|
-
launchctl_bootout(path)
|
|
437
|
+
path = "/Library/LaunchDaemons/#{LAUNCHD_LABEL}.plist"
|
|
438
|
+
launchctl_bootout(path) if File.file?(path)
|
|
411
439
|
FileUtils.rm_f(path)
|
|
440
|
+
# Also clear any pre-rename service, so uninstall leaves no root
|
|
441
|
+
# proxy behind on a machine upgraded from an older yamine.
|
|
442
|
+
remove_legacy_launchd
|
|
412
443
|
puts "Removed root LaunchDaemon."
|
|
413
444
|
when /linux/
|
|
414
445
|
Command.run("systemctl", "disable", "--now", "yamine")
|
data/lib/yamine/trust.rb
CHANGED
|
@@ -32,8 +32,8 @@ module Yamine
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# The fingerprint `security -Z` prints: uppercase hex, no colons.
|
|
35
|
-
# Certificates are identified by THIS, never by common name
|
|
36
|
-
#
|
|
35
|
+
# Certificates are identified by THIS, never by common name: every CA
|
|
36
|
+
# this project has generated shares a name with the others, so a
|
|
37
37
|
# by-name lookup or delete cannot tell the current CA from a stale
|
|
38
38
|
# one and may remove the wrong certificate.
|
|
39
39
|
def fingerprint_of(cert_path)
|
|
@@ -44,93 +44,155 @@ module Yamine
|
|
|
44
44
|
nil
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
# Certificates in a keychain as { fingerprint:, subject:, cert: }.
|
|
48
|
+
# Parsed in ONE call; empty when the keychain (or `security`) is
|
|
49
|
+
# unavailable, which callers treat as "cannot tell".
|
|
50
|
+
#
|
|
51
|
+
# -a is required: without it `security find-certificate` returns a
|
|
52
|
+
# single certificate, so any scan silently sees one entry and every
|
|
53
|
+
# comparison against it is wrong.
|
|
54
|
+
def keychain_certs(keychain)
|
|
55
|
+
out, status = Command.capture2("security", "find-certificate", "-a", "-Z", "-p", keychain)
|
|
55
56
|
return [] unless status.success?
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
require "digest"
|
|
59
|
+
out.split("-----BEGIN CERTIFICATE-----").filter_map do |block|
|
|
60
|
+
next unless block.include?("-----END CERTIFICATE-----")
|
|
61
|
+
|
|
62
|
+
cert = begin
|
|
63
|
+
OpenSSL::X509::Certificate.new("-----BEGIN CERTIFICATE-----" + block)
|
|
64
|
+
rescue OpenSSL::OpenSSLError
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
{ fingerprint: Digest::SHA1.hexdigest(cert.to_der).upcase,
|
|
68
|
+
subject: cert.subject.to_s,
|
|
69
|
+
cert: cert }
|
|
70
|
+
end
|
|
58
71
|
rescue SystemCallError
|
|
59
72
|
[]
|
|
60
73
|
end
|
|
61
74
|
|
|
75
|
+
# Fingerprints of certificates in a keychain, optionally limited to
|
|
76
|
+
# one common name.
|
|
77
|
+
def keychain_fingerprints(keychain, common_name: nil)
|
|
78
|
+
certs = keychain_certs(keychain)
|
|
79
|
+
certs = certs.select { |c| c[:subject].include?(common_name) } if common_name
|
|
80
|
+
certs.map { |c| c[:fingerprint] }
|
|
81
|
+
end
|
|
82
|
+
|
|
62
83
|
def keychains
|
|
63
84
|
[login_keychain, "/Library/Keychains/System.keychain"]
|
|
64
85
|
end
|
|
65
86
|
|
|
66
87
|
# Remove trusted certificates that carry one of our CA names but are
|
|
67
|
-
#
|
|
88
|
+
# neither the CA on disk nor the one a live proxy is signing with.
|
|
68
89
|
#
|
|
69
|
-
# Regeneration is normal (the CA is rebuilt when it is missing,
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
# liability.
|
|
90
|
+
# Regeneration is normal (the CA is rebuilt when it is missing,
|
|
91
|
+
# expiring, or renamed), and each rebuild produced a new certificate
|
|
92
|
+
# that was trusted and NEVER removed: one machine accumulated 14
|
|
93
|
+
# distinct trusted roots, all named "Ask Local CA". Apple's keychain
|
|
94
|
+
# keeps its own copy, so deleting the state dir does not untrust
|
|
95
|
+
# anything, and a trusted root whose superseded private key is still
|
|
96
|
+
# on disk (or in a backup) is a real liability.
|
|
77
97
|
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
# The running proxy's CA is protected by SIGNATURE, not by name:
|
|
99
|
+
# `server_context` loads the CA once at startup and the SNI callback
|
|
100
|
+
# mints host certs from that in-memory copy, so a live proxy keeps
|
|
101
|
+
# using its boot-time CA whatever is on disk. Deleting that
|
|
102
|
+
# certificate would break TLS for every live route until a restart,
|
|
103
|
+
# and since superseded CAs share a name with the live one, only a
|
|
104
|
+
# signature check can tell them apart.
|
|
105
|
+
def prune_stale(dir = Certs.state_dir, keychains: nil)
|
|
87
106
|
current = fingerprint_of(Certs.ca_paths(dir)[:cert])
|
|
88
107
|
return 0 unless current
|
|
89
108
|
|
|
90
109
|
names = Certs.ca_common_names
|
|
110
|
+
leaf = live_proxy_cert
|
|
91
111
|
(keychains || self.keychains).sum do |keychain|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
keychain_certs(keychain).count do |entry|
|
|
113
|
+
next false if entry[:fingerprint] == current
|
|
114
|
+
next false unless names.any? { |n| entry[:subject].include?(n) }
|
|
115
|
+
next false if leaf && signed_by?(leaf, entry[:cert])
|
|
116
|
+
|
|
117
|
+
Command.capture2("security", "delete-certificate", "-Z", entry[:fingerprint], keychain)
|
|
118
|
+
true
|
|
119
|
+
end
|
|
98
120
|
end
|
|
99
121
|
rescue SystemCallError
|
|
100
122
|
0
|
|
101
123
|
end
|
|
102
124
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
125
|
+
# True when `cert` is the issuer of `leaf` — i.e. that CA signed it.
|
|
126
|
+
# This is what identifies the live proxy's CA exactly.
|
|
127
|
+
def signed_by?(leaf, cert)
|
|
128
|
+
leaf.verify(cert.public_key)
|
|
107
129
|
rescue StandardError
|
|
108
130
|
false
|
|
109
131
|
end
|
|
110
132
|
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# -a is required: without it `security` returns a single certificate,
|
|
116
|
-
# so the subject check silently answered "not ours" for every
|
|
117
|
-
# certificate but one.
|
|
118
|
-
def our_cert?(keychain, fingerprint, names)
|
|
119
|
-
out, status = Command.capture2("security", "find-certificate", "-a", "-Z", "-p", keychain)
|
|
120
|
-
return false unless status.success?
|
|
133
|
+
# A certificate minted by the running proxy, whose issuer is the CA it
|
|
134
|
+
# is signing with, or nil when no proxy is serving.
|
|
135
|
+
def live_proxy_cert(store = nil)
|
|
136
|
+
return nil unless defined?(ProxyControl)
|
|
121
137
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
store ||= RouteStore.new(Certs.state_dir)
|
|
139
|
+
port = ProxyControl.serving_port(store)
|
|
140
|
+
return nil unless port
|
|
125
141
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
end
|
|
131
|
-
next false unless Digest::SHA1.hexdigest(cert.to_der).upcase == fingerprint
|
|
142
|
+
proxy_peer_cert(port)
|
|
143
|
+
rescue StandardError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
132
146
|
|
|
133
|
-
|
|
147
|
+
def proxy_peer_cert(port, host = "yamine-ca-probe.localhost")
|
|
148
|
+
sock = TCPSocket.new("127.0.0.1", port)
|
|
149
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
150
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
151
|
+
ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
|
|
152
|
+
ssl.hostname = host if ssl.respond_to?(:hostname=)
|
|
153
|
+
ssl.connect
|
|
154
|
+
ssl.peer_cert
|
|
155
|
+
ensure
|
|
156
|
+
begin
|
|
157
|
+
ssl&.close
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
begin
|
|
162
|
+
sock&.close unless sock.nil? || sock.closed?
|
|
163
|
+
rescue StandardError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The common name of the CA a running proxy actually signs with,
|
|
169
|
+
# read from the issuer of a certificate it mints, or nil when no
|
|
170
|
+
# proxy is serving or the name cannot be determined.
|
|
171
|
+
def serving_ca_common_name(store = nil)
|
|
172
|
+
leaf = live_proxy_cert(store)
|
|
173
|
+
return nil unless leaf
|
|
174
|
+
|
|
175
|
+
leaf.issuer.to_s[/CN=([^\/,]+)/, 1]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def proxy_peer_cert(port, host = "yamine-ca-probe.localhost")
|
|
179
|
+
sock = TCPSocket.new("127.0.0.1", port)
|
|
180
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
181
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
182
|
+
ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
|
|
183
|
+
ssl.hostname = host if ssl.respond_to?(:hostname=)
|
|
184
|
+
ssl.connect
|
|
185
|
+
ssl.peer_cert
|
|
186
|
+
ensure
|
|
187
|
+
begin
|
|
188
|
+
ssl&.close
|
|
189
|
+
rescue StandardError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
begin
|
|
193
|
+
sock&.close unless sock.nil? || sock.closed?
|
|
194
|
+
rescue StandardError
|
|
195
|
+
nil
|
|
134
196
|
end
|
|
135
197
|
end
|
|
136
198
|
|
data/lib/yamine/version.rb
CHANGED