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.
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
- # The sync short-circuit is load-bearing, not an optimization. /etc/hosts is
50
- # root-owned on a normal machine, so a rewrite fails without sudo — and
51
- # `setup` and every boot's workstation check run this. Without the
52
- # guard, a machine whose hosts file was already correct got a
53
- # "could not write /etc/hosts (try sudo yamine hosts sync)" warning on
54
- # every run, sending people to an elevated write for a file that
55
- # needed nothing.
56
- def sync(hostnames, path = PATH)
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
@@ -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
- args = [RbConfig.ruby, bin_path,
261
- "proxy", "start", "--foreground", "--port", port.to_s]
262
- args << "--no-tls" unless tls
263
- Array(tlds).each { |t| args.concat(["--tld", t]) }
264
- state_arg = "YAMINE_STATE_DIR=#{store.dir}"
265
- cmd = sudo ? ["sudo", "env", state_arg, *args] : [*args]
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.20.0"
4
+ VERSION = "0.21.0"
5
5
  end
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.20.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