yamine 0.20.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 +78 -0
- data/README.md +58 -14
- data/lib/yamine/cli/boot.rb +5 -4
- data/lib/yamine/cli/system.rb +260 -81
- data/lib/yamine/cli/worktree.rb +6 -1
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/doctor.rb +66 -0
- data/lib/yamine/hosts.rb +76 -8
- data/lib/yamine/privileged_payload.rb +419 -0
- data/lib/yamine/proxy_control.rb +45 -6
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
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
|
data/lib/yamine/proxy_control.rb
CHANGED
|
@@ -257,12 +257,15 @@ module Yamine
|
|
|
257
257
|
store.ensure_dir
|
|
258
258
|
log_path = File.join(store.dir, LOG_NAME)
|
|
259
259
|
Log.rotate(log_path)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
260
|
+
if sudo
|
|
261
|
+
# A privileged spawn never executes the gem directory as root:
|
|
262
|
+
# make sure the staged payload exists first (staging under this
|
|
263
|
+
# same human-authorized sudo when missing), then spawn the
|
|
264
|
+
# staged copy. The `env` prefix stays: no grant rule covers this
|
|
265
|
+
# path, and the long-lived daemon needs the exact state dir.
|
|
266
|
+
ensure_staged_payload!(store)
|
|
267
|
+
end
|
|
268
|
+
cmd = daemon_cmd(store: store, port: port, tls: tls, sudo: sudo, tlds: tlds)
|
|
266
269
|
pid = spawn({ "YAMINE_STATE_DIR" => store.dir }, *cmd,
|
|
267
270
|
out: log_path, err: [:child, :out])
|
|
268
271
|
Process.detach(pid)
|
|
@@ -297,6 +300,42 @@ module Yamine
|
|
|
297
300
|
pid
|
|
298
301
|
end
|
|
299
302
|
|
|
303
|
+
# The exact argv a daemon spawn executes, extracted so the
|
|
304
|
+
# privileged shape is pinnable without spawning processes. The
|
|
305
|
+
# payload is the root-owned staged copy when sudo is involved —
|
|
306
|
+
# never the user-writable gem directory — and the gem path when it
|
|
307
|
+
# is not.
|
|
308
|
+
def daemon_cmd(store:, port:, tls:, sudo:, tlds: nil)
|
|
309
|
+
payload = sudo ? PrivilegedPayload.bin_path : bin_path
|
|
310
|
+
args = [RbConfig.ruby, payload,
|
|
311
|
+
"proxy", "start", "--foreground", "--port", port.to_s]
|
|
312
|
+
args << "--no-tls" unless tls
|
|
313
|
+
Array(tlds).each { |t| args.concat(["--tld", t]) }
|
|
314
|
+
state_arg = "YAMINE_STATE_DIR=#{store.dir}"
|
|
315
|
+
sudo ? ["sudo", "env", state_arg, *args] : [*args]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Guarantee the staged payload before a privileged spawn. Runs the
|
|
319
|
+
# gem source once under the same human-authorized sudo that is
|
|
320
|
+
# about to launch the daemon (plain sudo, may prompt — never
|
|
321
|
+
# granted, never passwordless), so the daemon itself runs only the
|
|
322
|
+
# staged copy.
|
|
323
|
+
def ensure_staged_payload!(store)
|
|
324
|
+
return true if PrivilegedPayload.staged?
|
|
325
|
+
|
|
326
|
+
puts "Staging the privileged payload (sudo required)..."
|
|
327
|
+
stage_cmd = ["sudo", "env", "YAMINE_STATE_DIR=#{store.dir}",
|
|
328
|
+
RbConfig.ruby, bin_path, "service", "stage", "--internal"]
|
|
329
|
+
ok = Command.run(*stage_cmd)
|
|
330
|
+
unless ok && PrivilegedPayload.staged?
|
|
331
|
+
raise ProxyNotRunningError,
|
|
332
|
+
"Could not stage the privileged payload as root. " \
|
|
333
|
+
"Run once in a terminal: sudo yamine service install " \
|
|
334
|
+
"(or: yamine setup) — then re-run."
|
|
335
|
+
end
|
|
336
|
+
true
|
|
337
|
+
end
|
|
338
|
+
|
|
300
339
|
def log_tail(path, lines: 15)
|
|
301
340
|
return "(no log yet)" unless File.file?(path)
|
|
302
341
|
|
data/lib/yamine/version.rb
CHANGED
data/lib/yamine.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "yamine/worktrees"
|
|
|
13
13
|
require_relative "yamine/framework"
|
|
14
14
|
require_relative "yamine/config"
|
|
15
15
|
require_relative "yamine/ownership"
|
|
16
|
+
require_relative "yamine/privileged_payload"
|
|
16
17
|
require_relative "yamine/command"
|
|
17
18
|
require_relative "yamine/log"
|
|
18
19
|
require_relative "yamine/route_store"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yamine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.21.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -103,6 +103,7 @@ files:
|
|
|
103
103
|
- lib/yamine/log.rb
|
|
104
104
|
- lib/yamine/ownership.rb
|
|
105
105
|
- lib/yamine/ports.rb
|
|
106
|
+
- lib/yamine/privileged_payload.rb
|
|
106
107
|
- lib/yamine/probe.rb
|
|
107
108
|
- lib/yamine/procfile.rb
|
|
108
109
|
- lib/yamine/proxy.rb
|