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