ask-local 0.1.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.
@@ -0,0 +1,480 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+ require "uri"
6
+
7
+ module Ask
8
+ module Local
9
+ # Host-header reverse proxy: HTTPS on 443 (or HTTP on 80 with --no-tls)
10
+ # routing to unix-socket or TCP backends from the RouteStore.
11
+ #
12
+ # Pure stdlib: TCPServer + threads. HTTP/1.1 framing is honored
13
+ # per-request so keep-alive connections get header rewriting
14
+ # (X-Forwarded-*) on every request, not just the first; Upgrade
15
+ # requests (ActionCable) fall back to raw byte piping. Chunked or
16
+ # unclassifiable responses are close-delimited (connection not
17
+ # reused). Deliberately HTTP/1.1 only for v1.
18
+ class Proxy
19
+ HOPS_HEADER = "x-ask-local-hops"
20
+ HEALTH_HEADER = "x-ask-local"
21
+ MAX_HOPS = 5
22
+ MAX_HEAD_BYTES = 64 * 1024
23
+ MAX_HOSTNAME_BYTES = 253
24
+ # Bounded concurrency: beyond this many simultaneous connections the
25
+ # proxy answers 503 instead of spawning threads without limit.
26
+ MAX_CONNECTIONS = Integer(ENV.fetch("ASK_LOCAL_MAX_CONNECTIONS", "200"))
27
+ # Route cache: routes.json is the source of truth, but re-reading
28
+ # and re-parsing it on every request is wasteful under HMR polling.
29
+ # Keyed on file mtime, so a freshly registered route is visible on
30
+ # the very next request (no TTL race for agents that boot then curl).
31
+
32
+ NotFound = Struct.new(:host, :routes)
33
+ BadGateway = Struct.new(:host, :detail)
34
+ LoopDetected = Struct.new(:host, :hops)
35
+
36
+ def initialize(store:, port: 443, tls: true, state_dir: nil, on_error: nil,
37
+ supervisor: nil, max_connections: MAX_CONNECTIONS, tlds: nil)
38
+ @store = store
39
+ @port = port
40
+ @tls = tls
41
+ @state_dir = state_dir || Certs.state_dir
42
+ @on_error = on_error || ->(msg) { warn msg }
43
+ @supervisor = supervisor
44
+ @max_connections = max_connections
45
+ @tlds = Array(tlds).flatten.compact.map(&:downcase)
46
+ @tlds = [Hostname::DEFAULT_TLD] if @tlds.empty?
47
+ @inflight = 0
48
+ @inflight_mutex = Mutex.new
49
+ @route_cache = nil
50
+ @route_cache_mtime = nil
51
+ @route_cache_mutex = Mutex.new
52
+ @running = false
53
+ end
54
+
55
+ attr_reader :port, :supervisor, :tlds
56
+
57
+ def tls?
58
+ @tls
59
+ end
60
+
61
+ def start_foreground
62
+ @running = true
63
+ servers = build_servers
64
+ servers.each { |s| s.listen(@port) }
65
+ @port = servers.first.addr[1]
66
+ Ownership.chown_state_dir(@store.dir)
67
+ trap("INT") { stop(servers) }
68
+ trap("TERM") do
69
+ @supervisor&.shutdown
70
+ stop(servers)
71
+ end
72
+ acceptors = servers.map do |server|
73
+ Thread.new do
74
+ while @running
75
+ begin
76
+ sock = server.accept
77
+ unless admit?
78
+ begin
79
+ sock.write("HTTP/1.1 503 Service Unavailable\r\n" \
80
+ "Content-Length: 0\r\nConnection: close\r\n\r\n")
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ sock.close rescue nil
85
+ next
86
+ end
87
+ Thread.new do
88
+ begin
89
+ handle(sock)
90
+ ensure
91
+ release
92
+ end
93
+ end
94
+ rescue IOError, SystemCallError
95
+ break unless @running
96
+ end
97
+ end
98
+ end
99
+ end
100
+ acceptors.each(&:join)
101
+ end
102
+
103
+ def stop(servers = nil)
104
+ @running = false
105
+ Array(servers).each { |s| s.close rescue nil }
106
+ end
107
+
108
+ # Pure request-routing core, tested without sockets.
109
+ def route(authority, routes)
110
+ host = Hostname.strip_port(authority)
111
+ return nil if host.empty? || host.bytesize > MAX_HOSTNAME_BYTES
112
+
113
+ exact = routes.find { |r| r["hostname"] == host }
114
+ return exact if exact
115
+
116
+ wildcard = routes.find { |r| host.end_with?(".#{r["hostname"]}") }
117
+ wildcard
118
+ end
119
+
120
+ def check_hops(headers)
121
+ hops = headers[HOPS_HEADER].to_i
122
+ hops >= MAX_HOPS ? hops : nil
123
+ end
124
+
125
+ private
126
+
127
+ def admit?
128
+ @inflight_mutex.synchronize do
129
+ return false if @inflight >= @max_connections
130
+
131
+ @inflight += 1
132
+ true
133
+ end
134
+ end
135
+
136
+ def release
137
+ @inflight_mutex.synchronize { @inflight -= 1 if @inflight.positive? }
138
+ end
139
+
140
+ def cached_routes
141
+ @route_cache_mutex.synchronize do
142
+ mtime = routes_mtime
143
+ if @route_cache.nil? || mtime != @route_cache_mtime
144
+ @route_cache = @store.load_routes
145
+ @route_cache_mtime = mtime
146
+ end
147
+ @route_cache
148
+ end
149
+ end
150
+
151
+ def routes_mtime
152
+ File.mtime(@store.routes_path)
153
+ rescue SystemCallError
154
+ nil
155
+ end
156
+
157
+ # Both IPv4 and IPv6 loopback: *.localhost often resolves to ::1
158
+ # first, and binding v4-only then refuses with no helpful message.
159
+ def build_servers
160
+ [TCPServer.new("127.0.0.1", @port), TCPServer.new("::1", @port)].map do |server|
161
+ next server unless @tls
162
+
163
+ OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))
164
+ end
165
+ rescue SystemCallError
166
+ server = TCPServer.new("127.0.0.1", @port)
167
+ @tls ? [OpenSSL::SSL::SSLServer.new(server, Certs.server_context(@state_dir))] : [server]
168
+ end
169
+
170
+ # One connection = many requests (keep-alive). Each request head is
171
+ # re-parsed and rewritten; bodies are framed by Content-Length;
172
+ # responses with Content-Length allow the loop to continue.
173
+ def handle(sock)
174
+ buf = +""
175
+ loop do
176
+ head, buf = read_head(sock, buf)
177
+ break if head.nil? || head.empty?
178
+
179
+ method, target, headers = parse_head(head)
180
+ host = headers["host"].to_s
181
+
182
+ if upgrade_request?(headers)
183
+ handle_upgrade(sock, head, buf)
184
+ break
185
+ end
186
+
187
+ routes = cached_routes
188
+ entry = route(host, routes)
189
+ if entry.nil?
190
+ render_not_found(sock, host, routes)
191
+ break
192
+ end
193
+
194
+ if check_hops(headers)
195
+ render_loop(sock, Hostname.strip_port(host))
196
+ break
197
+ end
198
+
199
+ # Supervised managed apps may be stopped (idle/crashed/restarted):
200
+ # boot on request, then serve.
201
+ if @supervisor && entry["kind"] == "socket" && entry["spec"]
202
+ entry = @supervisor.ensure_running(entry)
203
+ unless entry
204
+ render_bad_gateway(sock)
205
+ break
206
+ end
207
+ end
208
+ @supervisor&.touch(entry["hostname"])
209
+
210
+ headers[HOPS_HEADER] = (headers[HOPS_HEADER].to_i + 1).to_s
211
+ set_forwarded(headers, sock, tls: @tls)
212
+
213
+ begin
214
+ backend = dial(entry)
215
+ rescue SystemCallError => e
216
+ @on_error.call("dial failed for #{host}: #{e.message}")
217
+ render_bad_gateway(sock)
218
+ break
219
+ end
220
+
221
+ keep = pipe_request(sock, backend, method, target, headers, buf)
222
+ unless keep == :keep_alive
223
+ backend.close rescue nil
224
+ break
225
+ end
226
+ backend.close rescue nil
227
+ end
228
+ rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
229
+ @on_error.call("Proxy error: #{e.message}")
230
+ render_bad_gateway(sock) rescue nil
231
+ ensure
232
+ sock.close rescue nil
233
+ end
234
+
235
+ # Forward one request (body framed by Content-Length), then relay
236
+ # the response. Returns :keep_alive when both sides want to reuse
237
+ # the connection and the response length was known.
238
+ def pipe_request(sock, backend, method, target, headers, buf)
239
+ backend.write(rebuild_head(method, target, headers))
240
+
241
+ body_len = request_body_length(headers)
242
+ if body_len == :chunked
243
+ # Unclassifiable request body: stream to EOF, close after.
244
+ backend.write(buf) unless buf.empty?
245
+ IO.copy_stream(sock, backend)
246
+ relay_response_close(backend, sock)
247
+ return :close
248
+ end
249
+
250
+ remaining = body_len
251
+ unless buf.empty?
252
+ from_buf = buf.byteslice(0, remaining)
253
+ backend.write(from_buf)
254
+ remaining -= from_buf.bytesize
255
+ buf = buf.byteslice(from_buf.bytesize..) || +""
256
+ end
257
+ if remaining > 0
258
+ IO.copy_stream(sock, backend, remaining)
259
+ end
260
+
261
+ rhead, rbuf = read_head(backend, +"")
262
+ if rhead.nil?
263
+ render_bad_gateway(sock)
264
+ return :close
265
+ end
266
+ _rm, _rt, rheaders = parse_head(rhead)
267
+ sock.write(rhead)
268
+ sock.write(rbuf) unless rbuf.empty?
269
+
270
+ rlen = content_length(rheaders)
271
+ if rlen.nil?
272
+ # No Content-Length: close-delimited response.
273
+ IO.copy_stream(backend, sock)
274
+ return :close
275
+ end
276
+ remaining = rlen - rbuf.bytesize
277
+ if remaining > 0
278
+ IO.copy_stream(backend, sock, remaining)
279
+ end
280
+
281
+ if keep_alive?(headers) && keep_alive?(rheaders)
282
+ # Any bytes beyond Content-Length on the backend are a second
283
+ # pipelined response on a connection we won't reuse — drop.
284
+ :keep_alive
285
+ else
286
+ :close
287
+ end
288
+ end
289
+
290
+ def relay_response_close(backend, sock)
291
+ rhead, rbuf = read_head(backend, +"")
292
+ return if rhead.nil?
293
+
294
+ sock.write(rhead)
295
+ sock.write(rbuf) unless rbuf.empty?
296
+ IO.copy_stream(backend, sock)
297
+ rescue IOError, SystemCallError
298
+ nil
299
+ end
300
+
301
+ def handle_upgrade(sock, head, buf)
302
+ routes = cached_routes
303
+ _method, _target, headers = parse_head(head)
304
+ entry = route(headers["host"].to_s, routes)
305
+ return unless entry
306
+
307
+ backend = dial(entry)
308
+ backend.write(head)
309
+ backend.write(buf) unless buf.empty?
310
+ pipe_both(sock, backend)
311
+ rescue SystemCallError, OpenSSL::SSL::SSLError, IOError => e
312
+ @on_error.call("upgrade proxy error: #{e.message}")
313
+ ensure
314
+ backend&.close rescue nil
315
+ sock.close rescue nil
316
+ end
317
+
318
+ def upgrade_request?(headers)
319
+ headers["upgrade"] && headers["connection"].to_s.downcase.include?("upgrade")
320
+ end
321
+
322
+ def request_body_length(headers)
323
+ if headers["content-length"]
324
+ Integer(headers["content-length"])
325
+ elsif headers["transfer-encoding"].to_s.downcase.include?("chunked")
326
+ :chunked
327
+ else
328
+ 0
329
+ end
330
+ rescue ArgumentError
331
+ :chunked
332
+ end
333
+
334
+ def content_length(headers)
335
+ headers["content-length"] && Integer(headers["content-length"])
336
+ rescue ArgumentError
337
+ nil
338
+ end
339
+
340
+ def keep_alive?(headers)
341
+ return false if headers["connection"].to_s.downcase.include?("close")
342
+
343
+ true
344
+ end
345
+
346
+ # Read just the header block with readpartial (no stdio buffering,
347
+ # so nothing is stolen from the body stream). `buf` carries bytes
348
+ # read ahead (pipelined requests) across calls. Returns [head, buf].
349
+ def read_head(sock, buf)
350
+ loop do
351
+ if (idx = buf.index("\r\n\r\n"))
352
+ return [buf.byteslice(0, idx + 4), buf.byteslice(idx + 4..) || +""]
353
+ end
354
+ return [nil, buf] if buf.bytesize > MAX_HEAD_BYTES
355
+
356
+ chunk = sock.readpartial(16_384)
357
+ buf << chunk
358
+ end
359
+ rescue EOFError, IOError
360
+ [nil, +""]
361
+ end
362
+
363
+ def parse_head(head)
364
+ lines = head.split("\r\n")
365
+ method, target, _version = lines.first.to_s.split(" ", 3)
366
+ headers = {}
367
+ lines[1..].each do |line|
368
+ break if line.empty?
369
+
370
+ key, value = line.split(":", 2)
371
+ headers[key.strip.downcase] = value.strip if key && value
372
+ end
373
+ [method, target, headers]
374
+ end
375
+
376
+ # X-Forwarded-* trust boundary: the proxy only ever listens on
377
+ # loopback, so a client-supplied X-Forwarded-For is the local
378
+ # developer's own header, not an attacker-controlled spoof. Never
379
+ # bind this proxy to a non-loopback interface without rethinking
380
+ # this (and Host-based routing) first.
381
+ def set_forwarded(headers, sock, tls:)
382
+ addr = begin
383
+ sock.peeraddr[3]
384
+ rescue StandardError
385
+ "127.0.0.1"
386
+ end
387
+ headers["x-forwarded-for"] = [headers["x-forwarded-for"], addr].compact.join(", ")
388
+ headers["x-forwarded-proto"] = tls ? "https" : "http"
389
+ headers["x-forwarded-host"] ||= headers["host"].to_s
390
+ end
391
+
392
+ def dial(entry)
393
+ case entry["kind"]
394
+ when "socket"
395
+ UNIXSocket.new(entry["target"])
396
+ else
397
+ host, port = entry["target"].split(":", 2)
398
+ TCPSocket.new(host, port.to_i)
399
+ end
400
+ end
401
+
402
+ def rebuild_head(method, target, headers)
403
+ lines = ["#{method} #{target} HTTP/1.1"]
404
+ headers.each { |k, v| lines << "#{k}: #{v}" }
405
+ (lines.join("\r\n") + "\r\n\r\n")
406
+ end
407
+
408
+ # Raw bidirectional pump for upgrades; either side closing unblocks
409
+ # the other.
410
+ def pipe_both(client, backend)
411
+ t1 = Thread.new do
412
+ IO.copy_stream(client, backend)
413
+ rescue IOError, SystemCallError
414
+ nil
415
+ ensure
416
+ backend.close rescue nil
417
+ end
418
+ t2 = Thread.new do
419
+ IO.copy_stream(backend, client)
420
+ rescue IOError, SystemCallError
421
+ nil
422
+ ensure
423
+ client.close rescue nil
424
+ end
425
+ t1.join
426
+ t2.join
427
+ end
428
+
429
+ # DNS-rebinding boundary: the proxy binds loopback, so any website
430
+ # that tricks a browser into requesting 127.0.0.1 reaches us. A
431
+ # foreign Host (outside our TLDs) gets a bare 404 that names nothing —
432
+ # never the route list. Only requests under our own TLDs see the
433
+ # helpful "no app registered, here are your apps" page, where the
434
+ # requester is unambiguously the local developer.
435
+ def friendly_host?(host)
436
+ @tlds.any? { |tld| host == tld || host.end_with?(".#{tld}") }
437
+ end
438
+
439
+ def render_not_found(sock, host, routes)
440
+ bare = Hostname.strip_port(host)
441
+ unless friendly_host?(bare.downcase)
442
+ return respond(sock, 404, "<h1>Not Found</h1>")
443
+ end
444
+
445
+ items = routes.map { |r| "<li>#{escape(r["hostname"])}</li>" }.join
446
+ body = "<h1>No app registered for #{escape(bare)}</h1>" \
447
+ "<ul>#{items}</ul>"
448
+ respond(sock, 404, body)
449
+ end
450
+
451
+ def render_bad_gateway(sock)
452
+ respond(sock, 502, "<h1>Bad Gateway</h1><p>The target app is not responding.</p>")
453
+ rescue IOError, SystemCallError
454
+ nil
455
+ end
456
+
457
+ def render_loop(sock, host)
458
+ respond(sock, 508, "<h1>Loop Detected</h1><p>#{escape(host)} passed through " \
459
+ "ask-local too many times. Check dev-server proxy config.</p>")
460
+ rescue IOError, SystemCallError
461
+ nil
462
+ end
463
+
464
+ def respond(sock, status, body)
465
+ message = { 404 => "Not Found", 502 => "Bad Gateway", 508 => "Loop Detected" }[status]
466
+ sock.write("HTTP/1.1 #{status} #{message}\r\n" \
467
+ "Content-Type: text/html\r\n" \
468
+ "#{HEALTH_HEADER}: 1\r\n" \
469
+ "Content-Length: #{body.bytesize}\r\n" \
470
+ "Connection: close\r\n\r\n#{body}")
471
+ rescue IOError, SystemCallError
472
+ nil
473
+ end
474
+
475
+ def escape(text)
476
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
477
+ end
478
+ end
479
+ end
480
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "openssl"
5
+ require "socket"
6
+ require "timeout"
7
+
8
+ module Ask
9
+ module Local
10
+ # Proxy daemon lifecycle: pid/port/tls files, liveness verification,
11
+ # spawn (with sudo for privileged ports), stop.
12
+ module ProxyControl
13
+ DEFAULT_TLS_PORT = 443
14
+ DEFAULT_PLAIN_PORT = 80
15
+ LOG_NAME = "proxy.log"
16
+
17
+ module_function
18
+
19
+ # Path to the ask-local executable relative to this file
20
+ # (lib/ask/local -> gem root/bin). Used for daemon spawn and
21
+ # service install; both must resolve identically in dev checkouts
22
+ # and installed gems.
23
+ def bin_path
24
+ File.expand_path("../../../bin/ask-local", __dir__)
25
+ end
26
+
27
+ def root?
28
+ Process.uid.zero?
29
+ end
30
+
31
+ def default_port(tls)
32
+ env = ENV["ASK_LOCAL_PORT"]
33
+ return env.to_i if env && env.to_i.between?(1, 65_535)
34
+
35
+ tls ? DEFAULT_TLS_PORT : DEFAULT_PLAIN_PORT
36
+ end
37
+
38
+ def proxy_tls(store)
39
+ marker = File.join(store.dir, "proxy.tls")
40
+ return false if ENV["ASK_LOCAL_HTTPS"] == "0"
41
+ return true if ENV["ASK_LOCAL_HTTPS"] == "1"
42
+ return false if File.file?(marker) && File.read(marker).strip == "0"
43
+
44
+ true
45
+ rescue SystemCallError
46
+ true
47
+ end
48
+
49
+ # Anything TCP-listening on the port (either loopback)?
50
+ def listening?(port)
51
+ ["127.0.0.1", "::1"].any? do |host|
52
+ begin
53
+ TCPSocket.new(host, port).close
54
+ true
55
+ rescue SystemCallError
56
+ false
57
+ end
58
+ end
59
+ end
60
+
61
+ # Is the thing on this port OUR proxy? Health requests hit an
62
+ # unregistered host; our proxy answers 404 with X-Ask-Local: 1.
63
+ # Probes both loopbacks: the proxy binds v4+IPv6, and on machines
64
+ # where only one family answers the check must still succeed.
65
+ # An explicit regression test pins this (health_test pinning
66
+ # ensure_proxy's "is that ours" logic against future proxy changes).
67
+ def ours?(port, tls:)
68
+ probe_ours(port, tls: tls, host: "127.0.0.1") ||
69
+ probe_ours(port, tls: tls, host: "::1")
70
+ end
71
+
72
+ def probe_ours(port, tls:, host:)
73
+ sock = TCPSocket.new(host, port)
74
+ if tls
75
+ ctx = OpenSSL::SSL::SSLContext.new
76
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
77
+ sock = OpenSSL::SSL::SSLSocket.new(sock, ctx)
78
+ sock.connect
79
+ end
80
+ Timeout.timeout(3) do
81
+ sock.write("GET / HTTP/1.1\r\nHost: ask-local-health.invalid\r\nConnection: close\r\n\r\n")
82
+ head = +""
83
+ while (chunk = sock.readpartial(4096))
84
+ head << chunk
85
+ break if head.include?("\r\n\r\n")
86
+ end
87
+ head.downcase.include?("x-ask-local: 1")
88
+ end
89
+ rescue SystemCallError, OpenSSL::SSL::SSLError, Timeout::Error, IOError, EOFError
90
+ false
91
+ ensure
92
+ begin
93
+ sock&.close
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ end
98
+
99
+ def pid_alive?(pid)
100
+ Process.kill(0, pid)
101
+ true
102
+ rescue SystemCallError
103
+ false
104
+ end
105
+
106
+ def read_pid(store)
107
+ return nil unless File.file?(store.pid_path)
108
+
109
+ pid = File.read(store.pid_path).strip.to_i
110
+ pid.positive? ? pid : nil
111
+ rescue SystemCallError, ArgumentError
112
+ nil
113
+ end
114
+
115
+ def write_pid(store, pid, port, tls)
116
+ store.ensure_dir
117
+ File.write(store.pid_path, "#{pid}\n")
118
+ File.write(store.port_path, "#{port}\n")
119
+ File.write(File.join(store.dir, "proxy.tls"), tls ? "1" : "0")
120
+ Ownership.fix(store.pid_path, store.port_path, File.join(store.dir, "proxy.tls"))
121
+ end
122
+
123
+ def clear_pid(store)
124
+ FileUtils.rm_f(store.pid_path)
125
+ FileUtils.rm_f(store.port_path)
126
+ end
127
+
128
+ def proxy_port(store)
129
+ return nil unless File.file?(store.port_path)
130
+
131
+ port = File.read(store.port_path).strip.to_i
132
+ port.positive? ? port : nil
133
+ rescue SystemCallError, ArgumentError
134
+ nil
135
+ end
136
+
137
+ # Start the proxy as a detached daemon. Sudo is used for privileged
138
+ # ports (portless auto-elevate pattern); the state dir is passed
139
+ # explicitly because sudo does not preserve the environment.
140
+ # Returns pid.
141
+ def spawn_daemon(store:, port:, tls:, sudo: false, tlds: nil)
142
+ store.ensure_dir
143
+ log_path = File.join(store.dir, LOG_NAME)
144
+ Log.rotate(log_path)
145
+ args = [RbConfig.ruby, bin_path,
146
+ "proxy", "start", "--foreground", "--port", port.to_s]
147
+ args << "--no-tls" unless tls
148
+ Array(tlds).each { |t| args.concat(["--tld", t]) }
149
+ state_arg = "ASK_LOCAL_STATE_DIR=#{store.dir}"
150
+ cmd = sudo ? ["sudo", "env", state_arg, *args] : [*args]
151
+ pid = spawn({ "ASK_LOCAL_STATE_DIR" => store.dir }, *cmd,
152
+ out: log_path, err: [:child, :out])
153
+ Process.detach(pid)
154
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
155
+ until ours?(port, tls: tls)
156
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
157
+ raise ProxyNotRunningError,
158
+ "Proxy did not start on port #{port}. " \
159
+ "Log (#{log_path}):\n#{log_tail(log_path)}"
160
+ end
161
+
162
+ sleep 0.25
163
+ end
164
+ write_pid(store, pid, port, tls)
165
+ pid
166
+ end
167
+
168
+ def log_tail(path, lines: 15)
169
+ return "(no log yet)" unless File.file?(path)
170
+
171
+ File.readlines(path).last(lines).join
172
+ rescue SystemCallError
173
+ "(unreadable log)"
174
+ end
175
+
176
+ def stop(store)
177
+ pid = read_pid(store)
178
+ port = proxy_port(store)
179
+ if pid.nil?
180
+ return :not_running unless port && listening?(port)
181
+
182
+ return :unknown_process
183
+ end
184
+ unless pid_alive?(pid)
185
+ clear_pid(store)
186
+ return :stale
187
+ end
188
+ begin
189
+ Process.kill("TERM", pid)
190
+ rescue SystemCallError
191
+ clear_pid(store)
192
+ return :stale
193
+ end
194
+ clear_pid(store)
195
+ :stopped
196
+ end
197
+ end
198
+ end
199
+ end