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.
@@ -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