yamine 0.21.2 → 0.22.1
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 +170 -0
- data/README.md +39 -12
- data/lib/ask/skills/yamine/SKILL.md +20 -1
- data/lib/yamine/cli/boot.rb +291 -26
- data/lib/yamine/cli/context.rb +1 -1
- data/lib/yamine/cli/routes.rb +96 -19
- data/lib/yamine/cli/system.rb +20 -10
- data/lib/yamine/cli.rb +3 -2
- data/lib/yamine/doctor.rb +9 -0
- data/lib/yamine/process_tree.rb +55 -0
- data/lib/yamine/proxy.rb +238 -53
- data/lib/yamine/runner.rb +10 -4
- data/lib/yamine/supervisor.rb +147 -15
- data/lib/yamine/trust.rb +183 -8
- data/lib/yamine/version.rb +1 -1
- metadata +1 -1
data/lib/yamine/supervisor.rb
CHANGED
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "monitor"
|
|
5
|
+
require "socket"
|
|
5
6
|
|
|
6
7
|
module Yamine
|
|
7
|
-
# Daemon-owned supervision for managed
|
|
8
|
+
# Daemon-owned supervision for managed apps (puma-dev model).
|
|
8
9
|
#
|
|
9
10
|
# The proxy daemon is long-lived and sees every request, so it owns:
|
|
10
11
|
# last-used tracking (idle kill), tmp/restart.txt watching, backend
|
|
11
|
-
# liveness, and boot-on-request for stopped apps.
|
|
12
|
-
#
|
|
12
|
+
# liveness, and boot-on-request for stopped apps.
|
|
13
|
+
#
|
|
14
|
+
# What it watches is any route that names a directory to boot from
|
|
15
|
+
# (spec.dir) — socket and tcp alike, because a `yamine start` tree
|
|
16
|
+
# registers tcp routes and was therefore invisible to all of it. What
|
|
17
|
+
# it may DO with a route is not the same set: see rebootable?.
|
|
18
|
+
# Static aliases (pid 0, no spec) are never supervised.
|
|
13
19
|
#
|
|
14
20
|
# State is in-memory (the daemon is the only supervisor); routes.json
|
|
15
21
|
# stays declarative. Killing is graceful-first with a short KILL
|
|
16
22
|
# fallback so puma drains.
|
|
17
23
|
class Supervisor
|
|
18
24
|
DEFAULT_IDLE = 900 # 15 minutes
|
|
25
|
+
# Bound on the connect that decides whether a tcp backend is
|
|
26
|
+
# answering. A local port answers in microseconds or not at all; the
|
|
27
|
+
# case that actually blocks is a full accept backlog, and this loop
|
|
28
|
+
# supervises every route on the machine.
|
|
29
|
+
CONNECT_TIMEOUT = 2
|
|
19
30
|
|
|
20
31
|
attr_reader :interval
|
|
21
32
|
|
|
@@ -38,8 +49,32 @@ module Yamine
|
|
|
38
49
|
v.to_f
|
|
39
50
|
end
|
|
40
51
|
|
|
52
|
+
# The daemon watches a route when it knows where the app lives on
|
|
53
|
+
# disk, because that is all restart.txt and the boot path need. Both
|
|
54
|
+
# kinds qualify, and the tcp half is not a nicety: `yamine start`
|
|
55
|
+
# registers kind "tcp" (Runner#boot_run, Runner#adopt), so excluding
|
|
56
|
+
# it made `yamine restart` a no-op for every app yamine itself
|
|
57
|
+
# started — it touched tmp/restart.txt and announced "managed app
|
|
58
|
+
# restarts on next request" for a tree nothing was watching. A spec
|
|
59
|
+
# is required, so static aliases and hand-written routes stay out:
|
|
60
|
+
# there is no directory to watch and no app to restart.
|
|
41
61
|
def supervised?(route)
|
|
42
|
-
|
|
62
|
+
%w[socket tcp].include?(route["kind"]) &&
|
|
63
|
+
route["spec"].is_a?(Hash) && route["spec"]["dir"].is_a?(String)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Can the daemon put this app back on its feet by itself?
|
|
67
|
+
#
|
|
68
|
+
# Only a socket route: its target is derived (dir/tmp/sockets/
|
|
69
|
+
# yamine.sock), so a fresh backend can be pointed at the same route,
|
|
70
|
+
# and the proxy asks the supervisor about socket routes by design. A
|
|
71
|
+
# tcp route's port is a free port chosen at boot and the route
|
|
72
|
+
# records no command to re-run, so there is nothing to rebuild it
|
|
73
|
+
# from — the two halves of "stopped, boots on next request" cannot
|
|
74
|
+
# both be true for one, and the half that is true is the half that
|
|
75
|
+
# matters.
|
|
76
|
+
def rebootable?(route)
|
|
77
|
+
route["kind"] == "socket"
|
|
43
78
|
end
|
|
44
79
|
|
|
45
80
|
def touch(hostname)
|
|
@@ -50,10 +85,14 @@ module Yamine
|
|
|
50
85
|
|
|
51
86
|
# Boot a stopped app on demand. Returns the route (unchanged — the
|
|
52
87
|
# target path is deterministic) or nil on boot failure.
|
|
88
|
+
#
|
|
89
|
+
# A tcp route returns unchanged even when its backend is down: the
|
|
90
|
+
# proxy only ever asks about socket routes, and there is nothing this
|
|
91
|
+
# could rebuild (see rebootable?).
|
|
53
92
|
def ensure_running(route, timeout: 60)
|
|
54
|
-
return route unless supervised?(route)
|
|
93
|
+
return route unless supervised?(route) && rebootable?(route)
|
|
55
94
|
|
|
56
|
-
|
|
95
|
+
backend_alive?(route) ? route : boot(route, timeout)
|
|
57
96
|
end
|
|
58
97
|
|
|
59
98
|
# Start the background supervision thread.
|
|
@@ -83,27 +122,44 @@ module Yamine
|
|
|
83
122
|
|
|
84
123
|
hostname = route["hostname"]
|
|
85
124
|
st = state_for(hostname, route, now)
|
|
125
|
+
alive = backend_alive?(route)
|
|
126
|
+
# The latch below only exists to stop a second kill landing on a
|
|
127
|
+
# backend that is already down, and it is cleared the moment the
|
|
128
|
+
# app is back — booted on request, or `yamine start` again in that
|
|
129
|
+
# directory. Left one-way (as it was) a route was supervised for
|
|
130
|
+
# exactly one event in the life of the daemon, so the second
|
|
131
|
+
# `yamine restart` was the no-op this method exists to remove.
|
|
132
|
+
st[:restarting] = false if alive
|
|
86
133
|
next if st[:restarting]
|
|
87
134
|
|
|
88
135
|
if restart_changed?(route, st)
|
|
89
136
|
@on_event.call("restart.txt changed for #{hostname} — stopping backend")
|
|
90
137
|
kill_backend(route)
|
|
91
138
|
st[:restarting] = true
|
|
92
|
-
elsif !
|
|
93
|
-
@on_event.call("backend for #{hostname} is down —
|
|
139
|
+
elsif !alive
|
|
140
|
+
@on_event.call("backend for #{hostname} is down — #{recovery(route)}")
|
|
94
141
|
st[:restarting] = true
|
|
95
|
-
elsif idle?(st, now)
|
|
96
|
-
@on_event.call("#{hostname} idle — stopping backend (
|
|
142
|
+
elsif idle?(st, now) && idle_kill?(route)
|
|
143
|
+
@on_event.call("#{hostname} idle — stopping backend, #{recovery(route)}")
|
|
97
144
|
kill_backend(route)
|
|
98
145
|
st[:restarting] = true
|
|
99
146
|
end
|
|
100
147
|
end
|
|
101
148
|
end
|
|
102
149
|
|
|
103
|
-
# Kill every
|
|
150
|
+
# Kill every backend the daemon booted (daemon shutdown).
|
|
151
|
+
#
|
|
152
|
+
# Only the ones it would have booted back. A socket app exists
|
|
153
|
+
# because the daemon started it, so it goes down with it — that is
|
|
154
|
+
# the puma-dev contract and the reason this method exists. A
|
|
155
|
+
# `yamine start` app has a supervising process of its own and a
|
|
156
|
+
# lifecycle of its own: the proxy is the listener in front of it, not
|
|
157
|
+
# its parent, and restarting the proxy is no reason to take every
|
|
158
|
+
# developer's app on the machine down. Supervised (watched) is not
|
|
159
|
+
# the same set as owned (booted by us and answerable to us).
|
|
104
160
|
def shutdown
|
|
105
161
|
@store.load_routes.each do |route|
|
|
106
|
-
kill_backend(route) if supervised?(route)
|
|
162
|
+
kill_backend(route) if supervised?(route) && rebootable?(route)
|
|
107
163
|
end
|
|
108
164
|
end
|
|
109
165
|
|
|
@@ -114,6 +170,60 @@ module Yamine
|
|
|
114
170
|
(now - st[:last_used]) > @idle_timeout
|
|
115
171
|
end
|
|
116
172
|
|
|
173
|
+
# Is the app behind this route serving right now? Both kinds, one
|
|
174
|
+
# question: a socket route answers a connect on its socket file, a
|
|
175
|
+
# tcp route answers a connect on its port. The daemon needs this
|
|
176
|
+
# rather than a socket-only probe because tcp routes are now watched
|
|
177
|
+
# (see supervised?) — and it is the same question
|
|
178
|
+
# CLI::Context#backend_alive? answers for `yamine list`, which is
|
|
179
|
+
# what makes the two surfaces able to disagree about one app.
|
|
180
|
+
def backend_alive?(route)
|
|
181
|
+
case route["kind"]
|
|
182
|
+
when "socket" then socket_alive?(route)
|
|
183
|
+
when "tcp"
|
|
184
|
+
host, port = route["target"].to_s.split(":", 2)
|
|
185
|
+
# Bounded: this runs in the daemon's single supervision loop, so
|
|
186
|
+
# a connect that hangs on a full accept backlog would stall every
|
|
187
|
+
# other route's supervision behind it.
|
|
188
|
+
Socket.tcp(host, port.to_i, connect_timeout: CONNECT_TIMEOUT) { |sock| sock.close }
|
|
189
|
+
true
|
|
190
|
+
else
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
rescue SystemCallError, IOError
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Stop an idle backend only when the daemon can bring it back.
|
|
198
|
+
#
|
|
199
|
+
# Idle-kill is the half of puma-dev that depends on the other half: it
|
|
200
|
+
# promises "stopped, boots on next request", and for a tcp route the
|
|
201
|
+
# daemon cannot keep that promise (see rebootable?). Killing one
|
|
202
|
+
# anyway would take a developer's app down for an afternoon and leave
|
|
203
|
+
# a 503 in place of it, so it is opt-in — YAMINE_IDLE_TCP=1 — rather
|
|
204
|
+
# than a default nobody asked for. Watched is not idle-killed:
|
|
205
|
+
# restart.txt and crash detection still work for these routes.
|
|
206
|
+
def idle_kill?(route)
|
|
207
|
+
return true if rebootable?(route)
|
|
208
|
+
|
|
209
|
+
idle_tcp_opt_in?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def idle_tcp_opt_in?
|
|
213
|
+
%w[1 true yes on].include?(ENV["YAMINE_IDLE_TCP"].to_s.strip.downcase)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# What happens next, in the one form that is true for both kinds: a
|
|
217
|
+
# socket app is rebooted on the next request, a tcp app has to be
|
|
218
|
+
# started again by hand. Saying "will boot on next request" for a
|
|
219
|
+
# route nothing will reboot is the exact lie this file stopped
|
|
220
|
+
# telling when tcp routes became supervised.
|
|
221
|
+
def recovery(route)
|
|
222
|
+
return "will boot on next request" if rebootable?(route)
|
|
223
|
+
|
|
224
|
+
"run `yamine start` in #{route.dig("spec", "dir") || "its directory"} again"
|
|
225
|
+
end
|
|
226
|
+
|
|
117
227
|
def socket_alive?(route)
|
|
118
228
|
target = route["target"]
|
|
119
229
|
return false unless target && File.socket?(target)
|
|
@@ -137,14 +247,25 @@ module Yamine
|
|
|
137
247
|
def kill_backend(route)
|
|
138
248
|
pid = backend_pid(route)
|
|
139
249
|
if pid && pid_alive?(pid)
|
|
250
|
+
# By group when the pid leads one, by pid when it does not. A tcp
|
|
251
|
+
# route's sidecar names the `sh -c` shell that `yamine start`
|
|
252
|
+
# wraps every command in, and TERM to that shell alone leaves the
|
|
253
|
+
# app behind it running (linux) — which would make a restarted
|
|
254
|
+
# tcp app keep serving while the route was re-registered, i.e. the
|
|
255
|
+
# old process under a new pid's name (ProcessTree). One syscall,
|
|
256
|
+
# no sleeping: the wait below still owns "and make sure it is
|
|
257
|
+
# gone".
|
|
140
258
|
begin
|
|
141
|
-
|
|
259
|
+
ProcessTree.term(pid)
|
|
142
260
|
wait_exit(pid, 10)
|
|
143
261
|
rescue SystemCallError
|
|
144
262
|
nil
|
|
145
263
|
end
|
|
146
264
|
end
|
|
147
|
-
|
|
265
|
+
# Only a socket target is a file we own and can leave behind; a tcp
|
|
266
|
+
# target is an address, and unlinking the string would only ever
|
|
267
|
+
# unlink a coincidence in the daemon's working directory.
|
|
268
|
+
FileUtils.rm_f(route["target"]) if route["kind"] == "socket" && route["target"]
|
|
148
269
|
FileUtils.rm_f(File.join(@store.dir, "backend-#{route["hostname"]}.pid"))
|
|
149
270
|
end
|
|
150
271
|
|
|
@@ -159,7 +280,18 @@ module Yamine
|
|
|
159
280
|
s = st(hostname)
|
|
160
281
|
# First sighting: baseline so a freshly booted app gets a full
|
|
161
282
|
# idle window and an existing restart.txt doesn't count as changed.
|
|
162
|
-
|
|
283
|
+
#
|
|
284
|
+
# Keyed on :baselined rather than on the mtime being nil, because
|
|
285
|
+
# "no restart.txt" IS a value here: a nil mtime re-baselined on
|
|
286
|
+
# every pass absorbs the first `yamine restart` an app ever gets
|
|
287
|
+
# — the file appears between two ticks, the new mtime becomes the
|
|
288
|
+
# baseline, and the change that was asked for is the change that
|
|
289
|
+
# gets missed. `yamine restart` in an app that had never been
|
|
290
|
+
# restarted did nothing at all, for either kind of route.
|
|
291
|
+
unless s[:baselined]
|
|
292
|
+
s[:restart_mtime] = restart_mtime(route)
|
|
293
|
+
s[:baselined] = true
|
|
294
|
+
end
|
|
163
295
|
s[:last_used] = now if s[:last_used].nil?
|
|
164
296
|
s
|
|
165
297
|
end
|
data/lib/yamine/trust.rb
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
4
5
|
|
|
5
6
|
module Yamine
|
|
6
7
|
# Install the local CA into the OS trust store.
|
|
7
8
|
module Trust
|
|
9
|
+
SYSTEM_KEYCHAIN = "/Library/Keychains/System.keychain"
|
|
10
|
+
|
|
11
|
+
# The policies a browser's TLS stack evaluates a leaf under: SSL
|
|
12
|
+
# server (the hostname check) and X.509 basic (the chain build).
|
|
13
|
+
#
|
|
14
|
+
# They have to be named, and this is measured, not assumed.
|
|
15
|
+
# `add-trusted-cert -r trustRoot` with no `-p` exits 0 and records
|
|
16
|
+
# the certificate in the trust store with NO `trustSettings` array at
|
|
17
|
+
# all: `security dump-trust-settings` reports "Number of trust
|
|
18
|
+
# settings : 0", and `security trust-settings-export` shows a
|
|
19
|
+
# trustList entry holding only issuerName/modDate/serialNumber. What
|
|
20
|
+
# the CA is trusted FOR is then unstated, and whether such an entry
|
|
21
|
+
# is honoured at all depends on the certificate also being installed
|
|
22
|
+
# in a keychain the evaluator searches — on the machine this was
|
|
23
|
+
# fixed on, it was not, and every browser answered
|
|
24
|
+
# ERR_CERT_AUTHORITY_INVALID.
|
|
25
|
+
#
|
|
26
|
+
# With `-p ssl -p basic` the same call records
|
|
27
|
+
# kSecTrustSettingsPolicyName sslServer + basicX509, and per
|
|
28
|
+
# SecTrustSettings.h a settings entry with no explicit
|
|
29
|
+
# kSecTrustSettingsResult defaults to kSecTrustSettingsResultTrustRoot
|
|
30
|
+
# ("trust this root cert"). That is also what Keychain Access writes
|
|
31
|
+
# for a certificate set to Secure Sockets Layer + X.509 Basic.
|
|
32
|
+
MACOS_TRUST_POLICIES = %w[-p ssl -p basic].freeze
|
|
33
|
+
|
|
34
|
+
# SecTrustSettingsResult (Security/SecTrustSettings.h). Only these
|
|
35
|
+
# two grant trust; 3 is Deny and 4 is Unspecified, so a CA whose only
|
|
36
|
+
# setting is one of those is explicitly NOT trusted.
|
|
37
|
+
TRUST_RESULTS = [1, 2].freeze
|
|
38
|
+
|
|
8
39
|
module_function
|
|
9
40
|
|
|
10
41
|
def platform
|
|
@@ -31,6 +62,33 @@ module Yamine
|
|
|
31
62
|
{ trusted: false, error: e.message }
|
|
32
63
|
end
|
|
33
64
|
|
|
65
|
+
# True when the OS trust store will honour the CA in `dir`.
|
|
66
|
+
#
|
|
67
|
+
# The state-dir marker is only half the answer, and the half that
|
|
68
|
+
# lies: it records that WE trusted this exact certificate at some
|
|
69
|
+
# point, so it survives a keychain that never took the setting, a
|
|
70
|
+
# trust run that recorded nothing, and a CA that was regenerated
|
|
71
|
+
# under it. Treating the marker as "trusted" is how a machine kept a
|
|
72
|
+
# CA in its keychain that no browser would accept.
|
|
73
|
+
def trusted?(dir = Certs.state_dir)
|
|
74
|
+
return false unless Certs.trusted?(dir)
|
|
75
|
+
return true unless platform == :macos
|
|
76
|
+
|
|
77
|
+
paths = Certs.ca_paths(dir)
|
|
78
|
+
return false unless File.file?(paths[:cert])
|
|
79
|
+
|
|
80
|
+
macos_keychains.any? { |keychain| already_trusted?(paths[:cert], keychain) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The keychains a CA can be trusted into: the user's own, and — for
|
|
84
|
+
# an elevated run — the System one, which every user on the machine
|
|
85
|
+
# reads. Both are checked because the certificate on disk may have
|
|
86
|
+
# been trusted into either, and a CA trusted only as root is not
|
|
87
|
+
# trusted for a later unprivileged run (or the reverse).
|
|
88
|
+
def macos_keychains
|
|
89
|
+
[login_keychain, SYSTEM_KEYCHAIN].uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
34
92
|
# The fingerprint `security -Z` prints: uppercase hex, no colons.
|
|
35
93
|
# Certificates are identified by THIS, never by common name: every CA
|
|
36
94
|
# this project has generated shares a name with the others, so a
|
|
@@ -81,7 +139,7 @@ module Yamine
|
|
|
81
139
|
end
|
|
82
140
|
|
|
83
141
|
def keychains
|
|
84
|
-
[login_keychain,
|
|
142
|
+
[login_keychain, SYSTEM_KEYCHAIN]
|
|
85
143
|
end
|
|
86
144
|
|
|
87
145
|
# Remove trusted certificates that carry one of our CA names but are
|
|
@@ -206,25 +264,142 @@ module Yamine
|
|
|
206
264
|
return if already_trusted?(cert_path, keychain)
|
|
207
265
|
|
|
208
266
|
_out, status = Command.capture2("security", "add-trusted-cert",
|
|
209
|
-
"-d", "-r", "trustRoot", "-k", keychain, cert_path)
|
|
210
|
-
raise CertError,
|
|
267
|
+
"-d", "-r", "trustRoot", *MACOS_TRUST_POLICIES, "-k", keychain, cert_path)
|
|
268
|
+
raise CertError, macos_trust_error(cert_path, keychain) unless status.success?
|
|
211
269
|
else
|
|
270
|
+
# Not elevated: the user's own keychain, with the trust policies
|
|
271
|
+
# named. The certificate still has to be in a keychain — macOS
|
|
272
|
+
# builds the chain by searching keychains for the issuer, so a
|
|
273
|
+
# trust setting for a certificate that is not installed anywhere
|
|
274
|
+
# is dead weight. Measured: CA trusted but absent from every
|
|
275
|
+
# keychain => "unable to get local issuer certificate".
|
|
212
276
|
keychain = login_keychain
|
|
213
277
|
return if already_trusted?(cert_path, keychain)
|
|
214
278
|
|
|
215
279
|
_out, status = Command.capture2("security", "add-trusted-cert",
|
|
216
|
-
"-r", "trustRoot", "-k", keychain, cert_path)
|
|
217
|
-
raise CertError,
|
|
280
|
+
"-r", "trustRoot", *MACOS_TRUST_POLICIES, "-k", keychain, cert_path)
|
|
281
|
+
raise CertError, macos_trust_error(cert_path, keychain) unless status.success?
|
|
218
282
|
end
|
|
283
|
+
|
|
284
|
+
# `add-trusted-cert` exits 0 for a certificate it merely filed —
|
|
285
|
+
# which is exactly what the policy-less invocation did on every
|
|
286
|
+
# non-elevated install. The exit status is not evidence of trust,
|
|
287
|
+
# only the trust store is: re-read it here, or the state-dir marker
|
|
288
|
+
# records a success no browser will ever agree with.
|
|
289
|
+
return if already_trusted?(cert_path, keychain)
|
|
290
|
+
|
|
291
|
+
raise CertError, macos_trust_error(cert_path, keychain)
|
|
219
292
|
end
|
|
220
293
|
|
|
221
|
-
|
|
222
|
-
|
|
294
|
+
def macos_trust_error(cert_path, keychain)
|
|
295
|
+
domain = (keychain == "/Library/Keychains/System.keychain") ? ["-d"] : []
|
|
296
|
+
command = (["security", "add-trusted-cert", *domain, "-r", "trustRoot",
|
|
297
|
+
*MACOS_TRUST_POLICIES, "-k", keychain, cert_path]).join(" ")
|
|
298
|
+
"macOS recorded no trust setting for the CA in #{keychain}. " \
|
|
299
|
+
"Writing user trust settings needs authorization, which a detached " \
|
|
300
|
+
"or headless run cannot grant — run this in a terminal, then retry: " \
|
|
301
|
+
"#{command}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# True when macOS will actually honour this certificate as a CA.
|
|
305
|
+
#
|
|
306
|
+
# BOTH halves are required, and the second one is the one that used
|
|
307
|
+
# to be missing:
|
|
308
|
+
#
|
|
309
|
+
# 1. the certificate is in the keychain, so the trust evaluator can
|
|
310
|
+
# find the issuer when it builds the chain; and
|
|
311
|
+
# 2. the trust store records a trust SETTING for its fingerprint.
|
|
312
|
+
#
|
|
313
|
+
# Presence alone is not trust: a certificate in a keychain with no
|
|
314
|
+
# trust setting fails with CSSMERR_TP_NOT_TRUSTED. Neither is a bare
|
|
315
|
+
# trust-list entry — the key with no `trustSettings` array that
|
|
316
|
+
# `add-trusted-cert` without `-p` leaves behind, which
|
|
317
|
+
# `security dump-trust-settings` counts as zero settings. Requiring
|
|
318
|
+
# a real setting costs one corrective add on a machine that has the
|
|
319
|
+
# bare kind (the add upgrades it in place, without duplicating the
|
|
320
|
+
# certificate) and nothing thereafter, so the stricter reading
|
|
321
|
+
# converges rather than looping.
|
|
322
|
+
#
|
|
323
|
+
# The fingerprint comparison stays: adding a certificate twice is not
|
|
324
|
+
# harmless, and it is how the trust store filled up with duplicates.
|
|
325
|
+
# It is now a necessary condition rather than the whole answer.
|
|
223
326
|
def already_trusted?(cert_path, keychain)
|
|
224
327
|
fp = fingerprint_of(cert_path)
|
|
225
328
|
return false unless fp
|
|
329
|
+
return false unless keychain_fingerprints(keychain, common_name: nil).include?(fp)
|
|
226
330
|
|
|
227
|
-
|
|
331
|
+
trust_setting?(fp, keychain)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# The trust settings the OS records, as plist XML, or nil when macOS
|
|
335
|
+
# cannot be asked at all.
|
|
336
|
+
#
|
|
337
|
+
# The trust store is keyed by the certificate's SHA-1 fingerprint —
|
|
338
|
+
# the same identity `security -Z` prints — so this never has to match
|
|
339
|
+
# on a common name, which every yamine CA shares.
|
|
340
|
+
#
|
|
341
|
+
# `security trust-settings-export` is the only `security` subcommand
|
|
342
|
+
# that reports the SETTING rather than the presence:
|
|
343
|
+
# `dump-trust-settings` lists certificates and a count of their
|
|
344
|
+
# settings, `find-certificate` lists certificates. The admin domain
|
|
345
|
+
# needs `-d`; without it only the user's own settings are exported,
|
|
346
|
+
# so a root install would look untrusted forever.
|
|
347
|
+
#
|
|
348
|
+
# The certificate's own key is looked up by the caller, so a store
|
|
349
|
+
# that holds no entry for it comes back as XML WITHOUT it — that is a
|
|
350
|
+
# definite answer (never trusted), not an unreadable one.
|
|
351
|
+
def trust_settings(keychain)
|
|
352
|
+
file = File.join(Dir.tmpdir, "yamine-trust-#{Process.pid}-#{rand(1 << 32)}.plist")
|
|
353
|
+
_out, status = Command.capture2("security", "trust-settings-export",
|
|
354
|
+
*("-d" if keychain == SYSTEM_KEYCHAIN), file)
|
|
355
|
+
return nil unless status.success?
|
|
356
|
+
|
|
357
|
+
# Read back through plutil rather than parsing the plist here: it
|
|
358
|
+
# is Apple's own reader, and it keeps this file free of a plist
|
|
359
|
+
# parser (rexml is a bundled gem, unavailable under bundler).
|
|
360
|
+
xml, converted = Command.capture2("plutil", "-convert", "xml1", "-o", "-", file)
|
|
361
|
+
converted.success? ? xml : nil
|
|
362
|
+
rescue SystemCallError
|
|
363
|
+
nil
|
|
364
|
+
ensure
|
|
365
|
+
File.unlink(file) if file && File.file?(file)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# True when the trust store records a trust SETTING that grants
|
|
369
|
+
# trust for this certificate.
|
|
370
|
+
#
|
|
371
|
+
# Returns TRUE when the trust store cannot be read. That direction
|
|
372
|
+
# is deliberate: a check that always answers "not trusted" turns
|
|
373
|
+
# every boot into a re-add, and a re-add in the user domain can
|
|
374
|
+
# raise a GUI authorization prompt — a worse failure than the one
|
|
375
|
+
# this fixes.
|
|
376
|
+
def trust_setting?(fingerprint, keychain)
|
|
377
|
+
settings = trust_settings(keychain)
|
|
378
|
+
return true if settings.nil?
|
|
379
|
+
|
|
380
|
+
entry = plist_dict_after(settings, fingerprint)
|
|
381
|
+
return false unless entry&.include?("<key>trustSettings</key>")
|
|
382
|
+
|
|
383
|
+
granted = entry.scan(%r{<key>kSecTrustSettingsResult</key>\s*<integer>(\d+)</integer>}).flatten
|
|
384
|
+
return true if granted.empty? # policies only: defaults to trustRoot
|
|
385
|
+
|
|
386
|
+
granted.any? { |result| TRUST_RESULTS.include?(result.to_i) }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The `<dict>` that follows `key`, nested dicts included. A
|
|
390
|
+
# trustSettings array is a list of dicts, so a lazy `.*?</dict>`
|
|
391
|
+
# would stop in the middle of the very array being read.
|
|
392
|
+
def plist_dict_after(xml, key)
|
|
393
|
+
at = xml.index("<key>#{key}</key>")
|
|
394
|
+
return nil unless at
|
|
395
|
+
|
|
396
|
+
rest = xml[(at + 1)..]
|
|
397
|
+
depth = 0
|
|
398
|
+
rest.scan(/<dict>|<\/dict>/) do
|
|
399
|
+
depth += ($~[0] == "<dict>" ? 1 : -1)
|
|
400
|
+
return rest[0...$~.end(0)] if depth.zero?
|
|
401
|
+
end
|
|
402
|
+
nil
|
|
228
403
|
end
|
|
229
404
|
|
|
230
405
|
def login_keychain
|
data/lib/yamine/version.rb
CHANGED