yamine 0.18.0 → 0.20.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 +65 -0
- data/lib/yamine/certs.rb +107 -7
- data/lib/yamine/cli/system.rb +47 -6
- data/lib/yamine/cli/worktree.rb +55 -1
- data/lib/yamine/doctor.rb +33 -0
- data/lib/yamine/errors.rb +4 -0
- data/lib/yamine/proxy.rb +166 -33
- data/lib/yamine/proxy_control.rb +31 -0
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine/worktrees.rb +55 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c48a7ce222b3ffe113f542e81d84aca45270e4c3ea4f4f7da74bd22e8d0747d7
|
|
4
|
+
data.tar.gz: 8a8c1ad0f17b8bd741723cb9d83100248c559f5c7c995d64f30abfcebfe4fa60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9c3ae18ac29e5a30b378d7b70c7dd6c538b9140b058f4308ee2d9e5075567cde9ea21d169d30c54808d6a06a4283d3454d2a652b4b254b40e406d2de4a7edbc
|
|
7
|
+
data.tar.gz: 594bf48948e649c390aa3aed20246abd1c0eabbd0889f2a159f3b4a35d77b0d8cf8874b1eb1f8ebb51be9b0dc4300879139b943a35bde12fbc421a789b08f809
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.20.0] — 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Every proxy socket wait is bounded by an idle timeout
|
|
10
|
+
(`YAMINE_PROXY_IDLE_TIMEOUT`, default 60s).** Silence is capped,
|
|
11
|
+
total duration is not: the timer resets on each byte transferred, so
|
|
12
|
+
a slow-but-moving transfer runs as long as it needs while a stalled
|
|
13
|
+
peer or a stale keep-alive socket is dropped instead of pinning a
|
|
14
|
+
thread until the proxy stops answering.
|
|
15
|
+
- **`yamine doctor` gains a "serving ca" check.** It verifies the
|
|
16
|
+
certificate the proxy actually serves against the CA now on disk and
|
|
17
|
+
names the fix when they disagree — so a regenerated CA shows up in
|
|
18
|
+
doctor instead of surfacing later as `ERR_CERT_AUTHORITY_INVALID` in
|
|
19
|
+
the browser.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`yamine proxy start` no longer spawns a second proxy when one
|
|
24
|
+
already serves the port.** It reports that idempotently (new
|
|
25
|
+
`ProxyAlreadyRunningError`) instead of recording a dead PID over
|
|
26
|
+
real state — the shape that left the recorded PID pointing at
|
|
27
|
+
nothing while the real proxy kept serving.
|
|
28
|
+
- **`yamine proxy start` on a privileged port elevates the way the
|
|
29
|
+
boot path does.** One sudo prompt when interactive, or a failure
|
|
30
|
+
pointing at `yamine setup` when not — never a doomed unprivileged
|
|
31
|
+
child that dies on `EACCES` and leaves its PID behind.
|
|
32
|
+
- **`yamine proxy stop` tells the truth about a proxy it cannot
|
|
33
|
+
signal.** On a dead recorded PID it probes the port first: a proxy
|
|
34
|
+
still serving — the root launchd/systemd service, which the CLI
|
|
35
|
+
cannot kill — yields a "still serving" answer with the restart
|
|
36
|
+
command and the recorded state kept, instead of "Removed stale
|
|
37
|
+
proxy state" while the proxy kept running.
|
|
38
|
+
- **`yamine clean` refuses while a proxy it cannot stop is serving.**
|
|
39
|
+
It exits 1 instead of untrusting the CA and deleting the state
|
|
40
|
+
directory out from under a live root service.
|
|
41
|
+
- **A running proxy reloads the signing CA when the pair on disk
|
|
42
|
+
changes.** It re-reads the CA files and drops cached host
|
|
43
|
+
certificates, so a regenerated CA takes effect without a restart —
|
|
44
|
+
previously only a restart fixed it, and until then the proxy kept
|
|
45
|
+
signing certificates no browser trusts.
|
|
46
|
+
- **The TLS handshake moved out of the acceptor into the connection
|
|
47
|
+
thread.** A client that connects and sends nothing can no longer pin
|
|
48
|
+
an acceptor; acceptors now survive per-connection errors and keep
|
|
49
|
+
accepting.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **A spawned daemon that dies before serving fails immediately with
|
|
54
|
+
the proxy log tail instead of a timeout.** A child killed by
|
|
55
|
+
`EACCES` on a privileged port (or anything else fatal at bind)
|
|
56
|
+
used to burn the whole wait and then record a PID that was never
|
|
57
|
+
alive; it now fails fast and records nothing.
|
|
58
|
+
|
|
59
|
+
## [0.19.0] — 2026-09-27
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- **`yamine worktree info <name> [--json]`** — detailed view of a single
|
|
64
|
+
worktree: branch, HEAD (short SHA + subject), author, commit date,
|
|
65
|
+
dirty/merged status, and any database claims. `--json` emits the same
|
|
66
|
+
data as a machine-readable hash. Follows the same safety rails as
|
|
67
|
+
`list`: main checkout is always clean, detached HEADs report n/a for
|
|
68
|
+
merged status, and the path is resolved to its real location.
|
|
69
|
+
|
|
5
70
|
## [0.18.0] — 2026-09-23
|
|
6
71
|
|
|
7
72
|
### Changed
|
data/lib/yamine/certs.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "fileutils"
|
|
4
5
|
require "openssl"
|
|
5
6
|
|
|
@@ -170,23 +171,32 @@ module Yamine
|
|
|
170
171
|
end
|
|
171
172
|
|
|
172
173
|
# Build an SSLContext whose SNI callback serves the right cert per host.
|
|
174
|
+
#
|
|
175
|
+
# The CA pair on disk can be regenerated under a live proxy (`yamine
|
|
176
|
+
# clean`/`trust`/`setup` rewrite it). A proxy that keeps signing with
|
|
177
|
+
# its boot-time CA then mints leaves no browser trusts
|
|
178
|
+
# (ERR_CERT_AUTHORITY_INVALID against the new CA), so the SNI
|
|
179
|
+
# callback — which already runs per handshake — reloads the pair
|
|
180
|
+
# when it changes and drops every leaf minted from the old one.
|
|
173
181
|
def server_context(dir = state_dir)
|
|
174
|
-
|
|
182
|
+
watcher = CaWatcher.new(dir)
|
|
175
183
|
cache = CertCache.new(CACHE_SIZE)
|
|
184
|
+
watcher.on_reload = -> { cache.clear }
|
|
176
185
|
ctx = OpenSSL::SSL::SSLContext.new
|
|
177
|
-
ctx.cert =
|
|
178
|
-
ctx.key = ca_key
|
|
186
|
+
ctx.cert, ctx.key = watcher.current
|
|
179
187
|
# ruby-openssl versions differ in how the callback receives its
|
|
180
188
|
# arguments: [[socket, name]] (one array arg), (socket, name), or
|
|
181
189
|
# (name). Flatten defensively — a raise inside the callback
|
|
182
190
|
# surfaces as an unrecognized-name handshake alert.
|
|
183
191
|
ctx.servername_cb = lambda do |*args|
|
|
184
192
|
host = Array(args).flatten.last.to_s.downcase
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
193
|
+
watcher.with_current do |ca_cert, ca_key|
|
|
194
|
+
entry = cache.fetch(host) do
|
|
195
|
+
cert, key = mint_host(host, ca_cert, ca_key)
|
|
196
|
+
[cert, key]
|
|
197
|
+
end
|
|
198
|
+
entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
|
|
188
199
|
end
|
|
189
|
-
entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
|
|
190
200
|
end
|
|
191
201
|
ctx
|
|
192
202
|
end
|
|
@@ -252,6 +262,96 @@ module Yamine
|
|
|
252
262
|
def size
|
|
253
263
|
@mutex.synchronize { @store.size }
|
|
254
264
|
end
|
|
265
|
+
|
|
266
|
+
# Every entry is signed by one CA generation; a reload must not
|
|
267
|
+
# leave old-CA leaves behind to be served after the swap.
|
|
268
|
+
def clear
|
|
269
|
+
@mutex.synchronize do
|
|
270
|
+
@store.clear
|
|
271
|
+
@order.clear
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The CA pair a live server_context signs with, reloaded when the
|
|
277
|
+
# files on disk change. The hot path is two stat calls per
|
|
278
|
+
# handshake; the pair is only re-read (and the leaf cache only
|
|
279
|
+
# dropped) when a stat snapshot moves AND the digest differs, so a
|
|
280
|
+
# touch without content change costs nothing. One mutex around
|
|
281
|
+
# check+reload+mint keeps a swap from racing a handshake into a
|
|
282
|
+
# mixed pair or a stale cache hit. A failed reload keeps serving
|
|
283
|
+
# the previous CA — a raise inside the SNI callback would abort
|
|
284
|
+
# the handshake outright.
|
|
285
|
+
class CaWatcher
|
|
286
|
+
def initialize(dir)
|
|
287
|
+
@dir = dir
|
|
288
|
+
@mutex = Mutex.new
|
|
289
|
+
@cert, @key = Certs.load_ca(dir)
|
|
290
|
+
@stat = stat_snapshot
|
|
291
|
+
@digest = pair_digest
|
|
292
|
+
@on_reload = nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Called (once per swap, under the lock) after a reload. The
|
|
296
|
+
# server_context sets this to drop its leaf cache.
|
|
297
|
+
attr_writer :on_reload
|
|
298
|
+
|
|
299
|
+
def current
|
|
300
|
+
[@cert, @key]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def with_current
|
|
304
|
+
@mutex.synchronize do
|
|
305
|
+
reload_if_changed!
|
|
306
|
+
yield @cert, @key
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
private
|
|
311
|
+
|
|
312
|
+
def paths
|
|
313
|
+
Certs.ca_paths(@dir)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def stat_snapshot
|
|
317
|
+
[paths[:cert], paths[:key]].map do |path|
|
|
318
|
+
stat = File.stat(path)
|
|
319
|
+
[stat.mtime, stat.size]
|
|
320
|
+
end
|
|
321
|
+
rescue SystemCallError
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def pair_digest
|
|
326
|
+
Digest::SHA256.hexdigest(File.read(paths[:cert]) + File.read(paths[:key]))
|
|
327
|
+
rescue SystemCallError, OpenSSL::OpenSSLError
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def reload_if_changed!
|
|
332
|
+
snapshot = stat_snapshot
|
|
333
|
+
return if !snapshot.nil? && snapshot == @stat
|
|
334
|
+
|
|
335
|
+
digest = pair_digest
|
|
336
|
+
if !digest.nil? && digest == @digest
|
|
337
|
+
# Touched but unchanged: adopt the snapshot so the hot path
|
|
338
|
+
# goes back to two stats instead of a digest per handshake.
|
|
339
|
+
@stat = snapshot
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# The serving SSLContext is frozen once the server accepts, so
|
|
344
|
+
# the default certificate cannot be swapped mid-flight — and it
|
|
345
|
+
# does not need to be: every real client sends SNI, and the
|
|
346
|
+
# per-host context this callback returns carries the new CA.
|
|
347
|
+
cert, key = Certs.load_ca(@dir)
|
|
348
|
+
@cert, @key = cert, key
|
|
349
|
+
@stat = stat_snapshot
|
|
350
|
+
@digest = digest || pair_digest
|
|
351
|
+
@on_reload&.call
|
|
352
|
+
rescue CertError, OpenSSL::OpenSSLError, SystemCallError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
255
355
|
end
|
|
256
356
|
end
|
|
257
357
|
end
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -30,7 +30,16 @@ module Yamine
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def clean(ctx, _args)
|
|
33
|
-
|
|
33
|
+
# A root service the CLI cannot signal keeps serving past stop:
|
|
34
|
+
# deleting the state dir (and untrusting the CA) then would
|
|
35
|
+
# orphan the live proxy and break its TLS. Refuse with the fix
|
|
36
|
+
# instead; the state on disk still describes the live proxy.
|
|
37
|
+
if ProxyControl.stop(ctx.store) == :needs_root
|
|
38
|
+
$stderr.puts "Cannot clean: the proxy runs as root (the launchd/systemd service) and is still serving."
|
|
39
|
+
$stderr.puts " Restart it: sudo launchctl kickstart -k system/#{LAUNCHD_LABEL}"
|
|
40
|
+
$stderr.puts " Or uninstall it: sudo yamine service uninstall — then re-run `yamine clean`."
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
34
43
|
result = Trust.untrust
|
|
35
44
|
puts "CA removed from trust store." if result[:removed]
|
|
36
45
|
warn "CA untrust failed: #{result[:error]}" if result[:error]
|
|
@@ -91,7 +100,33 @@ module Yamine
|
|
|
91
100
|
Proxy.new(store: ctx.store, port: port, tls: tls,
|
|
92
101
|
state_dir: ctx.store.dir, supervisor: sup, tlds: tlds).start_foreground
|
|
93
102
|
else
|
|
94
|
-
|
|
103
|
+
# Privileged ports need root exactly like the boot path:
|
|
104
|
+
# spawn elevated when interactive (one sudo prompt),
|
|
105
|
+
# otherwise fail with the setup hint — never a doomed
|
|
106
|
+
# unprivileged child that dies of EACCES.
|
|
107
|
+
privileged = port < 1024 && !ProxyControl.root?
|
|
108
|
+
if privileged && !ctx.interactive?
|
|
109
|
+
$stderr.puts "Error: proxy is not running and port #{port} needs root."
|
|
110
|
+
$stderr.puts " Human: run this once — yamine setup"
|
|
111
|
+
$stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
|
|
112
|
+
$stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
|
|
113
|
+
$stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
|
|
114
|
+
$stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
|
|
115
|
+
exit 1
|
|
116
|
+
end
|
|
117
|
+
begin
|
|
118
|
+
ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls,
|
|
119
|
+
sudo: privileged, tlds: tlds)
|
|
120
|
+
rescue ProxyAlreadyRunningError => e
|
|
121
|
+
# Idempotent: the proxy we wanted is already serving, so
|
|
122
|
+
# report that instead of a false "started".
|
|
123
|
+
puts e.message
|
|
124
|
+
return
|
|
125
|
+
rescue ProxyNotRunningError => e
|
|
126
|
+
$stderr.puts "Error: #{e.message.lines.first&.strip}"
|
|
127
|
+
$stderr.puts " Fix once: yamine setup"
|
|
128
|
+
exit 1
|
|
129
|
+
end
|
|
95
130
|
puts "Proxy started on port #{port}#{tls ? " (HTTPS)" : " (HTTP)"}."
|
|
96
131
|
end
|
|
97
132
|
when "stop"
|
|
@@ -102,10 +137,12 @@ module Yamine
|
|
|
102
137
|
when :unknown_process then puts "Port in use by an unknown process."
|
|
103
138
|
when :needs_root
|
|
104
139
|
# A root service keeps serving; say so rather than implying
|
|
105
|
-
# success.
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
$stderr.puts "
|
|
140
|
+
# success. Restarting picks up new state, uninstalling
|
|
141
|
+
# removes it — signalling the pid would just leave
|
|
142
|
+
# launchd's KeepAlive restarting it.
|
|
143
|
+
$stderr.puts "The proxy runs as root (the launchd/systemd service) and is still serving."
|
|
144
|
+
$stderr.puts " Restart it: sudo launchctl kickstart -k system/#{LAUNCHD_LABEL}"
|
|
145
|
+
$stderr.puts " Or uninstall it: sudo yamine service uninstall"
|
|
109
146
|
exit 1
|
|
110
147
|
end
|
|
111
148
|
else
|
|
@@ -669,6 +706,10 @@ module Yamine
|
|
|
669
706
|
end
|
|
670
707
|
ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
|
|
671
708
|
wait_for_ours(ctx, port, tls: tls)
|
|
709
|
+
rescue ProxyAlreadyRunningError
|
|
710
|
+
# The root service (or another daemon) already serves 443: that
|
|
711
|
+
# IS the privileged proxy setup wanted — verify and report.
|
|
712
|
+
wait_for_ours(ctx, port, tls: tls)
|
|
672
713
|
rescue Yamine::ProxyNotRunningError, SystemCallError => e
|
|
673
714
|
warn " daemon start failed: #{e.message.lines.first&.strip}"
|
|
674
715
|
false
|
data/lib/yamine/cli/worktree.rb
CHANGED
|
@@ -52,12 +52,13 @@ module Yamine
|
|
|
52
52
|
sub = args.first
|
|
53
53
|
case sub
|
|
54
54
|
when "list", nil then list(ctx, args[1..] || [])
|
|
55
|
+
when "info" then info(ctx, args[1..] || [])
|
|
55
56
|
when "add" then add(ctx, args[1..] || [])
|
|
56
57
|
when "remove" then remove(ctx, args[1..] || [])
|
|
57
58
|
when "clean" then clean(ctx, args[1..] || [])
|
|
58
59
|
else
|
|
59
60
|
raise Error,
|
|
60
|
-
"Usage: yamine worktree list | add <name> [--dir <path>] [--no-install] | " \
|
|
61
|
+
"Usage: yamine worktree list | info <name> [--json] | add <name> [--dir <path>] [--no-install] | " \
|
|
61
62
|
"remove <name> [--force] | clean [--all] [--dry-run]"
|
|
62
63
|
end
|
|
63
64
|
end
|
|
@@ -110,6 +111,59 @@ module Yamine
|
|
|
110
111
|
line
|
|
111
112
|
end
|
|
112
113
|
|
|
114
|
+
# yamine worktree info <name> [--json]
|
|
115
|
+
#
|
|
116
|
+
# Detailed view of a single worktree: branch, HEAD, author, date,
|
|
117
|
+
# dirty/merged status, and any database claims.
|
|
118
|
+
def info(ctx, args)
|
|
119
|
+
opts, rest = take_flags(args, value_flags: [], flags: %w[--json])
|
|
120
|
+
name = rest.first
|
|
121
|
+
raise Error, "Usage: yamine worktree info <name> [--json]" unless name
|
|
122
|
+
|
|
123
|
+
repo = repo_top || abort_not_in_repo
|
|
124
|
+
entry = Worktrees.find(repo, name)
|
|
125
|
+
unless entry
|
|
126
|
+
raise Error, "no worktree named #{name.inspect} here — `yamine worktree list` shows what exists"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
map = Database.load_map(ctx.store.dir)
|
|
130
|
+
claim = claim_for(map, entry.path)
|
|
131
|
+
default_branch = Worktrees.default_branch(repo)
|
|
132
|
+
|
|
133
|
+
result = Worktrees.info(repo, entry, default_branch: default_branch)
|
|
134
|
+
result[:databases] = database_names(claim)
|
|
135
|
+
|
|
136
|
+
if opts[:json]
|
|
137
|
+
require "json"
|
|
138
|
+
puts JSON.pretty_generate(result)
|
|
139
|
+
else
|
|
140
|
+
print_info(result)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def database_names(claim)
|
|
145
|
+
return [] unless claim
|
|
146
|
+
|
|
147
|
+
info = claim[1]
|
|
148
|
+
names = info.is_a?(Hash) ? info["names"] : nil
|
|
149
|
+
return [claim[0]] unless names.is_a?(Hash) && !names.empty?
|
|
150
|
+
|
|
151
|
+
names.values
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def print_info(result)
|
|
155
|
+
puts " branch #{result[:branch]}"
|
|
156
|
+
puts " path #{result[:path]}"
|
|
157
|
+
puts " HEAD #{result[:head]}"
|
|
158
|
+
puts " author #{result[:author]}" if result[:author]
|
|
159
|
+
puts " date #{result[:date]}" if result[:date]
|
|
160
|
+
puts " dirty #{result[:dirty] ? "yes" : "no"}"
|
|
161
|
+
puts " merged #{result[:merged].nil? ? "n/a" : (result[:merged] ? "yes" : "no")}"
|
|
162
|
+
if result[:databases] && !result[:databases].empty?
|
|
163
|
+
result[:databases].each { |db| puts " database #{db}" }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
113
167
|
# Every database a claim stands for, one per line under the row —
|
|
114
168
|
# five names inline would unreadable, and hiding them would
|
|
115
169
|
# re-create the "which database is this worktree even using"
|
data/lib/yamine/doctor.rb
CHANGED
|
@@ -34,6 +34,7 @@ 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)
|
|
37
38
|
checks
|
|
38
39
|
end
|
|
39
40
|
|
|
@@ -224,6 +225,38 @@ module Yamine
|
|
|
224
225
|
Check.new(name: "bundle", ok: false, message: "could not build #{Certs.bundle_path(dir)}: #{e.message}")
|
|
225
226
|
end
|
|
226
227
|
|
|
228
|
+
# The certificate the proxy ACTUALLY serves, verified against the CA
|
|
229
|
+
# now on disk. The CA can be regenerated under a live proxy
|
|
230
|
+
# (`yamine clean`/`trust`/`setup` rewrite the pair); without this
|
|
231
|
+
# check doctor reported "[ok] proxy: listening" and "[ok] ca: CA
|
|
232
|
+
# trusted" while every browser got ERR_CERT_AUTHORITY_INVALID,
|
|
233
|
+
# because nothing compared the served leaf to the current CA.
|
|
234
|
+
def check_serving_ca(port, tls:)
|
|
235
|
+
return Check.new(name: "serving ca", ok: true, message: "TLS off — no serving certificate to check") unless tls
|
|
236
|
+
return Check.new(name: "serving ca", ok: true, message: "no proxy serving — skipped") if port.nil?
|
|
237
|
+
|
|
238
|
+
dir = Certs.state_dir
|
|
239
|
+
ca_path = Certs.ca_paths(dir)[:cert]
|
|
240
|
+
return Check.new(name: "serving ca", ok: true, message: "no CA on disk — skipped") unless File.file?(ca_path)
|
|
241
|
+
|
|
242
|
+
leaf = Trust.proxy_peer_cert(port)
|
|
243
|
+
return Check.new(name: "serving ca", ok: true, warn: true,
|
|
244
|
+
message: "could not read the proxy's serving certificate — skipped") if leaf.nil?
|
|
245
|
+
|
|
246
|
+
ca = OpenSSL::X509::Certificate.new(File.read(ca_path))
|
|
247
|
+
if Trust.signed_by?(leaf, ca)
|
|
248
|
+
Check.new(name: "serving ca", ok: true, message: "serving certificate matches the CA on disk")
|
|
249
|
+
else
|
|
250
|
+
Check.new(name: "serving ca", ok: false,
|
|
251
|
+
message: "proxy serves a certificate signed by a superseded CA — " \
|
|
252
|
+
"restart it: sudo launchctl kickstart -k system/dev.yamine " \
|
|
253
|
+
"(or sudo yamine service install to re-register the service after a gem upgrade)")
|
|
254
|
+
end
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
Check.new(name: "serving ca", ok: true, warn: true,
|
|
257
|
+
message: "could not check the serving certificate (#{e.message}) — skipped")
|
|
258
|
+
end
|
|
259
|
+
|
|
227
260
|
# How many trusted certificates carry one of our CA names without
|
|
228
261
|
# being the CA currently on disk. Best-effort: 0 when the keychain
|
|
229
262
|
# cannot be read (doctor must never fail on a read-only probe).
|
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/proxy.rb
CHANGED
|
@@ -23,6 +23,20 @@ module Yamine
|
|
|
23
23
|
# Bounded concurrency: beyond this many simultaneous connections the
|
|
24
24
|
# proxy answers 503 instead of spawning threads without limit.
|
|
25
25
|
MAX_CONNECTIONS = Integer(ENV.fetch("YAMINE_MAX_CONNECTIONS", "200"))
|
|
26
|
+
# Idle bound: waiting for the NEXT byte on any client or backend
|
|
27
|
+
# socket gives up after this many seconds. The clock resets on every
|
|
28
|
+
# byte transferred, so it caps silence — never total duration. A
|
|
29
|
+
# slow trickle (SSE/NDJSON chat stream, big download) runs as long as
|
|
30
|
+
# bytes keep moving; a peer that goes quiet is dropped instead of
|
|
31
|
+
# pinning a thread. Without this, stale keep-alive sockets and
|
|
32
|
+
# stalled peers accumulate until the proxy stops answering.
|
|
33
|
+
# Named YAMINE_PROXY_IDLE_TIMEOUT (not YAMINE_IDLE_TIMEOUT — that one
|
|
34
|
+
# is the Supervisor's app-idle kill, a different clock entirely).
|
|
35
|
+
IDLE_TIMEOUT = Float(ENV.fetch("YAMINE_PROXY_IDLE_TIMEOUT", "60"))
|
|
36
|
+
# Raised when a peer is silent past the idle bound. An IOError so the
|
|
37
|
+
# existing rescue-and-close paths treat it like any dead peer.
|
|
38
|
+
IdleTimeout = Class.new(IOError)
|
|
39
|
+
CHUNK_BYTES = 16_384
|
|
26
40
|
# Route cache: routes.json is the source of truth, but re-reading
|
|
27
41
|
# and re-parsing it on every request is wasteful under HMR polling.
|
|
28
42
|
# Keyed on file mtime, so a freshly registered route is visible on
|
|
@@ -33,7 +47,7 @@ module Yamine
|
|
|
33
47
|
LoopDetected = Struct.new(:host, :hops)
|
|
34
48
|
|
|
35
49
|
def initialize(store:, port: 443, tls: true, state_dir: nil, on_error: nil,
|
|
36
|
-
supervisor: nil, max_connections: MAX_CONNECTIONS, tlds: nil)
|
|
50
|
+
supervisor: nil, max_connections: MAX_CONNECTIONS, idle_timeout: IDLE_TIMEOUT, tlds: nil)
|
|
37
51
|
@store = store
|
|
38
52
|
@port = port
|
|
39
53
|
@tls = tls
|
|
@@ -41,6 +55,7 @@ module Yamine
|
|
|
41
55
|
@on_error = on_error || ->(msg) { warn msg }
|
|
42
56
|
@supervisor = supervisor
|
|
43
57
|
@max_connections = max_connections
|
|
58
|
+
@idle_timeout = idle_timeout
|
|
44
59
|
@tlds = Array(tlds).flatten.compact.map(&:downcase)
|
|
45
60
|
@tlds = [Hostname::DEFAULT_TLD] if @tlds.empty?
|
|
46
61
|
@inflight = 0
|
|
@@ -92,7 +107,17 @@ module Yamine
|
|
|
92
107
|
end
|
|
93
108
|
end
|
|
94
109
|
rescue OpenSSL::SSL::SSLError, IOError, SystemCallError
|
|
95
|
-
|
|
110
|
+
# A per-connection accept failure (bad TLS handshake,
|
|
111
|
+
# reset peer) is one dropped connection, not a reason to
|
|
112
|
+
# stop listening. Only shutdown breaks the loop.
|
|
113
|
+
next if @running
|
|
114
|
+
break
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
# An unexpected accept error must not silently kill the
|
|
117
|
+
# acceptor either: log it and keep serving while running.
|
|
118
|
+
@on_error.call("accept error: #{e.class}: #{e.message}")
|
|
119
|
+
next if @running
|
|
120
|
+
break
|
|
96
121
|
end
|
|
97
122
|
end
|
|
98
123
|
end
|
|
@@ -180,17 +205,29 @@ module Yamine
|
|
|
180
205
|
[TCPServer.new("127.0.0.1", @port), TCPServer.new("::1", @port)].map do |server|
|
|
181
206
|
next server unless @tls
|
|
182
207
|
|
|
183
|
-
OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))
|
|
208
|
+
with_deferred_handshake(OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir)))
|
|
184
209
|
end
|
|
185
210
|
rescue SystemCallError
|
|
186
211
|
server = TCPServer.new("127.0.0.1", @port)
|
|
187
|
-
@tls ? [OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))] : [server]
|
|
212
|
+
@tls ? [with_deferred_handshake(OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir)))] : [server]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The TLS handshake reads from the peer, so doing it inside
|
|
216
|
+
# SSLServer#accept would let a connect-and-send-nothing client pin an
|
|
217
|
+
# acceptor thread forever (and with both acceptors pinned the proxy
|
|
218
|
+
# stops answering). Defer it: accept returns after the TCP handshake
|
|
219
|
+
# and the per-connection thread finishes TLS under the idle bound in
|
|
220
|
+
# #handle. SNI/cert behavior is unchanged — only the timing moves.
|
|
221
|
+
def with_deferred_handshake(ssl_server)
|
|
222
|
+
ssl_server.start_immediately = false
|
|
223
|
+
ssl_server
|
|
188
224
|
end
|
|
189
225
|
|
|
190
226
|
# One connection = many requests (keep-alive). Each request head is
|
|
191
227
|
# re-parsed and rewritten; bodies are framed by Content-Length;
|
|
192
228
|
# responses with Content-Length allow the loop to continue.
|
|
193
229
|
def handle(sock)
|
|
230
|
+
tls_handshake(sock)
|
|
194
231
|
buf = +""
|
|
195
232
|
loop do
|
|
196
233
|
head, buf = read_head(sock, buf)
|
|
@@ -256,13 +293,13 @@ module Yamine
|
|
|
256
293
|
# the response. Returns :keep_alive when both sides want to reuse
|
|
257
294
|
# the connection and the response length was known.
|
|
258
295
|
def pipe_request(sock, backend, method, target, headers, buf)
|
|
259
|
-
backend
|
|
296
|
+
write_all(backend, rebuild_head(method, target, headers))
|
|
260
297
|
|
|
261
298
|
body_len = request_body_length(headers)
|
|
262
299
|
if body_len == :chunked
|
|
263
300
|
# Unclassifiable request body: stream to EOF, close after.
|
|
264
|
-
backend
|
|
265
|
-
|
|
301
|
+
write_all(backend, buf) unless buf.empty?
|
|
302
|
+
copy_stream(sock, backend)
|
|
266
303
|
relay_response_close(backend, sock)
|
|
267
304
|
return :close
|
|
268
305
|
end
|
|
@@ -270,13 +307,11 @@ module Yamine
|
|
|
270
307
|
remaining = body_len
|
|
271
308
|
unless buf.empty?
|
|
272
309
|
from_buf = buf.byteslice(0, remaining)
|
|
273
|
-
backend
|
|
310
|
+
write_all(backend, from_buf)
|
|
274
311
|
remaining -= from_buf.bytesize
|
|
275
312
|
buf = buf.byteslice(from_buf.bytesize..) || +""
|
|
276
313
|
end
|
|
277
|
-
if remaining > 0
|
|
278
|
-
IO.copy_stream(sock, backend, remaining)
|
|
279
|
-
end
|
|
314
|
+
copy_stream(sock, backend, remaining) if remaining > 0
|
|
280
315
|
|
|
281
316
|
rhead, rbuf = read_head(backend, +"")
|
|
282
317
|
if rhead.nil?
|
|
@@ -284,19 +319,17 @@ module Yamine
|
|
|
284
319
|
return :close
|
|
285
320
|
end
|
|
286
321
|
_rm, _rt, rheaders = parse_head(rhead)
|
|
287
|
-
sock
|
|
288
|
-
sock
|
|
322
|
+
write_all(sock, rhead)
|
|
323
|
+
write_all(sock, rbuf) unless rbuf.empty?
|
|
289
324
|
|
|
290
325
|
rlen = content_length(rheaders)
|
|
291
326
|
if rlen.nil?
|
|
292
327
|
# No Content-Length: close-delimited response.
|
|
293
|
-
|
|
328
|
+
copy_stream(backend, sock)
|
|
294
329
|
return :close
|
|
295
330
|
end
|
|
296
331
|
remaining = rlen - rbuf.bytesize
|
|
297
|
-
if remaining > 0
|
|
298
|
-
IO.copy_stream(backend, sock, remaining)
|
|
299
|
-
end
|
|
332
|
+
copy_stream(backend, sock, remaining) if remaining > 0
|
|
300
333
|
|
|
301
334
|
if keep_alive?(headers) && keep_alive?(rheaders)
|
|
302
335
|
# Any bytes beyond Content-Length on the backend are a second
|
|
@@ -311,9 +344,9 @@ module Yamine
|
|
|
311
344
|
rhead, rbuf = read_head(backend, +"")
|
|
312
345
|
return if rhead.nil?
|
|
313
346
|
|
|
314
|
-
sock
|
|
315
|
-
sock
|
|
316
|
-
|
|
347
|
+
write_all(sock, rhead)
|
|
348
|
+
write_all(sock, rbuf) unless rbuf.empty?
|
|
349
|
+
copy_stream(backend, sock)
|
|
317
350
|
rescue IOError, SystemCallError
|
|
318
351
|
nil
|
|
319
352
|
end
|
|
@@ -331,8 +364,8 @@ module Yamine
|
|
|
331
364
|
set_forwarded(headers, sock, tls: @tls)
|
|
332
365
|
|
|
333
366
|
backend = dial(entry)
|
|
334
|
-
backend
|
|
335
|
-
backend
|
|
367
|
+
write_all(backend, rebuild_head(method, target, headers))
|
|
368
|
+
write_all(backend, buf) unless buf.empty?
|
|
336
369
|
pipe_both(sock, backend)
|
|
337
370
|
rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
|
|
338
371
|
@on_error.call("upgrade proxy error: #{e.message}")
|
|
@@ -369,9 +402,11 @@ module Yamine
|
|
|
369
402
|
true
|
|
370
403
|
end
|
|
371
404
|
|
|
372
|
-
# Read just the header block
|
|
373
|
-
#
|
|
374
|
-
#
|
|
405
|
+
# Read just the header block without stdio buffering (so nothing is
|
|
406
|
+
# stolen from the body stream). `buf` carries bytes read ahead
|
|
407
|
+
# (pipelined requests) across calls. Returns [head, buf]. The wait
|
|
408
|
+
# for more bytes is idle-bounded: a peer that sends nothing is
|
|
409
|
+
# dropped (nil) instead of pinning the thread.
|
|
375
410
|
def read_head(sock, buf)
|
|
376
411
|
loop do
|
|
377
412
|
if (idx = buf.index("\r\n\r\n"))
|
|
@@ -379,13 +414,109 @@ module Yamine
|
|
|
379
414
|
end
|
|
380
415
|
return [nil, buf] if buf.bytesize > MAX_HEAD_BYTES
|
|
381
416
|
|
|
382
|
-
|
|
383
|
-
buf << chunk
|
|
417
|
+
buf << read_chunk(sock, CHUNK_BYTES)
|
|
384
418
|
end
|
|
385
419
|
rescue EOFError, IOError
|
|
386
420
|
[nil, +""]
|
|
387
421
|
end
|
|
388
422
|
|
|
423
|
+
# Finish a deferred TLS handshake under the idle bound. A no-op for
|
|
424
|
+
# plain sockets and for SSLSockets that already handshook (a second
|
|
425
|
+
# accept on an established connection returns immediately).
|
|
426
|
+
def tls_handshake(sock)
|
|
427
|
+
return unless sock.is_a?(OpenSSL::SSL::SSLSocket)
|
|
428
|
+
|
|
429
|
+
loop do
|
|
430
|
+
result = sock.accept_nonblock(exception: false)
|
|
431
|
+
return if result.is_a?(OpenSSL::SSL::SSLSocket)
|
|
432
|
+
|
|
433
|
+
wait_for(sock, result)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# One idle-bounded read: returns bytes, raises EOFError at end of
|
|
438
|
+
# stream, IdleTimeout when the peer is silent past the bound. Never
|
|
439
|
+
# blocks in the kernel without a select deadline, so no stalled peer
|
|
440
|
+
# pins the thread — including mid-TLS-handshake stalls, which plain
|
|
441
|
+
# readpartial would ride out forever.
|
|
442
|
+
def read_chunk(sock, size)
|
|
443
|
+
loop do
|
|
444
|
+
result = sock.read_nonblock(size, exception: false)
|
|
445
|
+
return result if result.is_a?(String)
|
|
446
|
+
raise EOFError, "end of file reached" if result.nil?
|
|
447
|
+
|
|
448
|
+
wait_for(sock, result)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Idle-bounded write of the whole string. A peer that stops reading
|
|
453
|
+
# (full window) stalls here only until the bound, then IdleTimeout.
|
|
454
|
+
def write_all(sock, data)
|
|
455
|
+
offset = 0
|
|
456
|
+
while offset < data.bytesize
|
|
457
|
+
result = sock.write_nonblock(data.byteslice(offset..), exception: false)
|
|
458
|
+
if result.is_a?(Integer)
|
|
459
|
+
# A zero write made no progress: wait like a blocked writer
|
|
460
|
+
# instead of spinning.
|
|
461
|
+
result.zero? ? wait_for(sock, :wait_writable) : offset += result
|
|
462
|
+
else
|
|
463
|
+
wait_for(sock, result)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
offset
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Idle-bounded relay. length nil copies to EOF (close-delimited or
|
|
470
|
+
# chunked-upload bodies); otherwise exactly length bytes. Every byte
|
|
471
|
+
# that moves resets the idle clock, so a slow trickle survives while
|
|
472
|
+
# a stalled source is abandoned with IdleTimeout. A truncated fixed
|
|
473
|
+
# body re-raises EOFError like IO.copy_stream did.
|
|
474
|
+
def copy_stream(src, dst, length = nil)
|
|
475
|
+
remaining = length
|
|
476
|
+
loop do
|
|
477
|
+
break if !remaining.nil? && remaining <= 0
|
|
478
|
+
|
|
479
|
+
size = remaining.nil? ? CHUNK_BYTES : [CHUNK_BYTES, remaining].min
|
|
480
|
+
begin
|
|
481
|
+
data = read_chunk(src, size)
|
|
482
|
+
rescue EOFError
|
|
483
|
+
raise if !remaining.nil? && remaining.positive?
|
|
484
|
+
|
|
485
|
+
break
|
|
486
|
+
end
|
|
487
|
+
write_all(dst, data)
|
|
488
|
+
remaining -= data.bytesize unless remaining.nil?
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Block until the socket is ready for the direction a nonblocking op
|
|
493
|
+
# asked for; IdleTimeout when the bound passes with no progress.
|
|
494
|
+
def wait_for(sock, wait_kind)
|
|
495
|
+
if wait_kind == :wait_readable
|
|
496
|
+
raise IdleTimeout, "idle timeout after #{@idle_timeout}s with no bytes" unless readable?(sock)
|
|
497
|
+
elsif !writable?(sock)
|
|
498
|
+
raise IdleTimeout, "idle timeout after #{@idle_timeout}s with no bytes"
|
|
499
|
+
end
|
|
500
|
+
nil
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# select(2) with the idle bound. SSL-buffered bytes count as
|
|
504
|
+
# readable without a syscall. A closed socket raises in select —
|
|
505
|
+
# report not-ready and let the nonblocking op raise the real error.
|
|
506
|
+
def readable?(sock)
|
|
507
|
+
return true if sock.respond_to?(:pending) && sock.pending.positive?
|
|
508
|
+
|
|
509
|
+
!IO.select([sock], nil, nil, @idle_timeout).nil?
|
|
510
|
+
rescue IOError, SystemCallError
|
|
511
|
+
false
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def writable?(sock)
|
|
515
|
+
!IO.select(nil, [sock], nil, @idle_timeout).nil?
|
|
516
|
+
rescue IOError, SystemCallError
|
|
517
|
+
false
|
|
518
|
+
end
|
|
519
|
+
|
|
389
520
|
def parse_head(head)
|
|
390
521
|
lines = head.split("\r\n")
|
|
391
522
|
method, target, _version = lines.first.to_s.split(" ", 3)
|
|
@@ -432,7 +563,9 @@ module Yamine
|
|
|
432
563
|
end
|
|
433
564
|
|
|
434
565
|
# Raw bidirectional pump for upgrades; either side closing unblocks
|
|
435
|
-
# the other.
|
|
566
|
+
# the other. Deliberately outside the idle bound: an idle websocket
|
|
567
|
+
# is legitimate traffic (silence is the normal state), and framing
|
|
568
|
+
# no longer applies once the connection is hijacked.
|
|
436
569
|
def pipe_both(client, backend)
|
|
437
570
|
t1 = Thread.new do
|
|
438
571
|
IO.copy_stream(client, backend)
|
|
@@ -508,11 +641,11 @@ module Yamine
|
|
|
508
641
|
|
|
509
642
|
def respond(sock, status, body)
|
|
510
643
|
message = { 404 => "Not Found", 502 => "Bad Gateway", 508 => "Loop Detected" }[status]
|
|
511
|
-
sock
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
644
|
+
write_all(sock, "HTTP/1.1 #{status} #{message}\r\n" \
|
|
645
|
+
"Content-Type: text/html\r\n" \
|
|
646
|
+
"#{HEALTH_HEADER}: 1\r\n" \
|
|
647
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
648
|
+
"Connection: close\r\n\r\n#{body}")
|
|
516
649
|
rescue IOError, SystemCallError
|
|
517
650
|
nil
|
|
518
651
|
end
|
data/lib/yamine/proxy_control.rb
CHANGED
|
@@ -247,6 +247,13 @@ module Yamine
|
|
|
247
247
|
# explicitly because sudo does not preserve the environment.
|
|
248
248
|
# Returns pid.
|
|
249
249
|
def spawn_daemon(store:, port:, tls:, sudo: false, tlds: nil)
|
|
250
|
+
# Never spawn over a live proxy: the readiness probe below would
|
|
251
|
+
# pass on ITS behalf while our child dies of EACCES, and the dead
|
|
252
|
+
# pid would then be recorded over the real proxy's state.
|
|
253
|
+
if ours?(port, tls: tls)
|
|
254
|
+
raise ProxyAlreadyRunningError,
|
|
255
|
+
"A yamine proxy is already serving on port #{port} — not starting another."
|
|
256
|
+
end
|
|
250
257
|
store.ensure_dir
|
|
251
258
|
log_path = File.join(store.dir, LOG_NAME)
|
|
252
259
|
Log.rotate(log_path)
|
|
@@ -261,6 +268,14 @@ module Yamine
|
|
|
261
268
|
Process.detach(pid)
|
|
262
269
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
|
|
263
270
|
until ours?(port, tls: tls)
|
|
271
|
+
# The child died before serving (EACCES on a privileged port,
|
|
272
|
+
# EADDRINUSE on a taken one): fail now with its log instead of
|
|
273
|
+
# waiting out the deadline and blaming a timeout.
|
|
274
|
+
unless pid_alive?(pid)
|
|
275
|
+
raise ProxyNotRunningError,
|
|
276
|
+
"Proxy exited before serving on port #{port}. " \
|
|
277
|
+
"Log (#{log_path}):\n#{log_tail(log_path)}"
|
|
278
|
+
end
|
|
264
279
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
265
280
|
raise ProxyNotRunningError,
|
|
266
281
|
"Proxy did not start on port #{port}. " \
|
|
@@ -269,6 +284,15 @@ module Yamine
|
|
|
269
284
|
|
|
270
285
|
sleep 0.25
|
|
271
286
|
end
|
|
287
|
+
# The probe can pass on someone else's behalf (a root service that
|
|
288
|
+
# started answering between our pre-spawn check and now, while our
|
|
289
|
+
# child died): never record a pid that is not alive.
|
|
290
|
+
unless pid_alive?(pid)
|
|
291
|
+
raise ProxyNotRunningError,
|
|
292
|
+
"Proxy process #{pid} is not running, but port #{port} answers. " \
|
|
293
|
+
"Another proxy (likely the root service) is serving it — not recording #{pid}. " \
|
|
294
|
+
"Log (#{log_path}):\n#{log_tail(log_path)}"
|
|
295
|
+
end
|
|
272
296
|
write_proxy_state(store, pid: pid, port: port, tls: tls)
|
|
273
297
|
pid
|
|
274
298
|
end
|
|
@@ -296,6 +320,13 @@ module Yamine
|
|
|
296
320
|
return :unknown_process
|
|
297
321
|
end
|
|
298
322
|
unless pid_alive?(pid)
|
|
323
|
+
# The recorded process is gone, but someone may still be serving
|
|
324
|
+
# the recorded port (a root service the CLI cannot signal):
|
|
325
|
+
# clearing the state then would orphan the live proxy, leaving
|
|
326
|
+
# nothing on disk to find or stop it by. Probe first.
|
|
327
|
+
if port && ours?(port, tls: proxy_tls(store))
|
|
328
|
+
return :needs_root
|
|
329
|
+
end
|
|
299
330
|
clear_pid(store)
|
|
300
331
|
return :stale
|
|
301
332
|
end
|
data/lib/yamine/version.rb
CHANGED
data/lib/yamine/worktrees.rb
CHANGED
|
@@ -52,6 +52,36 @@ module Yamine
|
|
|
52
52
|
entries
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Detailed information about a single worktree. Returns a Hash with
|
|
56
|
+
# keys: :branch, :path, :head, :author, :date, :dirty, :merged.
|
|
57
|
+
def info(dir, entry, default_branch: nil)
|
|
58
|
+
repo = dir
|
|
59
|
+
branch_name = entry.main? ? "main" : (entry.branch || "(detached)")
|
|
60
|
+
|
|
61
|
+
head, subject, author_name, author_email, date = commit_info(entry.path)
|
|
62
|
+
|
|
63
|
+
dirty = entry.exists? &&
|
|
64
|
+
!entry.main? &&
|
|
65
|
+
dirty?(entry.path, ignore: [])
|
|
66
|
+
|
|
67
|
+
merged = if entry.detached || entry.main?
|
|
68
|
+
nil
|
|
69
|
+
else
|
|
70
|
+
db = default_branch || default_branch_for(repo)
|
|
71
|
+
merged?(repo, entry.branch, into: db)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
branch: branch_name,
|
|
76
|
+
path: File.expand_path(entry.path),
|
|
77
|
+
head: head,
|
|
78
|
+
author: author_name && author_email ? "#{author_name} <#{author_email}>" : nil,
|
|
79
|
+
date: date,
|
|
80
|
+
dirty: dirty,
|
|
81
|
+
merged: merged
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
55
85
|
# The worktree whose branch or directory basename is `name`.
|
|
56
86
|
def find(dir, name)
|
|
57
87
|
list(dir).find do |entry|
|
|
@@ -143,5 +173,30 @@ module Yamine
|
|
|
143
173
|
rescue SystemCallError, ArgumentError
|
|
144
174
|
["", nil]
|
|
145
175
|
end
|
|
176
|
+
|
|
177
|
+
# Return [short_sha, subject, author_name, author_email, iso_date]
|
|
178
|
+
# for the HEAD commit of `path`. Any field may be nil when git fails.
|
|
179
|
+
def commit_info(path)
|
|
180
|
+
out, status = git(path,
|
|
181
|
+
"log", "-1", "--format=%h\t%s\t%an\t%ae\t%aI")
|
|
182
|
+
return [nil, nil, nil, nil, nil] unless status&.success?
|
|
183
|
+
|
|
184
|
+
parts = out.strip.split("\t", 5)
|
|
185
|
+
[parts[0], parts[1], parts[2], parts[3], parts[4]]
|
|
186
|
+
rescue SystemCallError, ArgumentError
|
|
187
|
+
[nil, nil, nil, nil, nil]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Resolve the default branch for merging comparisons.
|
|
191
|
+
def default_branch_for(repo)
|
|
192
|
+
Variant::DEFAULT_BRANCHES.each do |candidate|
|
|
193
|
+
return candidate if branch_exists?(repo, candidate)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
out, status = git(repo, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
197
|
+
return out.strip.sub(%r{\Arefs/remotes/origin/}, "") if status&.success? && !out.strip.empty?
|
|
198
|
+
|
|
199
|
+
"master"
|
|
200
|
+
end
|
|
146
201
|
end
|
|
147
202
|
end
|