railwatch-server 0.1.0-x86_64-linux
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/LICENSE +661 -0
- data/NOTICE +24 -0
- data/README.md +368 -0
- data/app/controllers/railwatch/server/proxy_controller.rb +323 -0
- data/exe/x86_64-linux/railwatch +0 -0
- data/lib/puma/plugin/railwatch_server.rb +110 -0
- data/lib/railwatch/server/binary.rb +46 -0
- data/lib/railwatch/server/configuration.rb +128 -0
- data/lib/railwatch/server/data_dir.rb +155 -0
- data/lib/railwatch/server/engine.rb +58 -0
- data/lib/railwatch/server/process.rb +372 -0
- data/lib/railwatch/server/proxy_auth.rb +33 -0
- data/lib/railwatch/server/proxy_handoff.rb +55 -0
- data/lib/railwatch/server/relay.rb +286 -0
- data/lib/railwatch/server/settings.rb +175 -0
- data/lib/railwatch/server/version.rb +10 -0
- data/lib/railwatch/server.rb +157 -0
- data/lib/tasks/railwatch_server.rake +33 -0
- metadata +100 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Railwatch
|
|
7
|
+
module Server
|
|
8
|
+
# /railwatch/* in the app, forwarded to the bundled server on loopback.
|
|
9
|
+
#
|
|
10
|
+
# The gate is the railwatch gem's, so one setting covers the dashboard
|
|
11
|
+
# whichever gem serves it: HTTP Basic, on and closed by default
|
|
12
|
+
# (Railwatch.config.http_basic_auth_*), or the app's own controller
|
|
13
|
+
# (Railwatch.config.base_controller_class, with Basic off), whose
|
|
14
|
+
# before_actions run first. Past the gate, each request carries a signed,
|
|
15
|
+
# 60-second X-Railwatch-Proxy-Auth naming the visitor, which the server
|
|
16
|
+
# trusts (railwatch-rs trusted proxy auth); nobody signs in twice.
|
|
17
|
+
#
|
|
18
|
+
# The server is served under the same path (RAILWATCH_BASE_PATH), so
|
|
19
|
+
# paths, redirects, cookies, asset URLs and the live channel pass through
|
|
20
|
+
# unrewritten. CSRF is the server's: it checks Origin and its own token
|
|
21
|
+
# on every write, so Rails' check is skipped here.
|
|
22
|
+
#
|
|
23
|
+
# No Puma thread waits for the server. Past the gate, the request (which
|
|
24
|
+
# Puma has read in full) is left to ProxyHandoff, which takes the socket
|
|
25
|
+
# from Puma and gives it to the process's one Relay thread; the Relay
|
|
26
|
+
# writes the request to the server and the response back, then closes
|
|
27
|
+
# the connection (Connection: close: the browser, or the proxy in front
|
|
28
|
+
# of the app, opens a new one for the next request). Where the socket
|
|
29
|
+
# can't be taken (a server without Rack hijack, Puma's own TLS) the
|
|
30
|
+
# response is streamed from this thread instead (#stream).
|
|
31
|
+
class ProxyController < Railwatch.config.base_controller_class.constantize
|
|
32
|
+
# One upstream request whose response body is read as Puma writes it.
|
|
33
|
+
# Net::HTTP reads a body only inside the block of #request; a Fiber
|
|
34
|
+
# runs that block, handing back the response first and then each
|
|
35
|
+
# chunk, so headers can be returned before the body is read. Puma
|
|
36
|
+
# iterates the body on the thread that called the controller, which
|
|
37
|
+
# is the thread the Fiber was made on.
|
|
38
|
+
class Upstream
|
|
39
|
+
def initialize(request, headers, port:)
|
|
40
|
+
@http = Net::HTTP.new("127.0.0.1", port)
|
|
41
|
+
@http.open_timeout = Railwatch::Server.config.proxy_connect_timeout
|
|
42
|
+
@http.read_timeout = Railwatch::Server.config.proxy_first_byte_timeout
|
|
43
|
+
@http.max_retries = 0
|
|
44
|
+
# Byte-transparent: the browser's Accept-Encoding, or identity. A
|
|
45
|
+
# request built without one gets Net::HTTP's gzip default and its
|
|
46
|
+
# body decoded under the server's Content-Length.
|
|
47
|
+
headers = headers.merge("Accept-Encoding" => request.headers["Accept-Encoding"].presence || "identity")
|
|
48
|
+
@upstream = Net::HTTPGenericRequest.new(request.request_method, !request.content_length.to_i.zero? || chunked_input?(request),
|
|
49
|
+
!request.head?, request.fullpath, headers)
|
|
50
|
+
return unless @upstream.request_body_permitted?
|
|
51
|
+
|
|
52
|
+
input = request.body
|
|
53
|
+
input.rewind if input.respond_to?(:rewind)
|
|
54
|
+
@upstream.body_stream = input
|
|
55
|
+
if request.content_length
|
|
56
|
+
@upstream["Content-Length"] = request.content_length.to_s
|
|
57
|
+
else
|
|
58
|
+
@upstream["Transfer-Encoding"] = "chunked"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def start
|
|
63
|
+
@fiber = Fiber.new do
|
|
64
|
+
@http.start
|
|
65
|
+
@http.request(@upstream) do |response|
|
|
66
|
+
Fiber.yield response
|
|
67
|
+
response.read_body { |chunk| Fiber.yield chunk }
|
|
68
|
+
end
|
|
69
|
+
nil
|
|
70
|
+
ensure
|
|
71
|
+
@http.finish if @http.started?
|
|
72
|
+
end
|
|
73
|
+
@fiber.resume
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def each
|
|
77
|
+
while (chunk = @fiber.resume)
|
|
78
|
+
yield chunk
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Called by Rack when Puma is done with the body, read to the end or
|
|
83
|
+
# not (the browser went away): the connection is closed either way.
|
|
84
|
+
def close
|
|
85
|
+
@http.finish if @http.started?
|
|
86
|
+
rescue IOError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def chunked_input?(request)
|
|
93
|
+
request.get_header("HTTP_TRANSFER_ENCODING").to_s.include?("chunked")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The server's cookies only; the app's own (its session) stay here.
|
|
98
|
+
FORWARDED_COOKIES = %w[_railwatch_session session_token account_id XSRF-TOKEN].freeze
|
|
99
|
+
FORWARDED_HEADERS = %w[
|
|
100
|
+
Accept Accept-Encoding Accept-Language Content-Type Origin Referer User-Agent
|
|
101
|
+
X-Inertia X-Inertia-Version X-Inertia-Partial-Component X-Inertia-Partial-Data X-Inertia-Partial-Except
|
|
102
|
+
X-Inertia-Reset X-Inertia-Error-Bag X-Inertia-Except-Once-Props X-Requested-With X-XSRF-TOKEN X-CSRF-Token
|
|
103
|
+
Purpose Sec-Purpose Sec-Fetch-Site Sec-Fetch-Mode Sec-Fetch-Dest If-None-Match If-Modified-Since
|
|
104
|
+
].freeze
|
|
105
|
+
HOP_BY_HOP = %w[connection keep-alive transfer-encoding content-length te trailer upgrade proxy-connection].freeze
|
|
106
|
+
|
|
107
|
+
skip_forgery_protection if respond_to?(:skip_forgery_protection)
|
|
108
|
+
before_action :authenticate_by_http_basic
|
|
109
|
+
|
|
110
|
+
# An app on inertia_rails has its controller module in every
|
|
111
|
+
# ActionController::Base, this one included, and two of its behaviours
|
|
112
|
+
# break the dashboard, whose Inertia protocol is the server's end to
|
|
113
|
+
# end. Its middleware compares the dashboard's X-Inertia-Version with
|
|
114
|
+
# the app's and answers 409 to every Inertia visit, so each click was
|
|
115
|
+
# a full page load. Once this controller has answered (forwarded, or
|
|
116
|
+
# refused at the gate), the request's X-Inertia is removed, so the
|
|
117
|
+
# middleware, on its way out, sees no Inertia visit (every
|
|
118
|
+
# inertia_rails version checks that header first). Its after_action
|
|
119
|
+
# wrote the app's own XSRF-TOKEN (Path=/) on every response, so the
|
|
120
|
+
# browser held two and sent the app's to the server, which refused
|
|
121
|
+
# every write; forgery protection is off here (the server checks its
|
|
122
|
+
# own token), and that after_action skips a controller without it.
|
|
123
|
+
def process_action(...)
|
|
124
|
+
super
|
|
125
|
+
ensure
|
|
126
|
+
request.delete_header("HTTP_X_INERTIA")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def protect_against_forgery? = false
|
|
130
|
+
|
|
131
|
+
def forward
|
|
132
|
+
if (error = Railwatch::Server.provision_error) && !Railwatch::Server.data_dir.provisioned?
|
|
133
|
+
return unavailable("not provisioned: #{error.message}")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
secret = Railwatch::Server.data_dir.proxy_secret
|
|
137
|
+
return unavailable("not provisioned yet (no #{Railwatch::Server.data_dir.proxy_secret_file})") unless secret
|
|
138
|
+
|
|
139
|
+
if websocket?
|
|
140
|
+
tunnel(secret)
|
|
141
|
+
elsif hijackable?
|
|
142
|
+
hand_off(secret)
|
|
143
|
+
else
|
|
144
|
+
stream(secret)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def authenticate_by_http_basic
|
|
151
|
+
config = Railwatch.config
|
|
152
|
+
return unless config.http_basic_auth_enabled
|
|
153
|
+
return if config.http_basic_auth_ok?(request)
|
|
154
|
+
|
|
155
|
+
request_http_basic_authentication("Railwatch")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Who the server should see: Railwatch::Server.config.user when the app
|
|
159
|
+
# names its user, else the HTTP Basic user, else one operator.
|
|
160
|
+
def visitor
|
|
161
|
+
given = Railwatch::Server.config.user&.call(self)
|
|
162
|
+
return given.to_h.transform_keys(&:to_sym).reverse_merge(role: "admin") if given
|
|
163
|
+
|
|
164
|
+
basic_user = ActionController::HttpAuthentication::Basic.user_name_and_password(request).first if request.authorization.to_s.start_with?("Basic ")
|
|
165
|
+
# Decoded base64 comes back binary; parameterize needs a real encoding.
|
|
166
|
+
name = basic_user.to_s.dup.force_encoding(Encoding::UTF_8).scrub.presence || "Operator"
|
|
167
|
+
{ email: "#{name.parameterize.presence || 'operator'}@railwatch.invalid", name: name, role: "admin" }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def signed_header(secret)
|
|
171
|
+
v = visitor
|
|
172
|
+
ProxyAuth.sign(secret, email: v[:email], name: v[:name], role: v[:role])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def upstream_headers(secret)
|
|
176
|
+
headers = FORWARDED_HEADERS.each_with_object({}) do |name, h|
|
|
177
|
+
value = request.headers[name]
|
|
178
|
+
h[name] = value if value
|
|
179
|
+
end
|
|
180
|
+
cookies = request.cookies.slice(*FORWARDED_COOKIES)
|
|
181
|
+
headers["Cookie"] = cookies.map { |k, v| "#{k}=#{v}" }.join("; ") if cookies.any?
|
|
182
|
+
# The server compares Origin with Host, and builds absolute URLs (an
|
|
183
|
+
# Inertia version-mismatch reload) from Host and X-Forwarded-Proto.
|
|
184
|
+
headers["Host"] = request.host_with_port
|
|
185
|
+
headers["X-Forwarded-Proto"] = request.scheme
|
|
186
|
+
# The visitor, as the app sees it (RemoteIp: the proxies in front of
|
|
187
|
+
# the app are trusted, the browser's own X-Forwarded-For is not),
|
|
188
|
+
# and only that: the server (RAILWATCH_TRUST_PROXY) keys its per-IP
|
|
189
|
+
# limits on the last address here.
|
|
190
|
+
headers["X-Forwarded-For"] = request.remote_ip
|
|
191
|
+
headers[ProxyAuth::HEADER] = signed_header(secret)
|
|
192
|
+
headers
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The browser's socket can be taken from the app server and moved with
|
|
196
|
+
# IO.select: Rack hijack, and a plain socket (not Puma's own TLS, whose
|
|
197
|
+
# socket wrapper keeps decrypted bytes the relay could not see).
|
|
198
|
+
def hijackable?
|
|
199
|
+
return false unless request.env["rack.hijack"]
|
|
200
|
+
|
|
201
|
+
socket = request.env["puma.socket"]
|
|
202
|
+
socket.nil? || socket.is_a?(::BasicSocket)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Connects to the server and leaves the request to ProxyHandoff; the
|
|
206
|
+
# empty 200 here is never sent. Past max_proxied_requests the visitor
|
|
207
|
+
# gets a 503 at once.
|
|
208
|
+
def hand_off(secret)
|
|
209
|
+
relay = Relay.current
|
|
210
|
+
if relay.full?(:proxied)
|
|
211
|
+
Rails.logger.warn("[railwatch-server] #{relay.max(:proxied)} dashboard requests in flight (max_proxied_requests); refusing another")
|
|
212
|
+
response.headers["Retry-After"] = "1"
|
|
213
|
+
return render(plain: "The dashboard is busy (#{relay.max(:proxied)} requests in flight in this process). Try again in a moment.",
|
|
214
|
+
status: :service_unavailable)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
upstream = connect
|
|
218
|
+
request_head, body = upstream_request(secret)
|
|
219
|
+
request.env[ProxyHandoff::ENV_KEY] = ProxyHandoff::Handoff.new(upstream, request_head, body)
|
|
220
|
+
head :ok
|
|
221
|
+
rescue SystemCallError, IOError => e
|
|
222
|
+
upstream&.close rescue nil
|
|
223
|
+
unavailable("#{e.class}: #{e.message}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def connect
|
|
227
|
+
TCPSocket.new("127.0.0.1", Railwatch::Server.config.port, connect_timeout: Railwatch::Server.config.proxy_connect_timeout)
|
|
228
|
+
rescue IO::TimeoutError => e
|
|
229
|
+
raise Errno::ETIMEDOUT, e.message
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The request as the server gets it: its head (and its body, when Puma
|
|
233
|
+
# kept that in memory), and the body Puma spooled to a tempfile, as a
|
|
234
|
+
# second descriptor of it (Puma closes its own when this thread
|
|
235
|
+
# returns). Puma has read the body in full and decoded a chunked one,
|
|
236
|
+
# so it always has a length.
|
|
237
|
+
def upstream_request(secret)
|
|
238
|
+
input = request.body
|
|
239
|
+
input.rewind if input.respond_to?(:rewind)
|
|
240
|
+
length = request.content_length.to_i
|
|
241
|
+
body = input.is_a?(::File) ? input.dup : nil
|
|
242
|
+
# The browser's HTTP version: an HTTP/1.0 client (nginx's default
|
|
243
|
+
# upstream protocol) can't read a chunked response, and the server
|
|
244
|
+
# doesn't send one to an HTTP/1.0 request.
|
|
245
|
+
version = request.get_header("SERVER_PROTOCOL") == "HTTP/1.0" ? "HTTP/1.0" : "HTTP/1.1"
|
|
246
|
+
headers = upstream_headers(secret)
|
|
247
|
+
headers["Accept-Encoding"] = request.headers["Accept-Encoding"].presence || "identity"
|
|
248
|
+
headers["Content-Length"] = length.to_s if length.positive? || request.content_length
|
|
249
|
+
headers["Connection"] = "close"
|
|
250
|
+
head = "#{request.request_method} #{request.fullpath} #{version}\r\n" \
|
|
251
|
+
"#{headers.map { |k, v| "#{k}: #{v.to_s.delete("\r\n")}\r\n" }.join}\r\n".b
|
|
252
|
+
head << input.read.to_s.b if body.nil? && length.positive?
|
|
253
|
+
[ head, body ]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Without hijack: the response is streamed from this thread, status and
|
|
257
|
+
# headers as soon as the server sends them, then the body as the app
|
|
258
|
+
# server writes it (Upstream#each), so a large export never sits whole
|
|
259
|
+
# in this process. The request body is streamed too, from rack.input.
|
|
260
|
+
def stream(secret)
|
|
261
|
+
upstream = Upstream.new(request, upstream_headers(secret), port: Railwatch::Server.config.port)
|
|
262
|
+
response = upstream.start
|
|
263
|
+
response.each_header do |name, _|
|
|
264
|
+
next if HOP_BY_HOP.include?(name) || Relay::APP_OWNED.include?(name)
|
|
265
|
+
|
|
266
|
+
values = response.get_fields(name)
|
|
267
|
+
self.response.headers[name] = name == "set-cookie" ? values : values.join(", ")
|
|
268
|
+
end
|
|
269
|
+
# The server's length is the body's: Upstream asks for it unencoded
|
|
270
|
+
# unless the browser accepts an encoding, and never decodes it.
|
|
271
|
+
self.response.headers["content-length"] = response["content-length"] if response["content-length"] && !response.chunked?
|
|
272
|
+
self.status = response.code.to_i
|
|
273
|
+
self.response_body = request.head? || !response.class.body_permitted? ? (upstream.close; []) : upstream
|
|
274
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, EOFError => e
|
|
275
|
+
unavailable("#{e.class}: #{e.message}")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def websocket?
|
|
279
|
+
request.get? && request.headers["Upgrade"].to_s.casecmp?("websocket")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The live channel: the handshake is sent on with the signed header and
|
|
283
|
+
# the connection is spliced to the server's by the process's one relay
|
|
284
|
+
# thread (Relay), at most config.max_live_connections at a time. Needs
|
|
285
|
+
# a server with full Rack hijack (Puma).
|
|
286
|
+
def tunnel(secret)
|
|
287
|
+
hijack = request.env["rack.hijack"]
|
|
288
|
+
return head(:not_implemented) unless hijack
|
|
289
|
+
|
|
290
|
+
relay = Relay.current
|
|
291
|
+
if relay.full?(:live)
|
|
292
|
+
Rails.logger.warn("[railwatch-server] #{relay.max(:live)} live-channel connections open (max_live_connections); refusing another")
|
|
293
|
+
return head(:service_unavailable)
|
|
294
|
+
end
|
|
295
|
+
upstream = connect
|
|
296
|
+
handshake = upstream_headers(secret).merge(
|
|
297
|
+
"Connection" => "Upgrade", "Upgrade" => "websocket",
|
|
298
|
+
"Sec-WebSocket-Key" => request.headers["Sec-WebSocket-Key"],
|
|
299
|
+
"Sec-WebSocket-Version" => request.headers["Sec-WebSocket-Version"],
|
|
300
|
+
"Sec-WebSocket-Protocol" => request.headers["Sec-WebSocket-Protocol"],
|
|
301
|
+
"Sec-WebSocket-Extensions" => request.headers["Sec-WebSocket-Extensions"]
|
|
302
|
+
).compact
|
|
303
|
+
upstream.write("GET #{request.fullpath} HTTP/1.1\r\n#{handshake.map { |k, v| "#{k}: #{v}\r\n" }.join}\r\n")
|
|
304
|
+
client = hijack.call
|
|
305
|
+
unless relay.add(client, upstream)
|
|
306
|
+
[ client, upstream ].each { |io| io.close rescue nil }
|
|
307
|
+
end
|
|
308
|
+
self.status = -1
|
|
309
|
+
self.response_body = []
|
|
310
|
+
rescue SystemCallError => e
|
|
311
|
+
unavailable("#{e.class}: #{e.message}")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def unavailable(why)
|
|
315
|
+
message = "The Railwatch server is not answering on 127.0.0.1:#{Railwatch::Server.config.port} (#{why}). " \
|
|
316
|
+
"It is started by `plugin :railwatch_server` in config/puma.rb or `bin/rails railwatch_server:start`; " \
|
|
317
|
+
"its log is #{Railwatch::Server.data_dir.log_file}."
|
|
318
|
+
Rails.logger.error("[railwatch-server] #{message}")
|
|
319
|
+
render plain: message, status: :service_unavailable
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puma/plugin"
|
|
4
|
+
|
|
5
|
+
# `plugin :railwatch_server` in config/puma.rb: runs the bundled Railwatch
|
|
6
|
+
# server as a child of Puma's master (single or cluster mode; never in a
|
|
7
|
+
# worker), restarts it with backoff when it dies, and stops it after Puma
|
|
8
|
+
# has drained its requests and flushed its reporter.
|
|
9
|
+
#
|
|
10
|
+
# The shape is the railwatch gem's own writer plugin
|
|
11
|
+
# (lib/puma/plugin/railwatch.rb). A bare `puma` fires plugin starts before
|
|
12
|
+
# it loads the Rack app, so there a background supervisor waits for
|
|
13
|
+
# after_booted. Under `bin/rails server` the app is loaded first, and the
|
|
14
|
+
# server is started in `start` itself, before Puma accepts a request: an
|
|
15
|
+
# agent whose first delivery is refused counts it as backpressure and
|
|
16
|
+
# samples fewer requests for the next second or two. Stopping is in at_exit,
|
|
17
|
+
# not after_stopped: Puma fires after_stopped from its TERM trap BEFORE it
|
|
18
|
+
# drains in-flight requests, and those requests' records still have to
|
|
19
|
+
# reach this server.
|
|
20
|
+
Puma::Plugin.create do
|
|
21
|
+
POLL = 1
|
|
22
|
+
|
|
23
|
+
def start(launcher)
|
|
24
|
+
@log_writer = launcher.log_writer
|
|
25
|
+
@puma_pid = $$
|
|
26
|
+
@booted = false
|
|
27
|
+
@shutting_down = false
|
|
28
|
+
|
|
29
|
+
start_server_now if app_loaded?
|
|
30
|
+
in_background { supervise }
|
|
31
|
+
launcher.events.after_booted { @booted = true }
|
|
32
|
+
# A hot restart (USR2) re-execs this process: the server is stopped
|
|
33
|
+
# first and the new master starts its own.
|
|
34
|
+
launcher.events.before_restart { stop_server if Process.pid == @puma_pid }
|
|
35
|
+
launcher.events.after_stopped { @shutting_down = true }
|
|
36
|
+
at_exit { shutdown if Process.pid == @puma_pid }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def active?
|
|
42
|
+
defined?(::Railwatch::Server) && ::Railwatch::Server.enabled?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def app_loaded?
|
|
46
|
+
defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application&.initialized?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def start_server_now
|
|
50
|
+
return unless active?
|
|
51
|
+
|
|
52
|
+
build_server
|
|
53
|
+
@server.ensure_running
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
log "Railwatch server NOT started: #{e.class}: #{e.message}"
|
|
56
|
+
@server = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_server
|
|
60
|
+
::Railwatch::Server.provision!(log: method(:log))
|
|
61
|
+
@server = ::Railwatch::Server::Process.new(
|
|
62
|
+
config: ::Railwatch::Server.config, data_dir: ::Railwatch::Server.data_dir,
|
|
63
|
+
binary: ::Railwatch::Server::Binary.path, log: method(:log)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def supervise
|
|
68
|
+
sleep 0.05 until @server || @booted || @shutting_down
|
|
69
|
+
return unless active?
|
|
70
|
+
|
|
71
|
+
build_server unless @server
|
|
72
|
+
until @shutting_down
|
|
73
|
+
@server.ensure_running
|
|
74
|
+
sleep POLL
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
log "Railwatch server NOT started: #{e.class}: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The app's own reporter first, while the server still listens: the last
|
|
81
|
+
# requests' records (and in single mode, every request's) go out now.
|
|
82
|
+
#
|
|
83
|
+
# Under `bin/rails server` the app loads before Puma starts this plugin,
|
|
84
|
+
# so this at_exit was registered after the railwatch gem's and runs
|
|
85
|
+
# BEFORE them (at_exit is LIFO). Those stop the health sampler and flush
|
|
86
|
+
# the session tracker, which emits records; run them here first, so their
|
|
87
|
+
# records are in the final flush rather than produced after the server is
|
|
88
|
+
# gone. Each stop! is idempotent; the gem's own at_exit then does nothing.
|
|
89
|
+
def shutdown
|
|
90
|
+
@shutting_down = true
|
|
91
|
+
if defined?(::Railwatch) && ::Railwatch.respond_to?(:enabled?) && ::Railwatch.enabled? && !::Railwatch.config.local?
|
|
92
|
+
[ "Health", "Sessions", "Maintenance", "Export::Sender" ].each do |name|
|
|
93
|
+
mod = ::Railwatch.const_get(name) if ::Railwatch.const_defined?(name)
|
|
94
|
+
mod.stop! if mod.respond_to?(:stop!)
|
|
95
|
+
end
|
|
96
|
+
::Railwatch.reporter.shutdown
|
|
97
|
+
end
|
|
98
|
+
stop_server
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
log "Railwatch server shutdown failed (#{e.class}: #{e.message})"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stop_server
|
|
104
|
+
@server&.stop
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def log(message)
|
|
108
|
+
@log_writer.log("[railwatch-server] #{message}")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Server
|
|
5
|
+
# The bundled `railwatch` executable, the way tailwindcss-ruby finds its
|
|
6
|
+
# own: one directory per platform under exe/, and the platform gem for
|
|
7
|
+
# this machine carries exactly one of them. The plain "ruby" gem has
|
|
8
|
+
# none and says so.
|
|
9
|
+
module Binary
|
|
10
|
+
PLATFORMS = %w[x86_64-linux aarch64-linux x86_64-darwin arm64-darwin].freeze
|
|
11
|
+
DIR = File.expand_path("../../../exe", __dir__)
|
|
12
|
+
GEM_NAME = "railwatch-server"
|
|
13
|
+
|
|
14
|
+
class NotFound < StandardError; end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def path
|
|
19
|
+
own = Railwatch::Server.config.binary
|
|
20
|
+
if own
|
|
21
|
+
raise NotFound, "RAILWATCH_SERVER_BINARY=#{own} is not an executable file" unless File.executable?(own)
|
|
22
|
+
|
|
23
|
+
return own
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
found = Dir.glob(File.join(DIR, "*", "railwatch")).find do |file|
|
|
27
|
+
Gem::Platform.match_gem?(Gem::Platform.new(File.basename(File.dirname(file))), GEM_NAME)
|
|
28
|
+
end
|
|
29
|
+
return found if found && File.executable?(found)
|
|
30
|
+
|
|
31
|
+
raise NotFound, <<~MESSAGE.strip
|
|
32
|
+
railwatch-server has no Railwatch server binary for #{local_platform}.
|
|
33
|
+
Prebuilt binaries ship in the #{PLATFORMS.join(', ')} gems; this is the plain one.
|
|
34
|
+
Either let Bundler pick the platform gem (`bundle lock --add-platform #{local_platform}`,
|
|
35
|
+
and check `bundle config force_ruby_platform` is not true), or install the server
|
|
36
|
+
yourself (https://github.com/Rebulk/railwatch-rs, docs/SELF-HOST.md) and set
|
|
37
|
+
RAILWATCH_SERVER_BINARY=/path/to/railwatch.
|
|
38
|
+
MESSAGE
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def local_platform
|
|
42
|
+
[ Gem::Platform.local.cpu, Gem::Platform.local.os ].join("-")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Server
|
|
5
|
+
# Everything is an env var first (RAILWATCH_SERVER_*), then assignment in
|
|
6
|
+
# an initializer:
|
|
7
|
+
#
|
|
8
|
+
# Railwatch::Server.configure do |c|
|
|
9
|
+
# c.port = 28790
|
|
10
|
+
# c.user = ->(controller) { { email: controller.current_user.email, name: controller.current_user.name, role: "admin" } }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The dashboard's gate is the railwatch gem's (Railwatch.config): HTTP
|
|
14
|
+
# Basic on and closed by default, or `base_controller_class` with Basic
|
|
15
|
+
# off. One set of credentials for /railwatch whichever gem serves it.
|
|
16
|
+
class Configuration
|
|
17
|
+
attr_accessor :enabled, :port, :data_dir, :mount_path, :binary, :url, :stop_timeout, :setup_timeout, :user, :wire_agent,
|
|
18
|
+
:max_live_connections, :max_proxied_requests, :proxy_connect_timeout, :proxy_first_byte_timeout,
|
|
19
|
+
:proxy_idle_timeout, :nice, :smtp, :mail_from, :backup, :google, :retention, :alert_delivery, :server_env
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
# nil: on, except under Rails.env.test (a test run on a machine with
|
|
23
|
+
# a development server running would report into it). See #enabled.
|
|
24
|
+
@enabled = env_bool("RAILWATCH_SERVER_ENABLED", nil)
|
|
25
|
+
# One fixed port per app, not a free one picked at boot: every process
|
|
26
|
+
# of the app (Puma workers, Solid Queue, a rake task) must find the
|
|
27
|
+
# server without asking, and a second Puma on the same host and data
|
|
28
|
+
# dir must hit the same server (docs: two processes, one data dir).
|
|
29
|
+
@port = Integer(ENV.fetch("RAILWATCH_SERVER_PORT", "28790"))
|
|
30
|
+
# nil: storage/railwatch/<Rails.env>, one per environment, as Rails
|
|
31
|
+
# keeps storage/<env>.sqlite3. See #data_path.
|
|
32
|
+
@data_dir = ENV["RAILWATCH_SERVER_DATA_DIR"]
|
|
33
|
+
@mount_path = ENV.fetch("RAILWATCH_SERVER_MOUNT_PATH", "/railwatch")
|
|
34
|
+
# A railwatch binary of your own (another version, a platform this
|
|
35
|
+
# gem has no build for). Default: the one in this platform's gem.
|
|
36
|
+
@binary = ENV["RAILWATCH_SERVER_BINARY"]
|
|
37
|
+
# The public URL of the dashboard's host, for links in alert emails
|
|
38
|
+
# and the Secure cookie flag. Default: the loopback address.
|
|
39
|
+
@url = ENV["RAILWATCH_SERVER_URL"]
|
|
40
|
+
# How long Puma's exit waits for the server to drain after TERM. The
|
|
41
|
+
# server answers SIGTERM by finishing in-flight requests and committing
|
|
42
|
+
# queued writes; an idle one is gone in well under a second.
|
|
43
|
+
@stop_timeout = Float(ENV.fetch("RAILWATCH_SERVER_STOP_TIMEOUT", "15"))
|
|
44
|
+
# How long the first start of a data dir waits for `railwatch setup`
|
|
45
|
+
# (it takes about a second), before Puma listens. Past it the setup is
|
|
46
|
+
# KILLed and the app boots without the server; the next start retries.
|
|
47
|
+
@setup_timeout = Float(ENV.fetch("RAILWATCH_SERVER_SETUP_TIMEOUT", "30"))
|
|
48
|
+
# Who the dashboard's visitor is, as the server should see them:
|
|
49
|
+
# ->(controller) { { email:, name:, role: "admin" | "member" } }.
|
|
50
|
+
@user = nil
|
|
51
|
+
# Point the railwatch agent at the bundled server when the app has not
|
|
52
|
+
# configured one (no RAILWATCH_TOKEN). Off: the app wires it itself.
|
|
53
|
+
@wire_agent = env_bool("RAILWATCH_SERVER_WIRE_AGENT", true)
|
|
54
|
+
# Open live-channel connections (one per dashboard tab) per app
|
|
55
|
+
# process, all spliced by one relay thread. Past it the dashboard
|
|
56
|
+
# still works, without live updates.
|
|
57
|
+
@max_live_connections = Integer(ENV.fetch("RAILWATCH_SERVER_MAX_LIVE_CONNECTIONS", "64"))
|
|
58
|
+
# Dashboard requests waiting on the server or streaming back, per app
|
|
59
|
+
# process, all moved by that same relay thread instead of a Puma
|
|
60
|
+
# thread each. Past it a dashboard request gets 503 and Retry-After
|
|
61
|
+
# at once; the app's own requests are never affected.
|
|
62
|
+
@max_proxied_requests = Integer(ENV.fetch("RAILWATCH_SERVER_MAX_PROXIED_REQUESTS", "64"))
|
|
63
|
+
# Seconds: to connect to the server (on loopback: it is down, or past
|
|
64
|
+
# its own connection cap), for it to start answering a dashboard
|
|
65
|
+
# request (504 past it; the slowest page at 30 days takes seconds),
|
|
66
|
+
# and for a response that has started to move again (cut off past it).
|
|
67
|
+
# The first-byte timeout is under the 30 s after which Thruster and
|
|
68
|
+
# kamal-proxy drop a response that has not started: past theirs the
|
|
69
|
+
# visitor gets an empty reply, under it this proxy's 504 that says why.
|
|
70
|
+
@proxy_connect_timeout = Float(ENV.fetch("RAILWATCH_SERVER_PROXY_CONNECT_TIMEOUT", "2"))
|
|
71
|
+
@proxy_first_byte_timeout = Float(ENV.fetch("RAILWATCH_SERVER_PROXY_FIRST_BYTE_TIMEOUT", "25"))
|
|
72
|
+
@proxy_idle_timeout = Float(ENV.fetch("RAILWATCH_SERVER_PROXY_IDLE_TIMEOUT", "60"))
|
|
73
|
+
# How much lower than the app's the server's CPU priority is (nice
|
|
74
|
+
# increment, 0-19; 0 keeps the app's). The server can keep every core
|
|
75
|
+
# busy rendering dashboards; at 10 the app's requests come first. Not
|
|
76
|
+
# under a CPU quota (docker --cpus, Kubernetes limits), which stalls
|
|
77
|
+
# the app with the server whatever the priority.
|
|
78
|
+
@nice = Integer(ENV.fetch("RAILWATCH_SERVER_NICE", "10"))
|
|
79
|
+
# The server's own settings (Settings): nil means "from the app"
|
|
80
|
+
# (Action Mailer's SMTP and From, credentials.railwatch_server's
|
|
81
|
+
# backup and google), false/{} turns one off.
|
|
82
|
+
# smtp: { address:, port:, user_name:, password:, enable_starttls:, tls: }
|
|
83
|
+
# backup: { endpoint:, bucket:, access_key_id:, secret_access_key:, region:, prefix:, interval_seconds: }
|
|
84
|
+
# google: { client_id:, client_secret: }
|
|
85
|
+
# retention: { detail_days:, core_days_max: }
|
|
86
|
+
@smtp = nil
|
|
87
|
+
@mail_from = nil
|
|
88
|
+
@backup = nil
|
|
89
|
+
@google = nil
|
|
90
|
+
@retention = nil
|
|
91
|
+
# true/false: send alerts or only record them. nil: the server's default (on).
|
|
92
|
+
@alert_delivery = nil
|
|
93
|
+
# Any other RAILWATCH_* setting of the server, by name.
|
|
94
|
+
@server_env = {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def enabled
|
|
98
|
+
@enabled.nil? ? environment != "test" : @enabled
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The environment the server's data dir holds: Rails.env, else
|
|
102
|
+
# RAILS_ENV/RACK_ENV, else production. One per data dir.
|
|
103
|
+
def environment
|
|
104
|
+
(defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env : ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "production").to_s
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def data_path
|
|
108
|
+
path = Pathname.new(data_dir || File.join("storage", "railwatch", environment))
|
|
109
|
+
path.relative? && defined?(Rails) && Rails.root ? Rails.root.join(path) : path.expand_path
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def local_url = "http://127.0.0.1:#{port}"
|
|
113
|
+
|
|
114
|
+
def normalized_mount_path
|
|
115
|
+
"/#{mount_path.to_s.delete_prefix('/').delete_suffix('/')}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def env_bool(name, default)
|
|
121
|
+
value = ENV[name]
|
|
122
|
+
return default if value.nil? || value.strip.empty?
|
|
123
|
+
|
|
124
|
+
%w[1 true yes on].include?(value.strip.downcase)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|