yamine 0.19.0 → 0.21.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 +132 -0
- data/README.md +58 -14
- data/lib/yamine/certs.rb +107 -7
- data/lib/yamine/cli/boot.rb +5 -4
- data/lib/yamine/cli/system.rb +303 -83
- data/lib/yamine/cli/worktree.rb +6 -1
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/doctor.rb +99 -0
- data/lib/yamine/errors.rb +4 -0
- data/lib/yamine/hosts.rb +76 -8
- data/lib/yamine/privileged_payload.rb +419 -0
- data/lib/yamine/proxy.rb +166 -33
- data/lib/yamine/proxy_control.rb +76 -6
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
data/lib/yamine/doctor.rb
CHANGED
|
@@ -34,6 +34,8 @@ module Yamine
|
|
|
34
34
|
checks << check_dns(store)
|
|
35
35
|
checks << check_ca
|
|
36
36
|
checks << check_ca_bundle
|
|
37
|
+
checks << check_serving_ca(port, tls: tls)
|
|
38
|
+
checks << check_service
|
|
37
39
|
checks
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -224,6 +226,38 @@ module Yamine
|
|
|
224
226
|
Check.new(name: "bundle", ok: false, message: "could not build #{Certs.bundle_path(dir)}: #{e.message}")
|
|
225
227
|
end
|
|
226
228
|
|
|
229
|
+
# The certificate the proxy ACTUALLY serves, verified against the CA
|
|
230
|
+
# now on disk. The CA can be regenerated under a live proxy
|
|
231
|
+
# (`yamine clean`/`trust`/`setup` rewrite the pair); without this
|
|
232
|
+
# check doctor reported "[ok] proxy: listening" and "[ok] ca: CA
|
|
233
|
+
# trusted" while every browser got ERR_CERT_AUTHORITY_INVALID,
|
|
234
|
+
# because nothing compared the served leaf to the current CA.
|
|
235
|
+
def check_serving_ca(port, tls:)
|
|
236
|
+
return Check.new(name: "serving ca", ok: true, message: "TLS off — no serving certificate to check") unless tls
|
|
237
|
+
return Check.new(name: "serving ca", ok: true, message: "no proxy serving — skipped") if port.nil?
|
|
238
|
+
|
|
239
|
+
dir = Certs.state_dir
|
|
240
|
+
ca_path = Certs.ca_paths(dir)[:cert]
|
|
241
|
+
return Check.new(name: "serving ca", ok: true, message: "no CA on disk — skipped") unless File.file?(ca_path)
|
|
242
|
+
|
|
243
|
+
leaf = Trust.proxy_peer_cert(port)
|
|
244
|
+
return Check.new(name: "serving ca", ok: true, warn: true,
|
|
245
|
+
message: "could not read the proxy's serving certificate — skipped") if leaf.nil?
|
|
246
|
+
|
|
247
|
+
ca = OpenSSL::X509::Certificate.new(File.read(ca_path))
|
|
248
|
+
if Trust.signed_by?(leaf, ca)
|
|
249
|
+
Check.new(name: "serving ca", ok: true, message: "serving certificate matches the CA on disk")
|
|
250
|
+
else
|
|
251
|
+
Check.new(name: "serving ca", ok: false,
|
|
252
|
+
message: "proxy serves a certificate signed by a superseded CA — " \
|
|
253
|
+
"restart it: sudo launchctl kickstart -k system/dev.yamine " \
|
|
254
|
+
"(or sudo yamine service install to re-register the service after a gem upgrade)")
|
|
255
|
+
end
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
Check.new(name: "serving ca", ok: true, warn: true,
|
|
258
|
+
message: "could not check the serving certificate (#{e.message}) — skipped")
|
|
259
|
+
end
|
|
260
|
+
|
|
227
261
|
# How many trusted certificates carry one of our CA names without
|
|
228
262
|
# being the CA currently on disk. Best-effort: 0 when the keychain
|
|
229
263
|
# cannot be read (doctor must never fail on a read-only probe).
|
|
@@ -244,6 +278,71 @@ module Yamine
|
|
|
244
278
|
0
|
|
245
279
|
end
|
|
246
280
|
|
|
281
|
+
# The privileged service: what runs as root on port 443.
|
|
282
|
+
#
|
|
283
|
+
# Three states. A legacy unit — payload inside a user home or a
|
|
284
|
+
# version-stamped gem directory — keeps working, so it warns (never
|
|
285
|
+
# fails) with the exact migration. A staged unit whose tree fails
|
|
286
|
+
# ownership verification FAILS: user-writable code running as root
|
|
287
|
+
# is the hole this check exists to close. Version skew between the
|
|
288
|
+
# staged payload and this CLI warns. The interpreter is always
|
|
289
|
+
# reported truthfully when a service exists: there is no root-owned
|
|
290
|
+
# Ruby new enough for the gem, so the daemon runs a user-writable
|
|
291
|
+
# interpreter and that residual risk stays visible.
|
|
292
|
+
def check_service
|
|
293
|
+
unless PrivilegedPayload.service_unit_present?
|
|
294
|
+
return Check.new(name: "service", ok: true,
|
|
295
|
+
message: "no privileged service installed — skipped")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
ref = PrivilegedPayload.installed_payload_ref
|
|
299
|
+
if ref.nil?
|
|
300
|
+
return Check.new(name: "service", ok: true, warn: true,
|
|
301
|
+
message: "a privileged unit is installed but its payload path is unreadable" \
|
|
302
|
+
" — re-install it: sudo yamine service install")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
if PrivilegedPayload.legacy_ref?(ref)
|
|
306
|
+
return Check.new(name: "service", ok: true, warn: true,
|
|
307
|
+
message: "the privileged service runs a user-writable legacy payload (#{ref}) — " \
|
|
308
|
+
"migrate it: sudo yamine service install")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
problems = []
|
|
312
|
+
warnings = []
|
|
313
|
+
begin
|
|
314
|
+
PrivilegedPayload.verify!
|
|
315
|
+
rescue Error => e
|
|
316
|
+
problems << "#{e.message} — re-install it: sudo yamine service install"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
staged = PrivilegedPayload.staged_version
|
|
320
|
+
if staged.nil?
|
|
321
|
+
warnings << "the service points at the staged payload but no staged version is recorded" \
|
|
322
|
+
" — re-install it: sudo yamine service install"
|
|
323
|
+
elsif staged != Yamine::VERSION
|
|
324
|
+
warnings << "the staged payload is v#{staged}, this CLI is v#{Yamine::VERSION}" \
|
|
325
|
+
" — re-install it: sudo yamine service install"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
ruby = PrivilegedPayload.ruby_info
|
|
329
|
+
if ruby[:writable]
|
|
330
|
+
warnings << "the service interpreter #{ruby[:path]} is user-writable — " \
|
|
331
|
+
"anything that can write it changes what runs as root; only the payload above is root-owned " \
|
|
332
|
+
"(no root-owned Ruby >= 3.2 exists on this machine, so this residual risk stays until one does)"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
if problems.empty? && warnings.empty?
|
|
336
|
+
Check.new(name: "service", ok: true,
|
|
337
|
+
message: "staged payload v#{staged} (#{PrivilegedPayload.bin_path})")
|
|
338
|
+
elsif problems.empty?
|
|
339
|
+
Check.new(name: "service", ok: true, warn: true, message: warnings.join("; "))
|
|
340
|
+
else
|
|
341
|
+
Check.new(name: "service", ok: false,
|
|
342
|
+
message: (problems + warnings).join("; "))
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
247
346
|
def print(checks, out: $stdout, json: false)
|
|
248
347
|
if json
|
|
249
348
|
require "json"
|
data/lib/yamine/errors.rb
CHANGED
|
@@ -33,5 +33,9 @@ module Yamine
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
class ProxyNotRunningError < Error; end
|
|
36
|
+
# spawn_daemon raises this instead of spawning when our proxy already
|
|
37
|
+
# answers on the port: the readiness probe would otherwise pass on the
|
|
38
|
+
# live proxy's behalf and a dead pid would be recorded over real state.
|
|
39
|
+
class ProxyAlreadyRunningError < Error; end
|
|
36
40
|
class CertError < Error; end
|
|
37
41
|
end
|
data/lib/yamine/hosts.rb
CHANGED
|
@@ -46,14 +46,14 @@ module Yamine
|
|
|
46
46
|
# Bring the managed block to exactly these hostnames. Idempotent: when
|
|
47
47
|
# the block already matches, this is a no-op returning true.
|
|
48
48
|
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
# Every name is validated BEFORE anything is read or written — even
|
|
50
|
+
# on the already-synced path. routes.json is user-writable and this
|
|
51
|
+
# runs as root, so a crafted name (an embedded newline, a real
|
|
52
|
+
# vendor domain) would otherwise turn the sync into a domain-hijack
|
|
53
|
+
# primitive. Raises Yamine::Error instead of writing.
|
|
54
|
+
def sync(hostnames, path = PATH, tlds: nil)
|
|
55
|
+
tlds ||= default_scope_tlds
|
|
56
|
+
validate!(hostnames, tlds: tlds)
|
|
57
57
|
return true if synced?(hostnames, path)
|
|
58
58
|
|
|
59
59
|
content = read(path)
|
|
@@ -77,6 +77,74 @@ module Yamine
|
|
|
77
77
|
read(path).include?(managed_block(hostnames))
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Well-formed hostname only: labels of letters/digits/hyphens that
|
|
81
|
+
# neither start nor end with a hyphen, dots between them, 253 chars
|
|
82
|
+
# total. Anything else — whitespace, newlines, empty labels — is
|
|
83
|
+
# refused before it can reach the root-owned file.
|
|
84
|
+
def valid_hostname?(name)
|
|
85
|
+
host = name.to_s.downcase.sub(/\.\z/, "")
|
|
86
|
+
return false if host.empty? || host.length > 253
|
|
87
|
+
|
|
88
|
+
labels = host.split(".")
|
|
89
|
+
return false if labels.empty?
|
|
90
|
+
|
|
91
|
+
labels.all? do |label|
|
|
92
|
+
!label.empty? && label.length <= 63 &&
|
|
93
|
+
label.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# In scope when the name IS an allowed dev TLD or sits under one.
|
|
98
|
+
# Suffix matching only: "evilgithub.com" is not "github.com", and
|
|
99
|
+
# "evil.github.com" is not "localhost".
|
|
100
|
+
def in_scope?(hostname, tlds)
|
|
101
|
+
host = hostname.to_s.downcase.sub(/\.\z/, "")
|
|
102
|
+
Array(tlds).map { |t| t.to_s.downcase }.any? do |tld|
|
|
103
|
+
!tld.empty? && (host == tld || host.end_with?(".#{tld}"))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Fail closed on the first out-of-scope name: a partial managed
|
|
108
|
+
# block that silently drops a route would send people debugging
|
|
109
|
+
# DNS for a policy decision.
|
|
110
|
+
def validate!(hostnames, tlds:)
|
|
111
|
+
invalid = Array(hostnames).reject { |h| valid_hostname?(h) }
|
|
112
|
+
unless invalid.empty?
|
|
113
|
+
raise Error, "refusing hosts sync: invalid hostname(s): #{invalid.join(", ")}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
outside = Array(hostnames).reject { |h| in_scope?(h, tlds) }
|
|
117
|
+
unless outside.empty?
|
|
118
|
+
raise Error, "refusing hosts sync: #{outside.join(", ")} " \
|
|
119
|
+
"is outside the allowed dev TLDs (#{Array(tlds).join(", ")}). " \
|
|
120
|
+
"Keep names under .localhost, or have a human allow the parent domain."
|
|
121
|
+
end
|
|
122
|
+
true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Which TLDs a sync may write under. *.localhost is always allowed
|
|
126
|
+
# (RFC 6761 reserves .localhost to loopback, so it can never hijack
|
|
127
|
+
# a real domain). Everything else comes from the root-owned
|
|
128
|
+
# allowlist when one is staged; before the first elevated install
|
|
129
|
+
# it falls back to the TLDs the proxy actually serves.
|
|
130
|
+
def default_scope_tlds(state_dir = Certs.state_dir)
|
|
131
|
+
tlds = ["localhost"]
|
|
132
|
+
staged = PrivilegedPayload.allowlist_file
|
|
133
|
+
if File.file?(staged)
|
|
134
|
+
tlds |= read_tld_list(staged)
|
|
135
|
+
else
|
|
136
|
+
tlds |= read_tld_list(File.join(state_dir, "proxy.tlds"))
|
|
137
|
+
end
|
|
138
|
+
tlds
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_tld_list(path)
|
|
142
|
+
File.readlines(path).map { |l| l.strip.downcase }.reject(&:empty?)
|
|
143
|
+
.select { |t| Sanitize.valid_tld?(t) }.uniq
|
|
144
|
+
rescue SystemCallError
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
|
|
80
148
|
def clean(path = PATH)
|
|
81
149
|
content = read(path)
|
|
82
150
|
updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, "")
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Yamine
|
|
6
|
+
# The root-owned, version-independent payload the privileged service
|
|
7
|
+
# runs.
|
|
8
|
+
#
|
|
9
|
+
# The root daemon used to execute the gem's bin+lib straight out of
|
|
10
|
+
# the user's home (a version-stamped, user-writable gem directory),
|
|
11
|
+
# so anything that could write those paths — a gem upgrade, a bundle
|
|
12
|
+
# install, an agent editing the gem — ran as root at every boot.
|
|
13
|
+
# Instead the elevated install stages bin+lib once into a root-owned
|
|
14
|
+
# directory at a path that never changes across versions, and the
|
|
15
|
+
# launchd/systemd unit plus the passwordless grant pin only that
|
|
16
|
+
# path. User-space writes can no longer change what runs as root;
|
|
17
|
+
# introducing new root-executed code stays a human-authorized
|
|
18
|
+
# interactive-sudo step (`sudo yamine service install`).
|
|
19
|
+
module PrivilegedPayload
|
|
20
|
+
# Conventional root-owned homes, one leaf name ("yamine") on both
|
|
21
|
+
# platforms: /Library/* is where macOS keeps machine-wide app data,
|
|
22
|
+
# /usr/local/share is the FHS equivalent on Linux.
|
|
23
|
+
MACOS_ROOT = "/Library/Application Support/yamine"
|
|
24
|
+
LINUX_ROOT = "/usr/local/share/yamine"
|
|
25
|
+
KNOWN_PARENTS = ["/Library/Application Support", "/usr/local/share", "/opt"].freeze
|
|
26
|
+
|
|
27
|
+
# Test/dev override for the root (the suite redirects it at a
|
|
28
|
+
# tmpdir). Never survives the sudo boundary without SETENV, which
|
|
29
|
+
# the grant does not set — so the passwordless rules always pin
|
|
30
|
+
# the real root, never this.
|
|
31
|
+
ROOT_ENV_VAR = "YAMINE_PRIVILEGED_ROOT"
|
|
32
|
+
|
|
33
|
+
VERSIONS_DIRNAME = "versions"
|
|
34
|
+
CURRENT_LINKNAME = "current"
|
|
35
|
+
VERSION_FILENAME = "VERSION"
|
|
36
|
+
ALLOWLIST_FILENAME = "allowed-tlds"
|
|
37
|
+
|
|
38
|
+
LAUNCHD_LABEL = "dev.yamine"
|
|
39
|
+
PLIST_PATH = "/Library/LaunchDaemons/dev.yamine.plist"
|
|
40
|
+
UNIT_PATH = "/etc/systemd/system/yamine.service"
|
|
41
|
+
|
|
42
|
+
# A version-stamped gem payload: the legacy layout the unit must
|
|
43
|
+
# stop referencing.
|
|
44
|
+
VERSIONED_GEM_PATTERN = %r{/gems/yamine-\d+\.\d+\.\d+/}
|
|
45
|
+
|
|
46
|
+
StatInfo = Struct.new(:uid, :mode, :directory)
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def root_dir
|
|
51
|
+
override = ENV[ROOT_ENV_VAR]
|
|
52
|
+
return override unless override.nil? || override.empty?
|
|
53
|
+
|
|
54
|
+
RUBY_PLATFORM =~ /darwin/ ? MACOS_ROOT : LINUX_ROOT
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def versions_dir(root = root_dir)
|
|
58
|
+
File.join(root, VERSIONS_DIRNAME)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def current_link(root = root_dir)
|
|
62
|
+
File.join(root, CURRENT_LINKNAME)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def version_file(root = root_dir)
|
|
66
|
+
File.join(root, VERSION_FILENAME)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def allowlist_file(root = root_dir)
|
|
70
|
+
File.join(root, ALLOWLIST_FILENAME)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The version-independent argv the unit and the grant pin: stable
|
|
74
|
+
# across upgrades, never inside a user home, never version-stamped.
|
|
75
|
+
def bin_path(root = root_dir)
|
|
76
|
+
File.join(current_link(root), "bin", "yamine")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def gem_root
|
|
80
|
+
File.expand_path("../..", __dir__)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The version recorded at staging time, or nil when nothing valid
|
|
84
|
+
# was ever staged.
|
|
85
|
+
def staged_version(root = root_dir)
|
|
86
|
+
path = version_file(root)
|
|
87
|
+
return nil unless File.file?(path)
|
|
88
|
+
|
|
89
|
+
version = File.read(path).strip
|
|
90
|
+
version.empty? ? nil : version
|
|
91
|
+
rescue SystemCallError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def staged?(root = root_dir)
|
|
96
|
+
File.file?(bin_path(root)) && !staged_version(root).nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Only ever stage into (or delete) our own payload root. The guard
|
|
100
|
+
# is structural — leaf name plus a known parent, or the explicit
|
|
101
|
+
# test override — so a bug can never turn stage!/remove_payload!
|
|
102
|
+
# into a recursive delete elsewhere.
|
|
103
|
+
def payload_root?(root = root_dir)
|
|
104
|
+
return false unless File.basename(root) == "yamine"
|
|
105
|
+
|
|
106
|
+
override = ENV[ROOT_ENV_VAR]
|
|
107
|
+
return true if override && !override.empty? && root == override
|
|
108
|
+
|
|
109
|
+
KNOWN_PARENTS.include?(File.dirname(root))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Stage bin+lib from the running gem into the payload root:
|
|
113
|
+
# copy to a fresh version dir, lock ownership/modes, verify BEFORE
|
|
114
|
+
# touching the live `current` link, swap the link atomically,
|
|
115
|
+
# record the version marker, prune superseded versions, verify the
|
|
116
|
+
# whole tree again. Raises Error on any failure — the live payload
|
|
117
|
+
# (if any) is left untouched, and no unit is registered against an
|
|
118
|
+
# unverified tree.
|
|
119
|
+
def stage!(version: Yamine::VERSION, root: root_dir, source: gem_root, state_dir: nil)
|
|
120
|
+
raise Error, "refusing to stage into #{root.inspect} — not a yamine payload root" unless payload_root?(root)
|
|
121
|
+
|
|
122
|
+
FileUtils.mkdir_p(versions_dir(root))
|
|
123
|
+
dest = File.join(versions_dir(root), version)
|
|
124
|
+
tmp = "#{dest}.staging-#{Process.pid}"
|
|
125
|
+
FileUtils.rm_rf(tmp)
|
|
126
|
+
FileUtils.mkdir_p(tmp)
|
|
127
|
+
begin
|
|
128
|
+
%w[bin lib].each do |entry|
|
|
129
|
+
src = File.join(source, entry)
|
|
130
|
+
raise Error, "cannot stage the privileged payload: #{src} is missing" unless File.exist?(src)
|
|
131
|
+
|
|
132
|
+
FileUtils.cp_r(src, tmp)
|
|
133
|
+
end
|
|
134
|
+
normalize_modes!(tmp)
|
|
135
|
+
secure_tree!(tmp)
|
|
136
|
+
verify_tree!(tmp)
|
|
137
|
+
# Promote the verified tree into place, then swing the link:
|
|
138
|
+
# readers always see a complete version dir.
|
|
139
|
+
File.rename(tmp, dest)
|
|
140
|
+
swap_link!(current_link(root), dest)
|
|
141
|
+
write_root_file(version_file(root), "#{version}\n")
|
|
142
|
+
seed_allowlist!(root, state_dir: state_dir)
|
|
143
|
+
prune_versions!(root, keep: dest)
|
|
144
|
+
verify!(root)
|
|
145
|
+
ensure
|
|
146
|
+
FileUtils.rm_rf(tmp)
|
|
147
|
+
end
|
|
148
|
+
version
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Remove the staged payload: the link, every version dir, the
|
|
152
|
+
# version marker, and the allowlist — each by exact name, so the
|
|
153
|
+
# removal stays scoped to yamine's own files. The root dir itself
|
|
154
|
+
# is left in place.
|
|
155
|
+
def remove_payload!(root = root_dir)
|
|
156
|
+
raise Error, "refusing to remove #{root.inspect} — not a yamine payload root" unless payload_root?(root)
|
|
157
|
+
|
|
158
|
+
FileUtils.rm_f(current_link(root))
|
|
159
|
+
FileUtils.rm_rf(versions_dir(root))
|
|
160
|
+
FileUtils.rm_f(version_file(root))
|
|
161
|
+
FileUtils.rm_f(allowlist_file(root))
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Every path root executes through this payload: the root itself,
|
|
166
|
+
# the entrypoint, the version marker, the allowlist, and each file
|
|
167
|
+
# under the live version dir (symlinks resolved — the check must
|
|
168
|
+
# see the real payload, not the link).
|
|
169
|
+
def root_executed_paths(root = root_dir)
|
|
170
|
+
paths = [root, bin_path(root), version_file(root), allowlist_file(root)]
|
|
171
|
+
target = current_target(root)
|
|
172
|
+
if target && File.directory?(target)
|
|
173
|
+
Dir.glob(File.join(target, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
174
|
+
next if [".", ".."].include?(File.basename(path))
|
|
175
|
+
|
|
176
|
+
paths << path
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
paths.uniq
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def current_target(root = root_dir)
|
|
183
|
+
link = current_link(root)
|
|
184
|
+
return nil unless File.symlink?(link)
|
|
185
|
+
|
|
186
|
+
File.realpath(link)
|
|
187
|
+
rescue SystemCallError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Fail closed: every root-executed path must exist, be root-owned,
|
|
192
|
+
# and not be group/other-writable. Raises Error naming each
|
|
193
|
+
# problem; returns true when the tree is safe to register.
|
|
194
|
+
def verify!(root = root_dir)
|
|
195
|
+
problems = []
|
|
196
|
+
paths = root_executed_paths(root)
|
|
197
|
+
missing = paths.reject { |p| File.exist?(p) || File.symlink?(p) }
|
|
198
|
+
problems += missing.map { |p| "#{p} is missing" }
|
|
199
|
+
(paths - missing).each do |path|
|
|
200
|
+
problems << "#{path} is not root-owned" unless root_owned?(path)
|
|
201
|
+
problems << "#{path} is group/other-writable" unless world_safe?(path)
|
|
202
|
+
end
|
|
203
|
+
raise Error, "refusing the privileged payload: #{problems.join("; ")}" unless problems.empty?
|
|
204
|
+
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Single ownership seam (stubbed in tests): follows symlinks so
|
|
209
|
+
# the check always sees the real payload.
|
|
210
|
+
def stat_info(path)
|
|
211
|
+
st = File.stat(path)
|
|
212
|
+
StatInfo.new(st.uid, st.mode, st.directory?)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def root_owned?(path)
|
|
216
|
+
stat_info(path).uid.zero?
|
|
217
|
+
rescue SystemCallError
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def world_safe?(path)
|
|
222
|
+
(stat_info(path).mode & 0o022).zero?
|
|
223
|
+
rescue SystemCallError
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The payload argv a unit file references, or nil when the unit
|
|
228
|
+
# does not run yamine at all. Pure string parsing — hermetic.
|
|
229
|
+
def payload_ref_from_plist(xml)
|
|
230
|
+
strings = xml.scan(%r{<string>(.*?)</string>}m).flatten.map(&:strip)
|
|
231
|
+
strings.find { |s| s.end_with?("bin/yamine") }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def payload_ref_from_unit(text)
|
|
235
|
+
line = text.each_line.find { |l| l.strip.start_with?("ExecStart=") }
|
|
236
|
+
return nil unless line
|
|
237
|
+
|
|
238
|
+
line.strip.sub(/\AExecStart=/, "").split(/\s+/)[1]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A legacy install: the unit's payload path points into a user
|
|
242
|
+
# home or at a version-stamped gem directory — user-writable code
|
|
243
|
+
# running as root. The staged path is never legacy.
|
|
244
|
+
def legacy_ref?(ref, home: default_home)
|
|
245
|
+
return false if ref.nil? || ref.empty?
|
|
246
|
+
return false if ref == bin_path
|
|
247
|
+
return true if ref.match?(VERSIONED_GEM_PATTERN)
|
|
248
|
+
return true if ref.start_with?(home + "/")
|
|
249
|
+
|
|
250
|
+
false
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Which payload the installed unit (if any) runs: the plist on
|
|
254
|
+
# macOS, the systemd unit on Linux. Nil when no unit is installed
|
|
255
|
+
# or when it references nothing yamine-shaped.
|
|
256
|
+
def installed_payload_ref(plist_path: PLIST_PATH, unit_path: UNIT_PATH)
|
|
257
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
258
|
+
return nil unless File.file?(plist_path)
|
|
259
|
+
|
|
260
|
+
begin
|
|
261
|
+
payload_ref_from_plist(File.read(plist_path))
|
|
262
|
+
rescue SystemCallError
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
else
|
|
266
|
+
return nil unless File.file?(unit_path)
|
|
267
|
+
|
|
268
|
+
begin
|
|
269
|
+
payload_ref_from_unit(File.read(unit_path))
|
|
270
|
+
rescue SystemCallError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def service_unit_present?(plist_path: PLIST_PATH, unit_path: UNIT_PATH)
|
|
277
|
+
path = RUBY_PLATFORM =~ /darwin/ ? plist_path : unit_path
|
|
278
|
+
File.file?(path)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Domains hosts sync may write, besides *.localhost (always
|
|
282
|
+
# allowed — RFC 6761 reserves .localhost to loopback, so it can
|
|
283
|
+
# never hijack a real domain). Root-owned and read by the elevated
|
|
284
|
+
# sync, so user-space writes cannot widen it.
|
|
285
|
+
def allowed_tlds(root = root_dir)
|
|
286
|
+
path = allowlist_file(root)
|
|
287
|
+
return ["localhost"] unless File.file?(path)
|
|
288
|
+
|
|
289
|
+
["localhost"] | read_tld_list(path)
|
|
290
|
+
rescue SystemCallError
|
|
291
|
+
["localhost"]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The interpreter the unit runs under, and whether user-space
|
|
295
|
+
# writes can change it. This machine has no root-owned Ruby new
|
|
296
|
+
# enough for the gem, so the daemon necessarily runs a
|
|
297
|
+
# user-writable interpreter today — reported truthfully, never
|
|
298
|
+
# papered over.
|
|
299
|
+
def ruby_info(ruby: RbConfig.ruby, home: default_home)
|
|
300
|
+
{ path: ruby, writable: ruby_writable?(ruby, home: home) }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def ruby_writable?(ruby = RbConfig.ruby, home: default_home)
|
|
304
|
+
return true if ruby.start_with?(home + "/")
|
|
305
|
+
|
|
306
|
+
begin
|
|
307
|
+
st = File.stat(ruby)
|
|
308
|
+
return true if st.uid != 0
|
|
309
|
+
return true unless (st.mode & 0o022).zero?
|
|
310
|
+
|
|
311
|
+
false
|
|
312
|
+
rescue SystemCallError
|
|
313
|
+
true
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def default_home
|
|
318
|
+
Yamine::Certs.home
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Staging helpers (public so tests can pin the failure modes, but
|
|
322
|
+
# only ever called from stage! above).
|
|
323
|
+
|
|
324
|
+
# Root-own the staged tree and lock modes: dirs 0755, files 0644,
|
|
325
|
+
# the entrypoint 0755. The chown only succeeds as root; anywhere
|
|
326
|
+
# else it is skipped and verify_tree! below fails closed.
|
|
327
|
+
def secure_tree!(dir)
|
|
328
|
+
FileUtils.chown_R(0, 0, dir)
|
|
329
|
+
rescue SystemCallError, ArgumentError
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def verify_tree!(dir)
|
|
334
|
+
problems = []
|
|
335
|
+
Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
336
|
+
next if [".", ".."].include?(File.basename(path))
|
|
337
|
+
|
|
338
|
+
problems << "#{path} is not root-owned" unless root_owned?(path)
|
|
339
|
+
problems << "#{path} is group/other-writable" unless world_safe?(path)
|
|
340
|
+
end
|
|
341
|
+
raise Error, "refusing the privileged payload: #{problems.join("; ")}" unless problems.empty?
|
|
342
|
+
|
|
343
|
+
true
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# dirs keep their copied modes here; normalize explicitly so the
|
|
347
|
+
# staged tree never inherits a permissive umask from the gem dir.
|
|
348
|
+
def normalize_modes!(dir)
|
|
349
|
+
Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
350
|
+
next if [".", ".."].include?(File.basename(path))
|
|
351
|
+
|
|
352
|
+
if File.directory?(path) && !File.symlink?(path)
|
|
353
|
+
File.chmod(0o755, path)
|
|
354
|
+
elsif File.file?(path) && !File.symlink?(path)
|
|
355
|
+
File.chmod(path == File.join(dir, "bin", "yamine") ? 0o755 : 0o644, path)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
rescue SystemCallError => e
|
|
359
|
+
raise Error, "cannot lock down the staged payload: #{e.message}"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Atomically point `current` at the new version dir: build the
|
|
363
|
+
# symlink aside, then rename over the old link. Readers (the unit,
|
|
364
|
+
# the grant) always see a complete tree.
|
|
365
|
+
def swap_link!(link, dest)
|
|
366
|
+
tmp_link = "#{link}.link-#{Process.pid}"
|
|
367
|
+
FileUtils.rm_f(tmp_link)
|
|
368
|
+
File.symlink(dest, tmp_link)
|
|
369
|
+
File.rename(tmp_link, link)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def write_root_file(path, content)
|
|
373
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
374
|
+
File.write(path, content)
|
|
375
|
+
File.chmod(0o644, path)
|
|
376
|
+
FileUtils.chown(0, 0, path)
|
|
377
|
+
rescue SystemCallError, ArgumentError
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Seed the root-owned allowlist: localhost plus whatever dev TLDs
|
|
382
|
+
# the machine already serves (union with entries already there —
|
|
383
|
+
# an install never narrows what a human previously allowed).
|
|
384
|
+
def seed_allowlist!(root, state_dir: nil)
|
|
385
|
+
entries = ["localhost"]
|
|
386
|
+
begin
|
|
387
|
+
entries |= read_tld_list(allowlist_file(root)) if File.file?(allowlist_file(root))
|
|
388
|
+
rescue SystemCallError
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
dir = state_dir || Yamine::Certs.state_dir
|
|
392
|
+
tlds_path = File.join(dir, "proxy.tlds")
|
|
393
|
+
begin
|
|
394
|
+
entries |= read_tld_list(tlds_path) if File.file?(tlds_path)
|
|
395
|
+
rescue SystemCallError
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
write_root_file(allowlist_file(root), "#{entries.sort.join("\n")}\n")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def read_tld_list(path)
|
|
402
|
+
File.readlines(path).map { |l| l.strip.downcase }.reject(&:empty?)
|
|
403
|
+
.select { |t| Yamine::Sanitize.valid_tld?(t) }.uniq
|
|
404
|
+
rescue SystemCallError
|
|
405
|
+
[]
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def prune_versions!(root, keep:)
|
|
409
|
+
Dir.glob(File.join(versions_dir(root), "*")).each do |path|
|
|
410
|
+
next if path == keep || path == "#{keep}.staging-#{Process.pid}"
|
|
411
|
+
|
|
412
|
+
FileUtils.rm_rf(path) if File.directory?(path)
|
|
413
|
+
end
|
|
414
|
+
Dir.glob(File.join(versions_dir(root), "*.staging-*")).each do |path|
|
|
415
|
+
FileUtils.rm_rf(path)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|