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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "securerandom"
6
+
7
+ module Railwatch
8
+ module Server
9
+ # The server's data directory under the app (storage/railwatch by
10
+ # default) and what this gem keeps in it next to the server's own files:
11
+ #
12
+ # control.sqlite3, telemetry/, serve.lock, secret_key_base the server's
13
+ # proxy_secret the HMAC key the /railwatch proxy signs with (0600)
14
+ # ingest_token the first environment's rw_ token (0600), so every
15
+ # process of the app can report without asking
16
+ # server.log the server's stdout and stderr
17
+ # server.env the server's settings from the app (Settings, 0600)
18
+ # environment the Rails environment it holds (one per data dir)
19
+ #
20
+ # Provisioning runs `railwatch setup` once, before the first `serve`:
21
+ # setup refuses a data dir that already has users, so it is idempotent by
22
+ # construction, and the token it prints is written down here because the
23
+ # server keeps only its digest.
24
+ #
25
+ # It runs before Puma listens, so it has a deadline (setup_timeout): past
26
+ # it the setup's process group is KILLed and provisioning fails, and the
27
+ # app boots without the server. A setup that fails, by deadline or exit
28
+ # status, leaves no ingest_token (so the dir is not provisioned?), and
29
+ # what it had created is removed: users it had committed would make every
30
+ # later setup refuse the dir ("already has users").
31
+ class DataDir
32
+ SETUP_LOCK = "provision.lock"
33
+
34
+ attr_reader :path
35
+
36
+ def initialize(path)
37
+ @path = Pathname.new(path)
38
+ end
39
+
40
+ def proxy_secret_file = path.join("proxy_secret")
41
+ def token_file = path.join("ingest_token")
42
+ def log_file = path.join("server.log")
43
+ def settings_file = path.join("server.env")
44
+ def environment_file = path.join("environment")
45
+
46
+ # The Rails environment this data dir was provisioned for. Data dirs
47
+ # provisioned before the file existed say nothing.
48
+ def environment
49
+ environment_file.read.strip if environment_file.exist?
50
+ end
51
+
52
+ def token
53
+ token_file.read.strip if token_file.exist?
54
+ end
55
+
56
+ def proxy_secret
57
+ proxy_secret_file.read.strip if proxy_secret_file.exist?
58
+ end
59
+
60
+ def provisioned?
61
+ token_file.exist? && proxy_secret_file.exist?
62
+ end
63
+
64
+ # Creates the directory, the proxy secret and (through `railwatch
65
+ # setup`) the admin, account, app and environment, once. Two processes
66
+ # booting together take turns on an flock, so only one runs setup; the
67
+ # wait for it counts against the same timeout, so workers that each
68
+ # provision (a bare `puma` in cluster mode) are not held up one after
69
+ # another. Returns what it did, for the log.
70
+ def provision!(binary:, env:, app_name:, env_name:, timeout:)
71
+ FileUtils.mkdir_p(path, mode: 0o700)
72
+ deadline = monotonic + timeout
73
+ done = []
74
+ File.open(path.join(SETUP_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
75
+ until lock.flock(File::LOCK_EX | File::LOCK_NB)
76
+ if monotonic >= deadline
77
+ raise Error, "another process has been provisioning #{path} for #{timeout}s (RAILWATCH_SERVER_SETUP_TIMEOUT); " \
78
+ "this one boots without the Railwatch server and tries again at its next start"
79
+ end
80
+ sleep 0.05
81
+ end
82
+ unless proxy_secret_file.exist?
83
+ write_secret(proxy_secret_file, SecureRandom.hex(32))
84
+ done << "proxy secret"
85
+ end
86
+ unless token_file.exist?
87
+ write_secret(token_file, setup!(binary, env, app_name, env_name, timeout, [ deadline - monotonic, 0.1 ].max))
88
+ done << "admin, account, #{app_name}/#{env_name} and its ingest token"
89
+ end
90
+ File.write(environment_file, "#{env_name}\n") unless environment_file.exist?
91
+ end
92
+ done
93
+ end
94
+
95
+ private
96
+
97
+ def write_secret(file, value)
98
+ tmp = file.sub_ext(".tmp")
99
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write("#{value}\n") }
100
+ File.rename(tmp, file)
101
+ end
102
+
103
+ # The admin's password is random and thrown away: nobody signs in with
104
+ # it. People reach the dashboard through the app's own gate, and the
105
+ # proxy names them (ProxyAuth); the admin is only the account's owner.
106
+ def setup!(binary, env, app_name, env_name, timeout, left)
107
+ password = SecureRandom.base58(32)
108
+ before = path.children
109
+ out, err, status = run_setup(left, env, binary, "setup", "--email", "railwatch-owner@localhost.invalid", "--name", "Railwatch",
110
+ "--account", app_name, "--app", app_name, "--env", env_name, "--password-stdin",
111
+ stdin_data: "#{password}\n")
112
+ token = out[/RAILWATCH_TOKEN=(rw_\S+)/, 1]
113
+ return token if status&.success? && token
114
+
115
+ (path.children - before).each { FileUtils.rm_rf(_1) }
116
+ unless status
117
+ raise Error, "railwatch setup did not finish within #{timeout}s (RAILWATCH_SERVER_SETUP_TIMEOUT); KILLED it. " \
118
+ "The app boots without the Railwatch server: the agent is not wired and the dashboard is unavailable. " \
119
+ "The next start runs setup again#{"; it printed: #{err.strip.lines.last(5).join.strip}" if err.strip != ""}"
120
+ end
121
+ if err.include?("already has users")
122
+ raise Error, "#{path} already has a Railwatch server with users but no #{token_file.basename}: " \
123
+ "create a token with `#{binary} env create <slug>` (RAILWATCH_DATA_DIR=#{path}) and write it to #{token_file}"
124
+ end
125
+ raise Error, "railwatch setup failed (exit #{status.exitstatus}): #{err.strip.presence || out.strip}"
126
+ end
127
+
128
+ # Open3.capture3 with a deadline: [stdout, stderr, status], status nil
129
+ # when the deadline passed and the setup's process group was KILLed.
130
+ def run_setup(timeout, *command, stdin_data:)
131
+ Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
132
+ out = Thread.new { stdout.read }
133
+ err = Thread.new { stderr.read }
134
+ begin
135
+ stdin.write(stdin_data)
136
+ rescue Errno::EPIPE
137
+ nil
138
+ end
139
+ stdin.close
140
+ return [ out.value, err.value, wait.value ] if wait.join(timeout)
141
+
142
+ begin
143
+ ::Process.kill(:KILL, -wait.pid)
144
+ rescue Errno::ESRCH
145
+ nil
146
+ end
147
+ wait.join
148
+ [ out.join(1)&.value.to_s, err.join(1)&.value.to_s, nil ]
149
+ end
150
+ end
151
+
152
+ def monotonic = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Server
5
+ # An engine only for its app/controllers (autoloaded, so the proxy can
6
+ # inherit from the app's own base controller) and lib/tasks (an engine
7
+ # loads those itself); the route is the app's, prepended so no catch-all
8
+ # of the app shadows it.
9
+ class Engine < ::Rails::Engine
10
+ initializer "railwatch_server.routes" do |app|
11
+ next unless Railwatch::Server.enabled?
12
+
13
+ mount = Railwatch::Server.config.normalized_mount_path
14
+ app.routes.prepend do
15
+ match "#{mount}(/*path)", to: "railwatch/server/proxy#forward", via: :all, format: false,
16
+ as: :railwatch_server_dashboard
17
+ end
18
+ end
19
+
20
+ # Outside every other middleware (the railwatch agent's included), so
21
+ # the app's session and cookies are written before the browser's socket
22
+ # is handed to the relay (ProxyHandoff).
23
+ initializer "railwatch_server.handoff" do |app|
24
+ next unless Railwatch::Server.enabled?
25
+
26
+ app.middleware.insert_before 0, Railwatch::Server::ProxyHandoff
27
+ end
28
+
29
+ # After the app's initializers (and the railwatch gem's credentials
30
+ # lookup), before the railwatch gem's reporter adopts its final config.
31
+ initializer "railwatch_server.agent", after: :load_config_initializers, before: "railwatch.adopt_final_config" do
32
+ next unless Railwatch::Server.enabled?
33
+
34
+ # The dashboard's own requests (every page, each live refresh) are
35
+ # not the app's traffic: recording them would also make each view a
36
+ # new ingest, which pings the live channel, which refetches the page.
37
+ mount = Railwatch::Server.config.normalized_mount_path
38
+ Railwatch.config.ignored_request_paths |= [ %r{\A#{Regexp.escape(mount)}(/|\z)} ]
39
+
40
+ case Railwatch::Server.wire_agent!
41
+ when :wired
42
+ Rails.logger.info("[railwatch-server] the railwatch agent reports to #{Railwatch::Server.config.local_url}")
43
+ when :not_provisioned
44
+ Rails.logger.warn("[railwatch-server] #{Railwatch::Server.data_dir.path} is not provisioned yet, so this process's agent is not wired: " \
45
+ "the server's process creates it the first time it starts (Puma with `plugin :railwatch_server`, or " \
46
+ "railwatch_server:start); this process reports once it restarts after that")
47
+ when :configured_elsewhere
48
+ Rails.logger.info("[railwatch-server] the railwatch agent is configured elsewhere (RAILWATCH_TOKEN or transport :local); " \
49
+ "the bundled server only serves the dashboard")
50
+ end
51
+ rescue Railwatch::Server::Error, Railwatch::Server::Binary::NotFound => e
52
+ # Loud and not fatal: the app boots, the dashboard says what is wrong.
53
+ Rails.logger.error("[railwatch-server] #{e.message}")
54
+ warn("[railwatch-server] #{e.message}")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "socket"
5
+
6
+ module Railwatch
7
+ module Server
8
+ # One `railwatch serve` child: started, watched, restarted with backoff
9
+ # when it dies, stopped with TERM and a deadline. The Puma plugin drives
10
+ # it from Puma's master; nothing here runs in a worker.
11
+ #
12
+ # The child runs in its own process group, so a Ctrl-C in the terminal
13
+ # reaches Puma only: Puma drains its requests, its reporter flushes the
14
+ # last batches into this server, and only then is the server stopped.
15
+ #
16
+ # `server.pid` in the data dir holds "<child pid> <parent pid> <host>". A
17
+ # Puma that was SIGKILLed cannot stop its child, which would then hold
18
+ # serve.lock forever and make every later server wait; the next start
19
+ # finds the record, sees the parent gone, and stops the orphan first.
20
+ # Only on the same host name: the data dir may be a volume shared with
21
+ # another container, whose pids mean other processes here.
22
+ #
23
+ # Alive is not enough: a stopped or wedged server records nothing and
24
+ # holds every dashboard request until the proxy gives up. Each poll asks
25
+ # its /up (PROBE_TIMEOUT); one that has not answered for HANG_TIMEOUT
26
+ # is KILLed and restarted with the backoff. One that never answered
27
+ # since it was started gets STARTUP_TIMEOUT (it may be migrating).
28
+ class Process
29
+ BACKOFF = [ 1, 2, 4, 8, 15, 30 ].freeze
30
+ # How long a start waits for /up. Under `bin/rails server` that is
31
+ # before Puma listens, so it is boot time: a healthy server answers in
32
+ # ~100 ms, and one that doesn't is left to the supervisor.
33
+ READY_TIMEOUT = 5
34
+ PROBE_TIMEOUT = 2
35
+ HANG_TIMEOUT = 8
36
+ STARTUP_TIMEOUT = 60
37
+
38
+ attr_reader :pid
39
+
40
+ def initialize(config:, data_dir:, binary:, log:)
41
+ @config = config
42
+ @data_dir = data_dir
43
+ @binary = binary
44
+ @log = log
45
+ @pid = nil
46
+ @failures = 0
47
+ @next_start_at = 0.0
48
+ end
49
+
50
+ def env
51
+ {
52
+ "RAILWATCH_DATA_DIR" => @data_dir.path.to_s,
53
+ "RAILWATCH_BIND" => "127.0.0.1:#{@config.port}",
54
+ "RAILWATCH_URL" => @config.url || @config.local_url,
55
+ "RAILWATCH_BASE_PATH" => @config.normalized_mount_path,
56
+ "RAILWATCH_TRUSTED_PROXY_SECRET_FILE" => @data_dir.proxy_secret_file.to_s,
57
+ # Every dashboard request comes from the app's ProxyController,
58
+ # which sets X-Forwarded-For to the visitor's request.remote_ip
59
+ # (and never passes the browser's on), so the server's per-IP
60
+ # limits (sign-in, MCP) see visitors, not the app. The agents post
61
+ # /ingest on loopback without one and are keyed on 127.0.0.1.
62
+ "RAILWATCH_TRUST_PROXY" => "true",
63
+ # A second server on this data dir (the new container of a deploy,
64
+ # which has its own loopback) answers on its own port at once and
65
+ # relays to the one holding serve.lock through serve.sock in the
66
+ # data dir, until it takes over (railwatch-rs RAILWATCH_STANDBY_RELAY).
67
+ "RAILWATCH_STANDBY_RELAY" => "true",
68
+ # The server's settings from the app (Settings), and nothing else
69
+ # of the machine's: no installed /etc/railwatch/railwatch.env, no
70
+ # railwatch.toml from the app's directory, no systemd sockets
71
+ # meant for Puma.
72
+ "RAILWATCH_ENV_FILE" => @data_dir.settings_file.to_s,
73
+ "RAILWATCH_CONFIG" => nil,
74
+ "LISTEN_FDS" => nil, "LISTEN_PID" => nil, "LISTEN_FDNAMES" => nil
75
+ }
76
+ end
77
+
78
+ def alive?
79
+ return false unless @pid
80
+
81
+ ::Process.kill(0, @pid)
82
+ # A zombie nobody has reaped yet still answers signal 0.
83
+ !::Process.waitpid(@pid, ::Process::WNOHANG)
84
+ rescue Errno::ESRCH
85
+ false
86
+ rescue Errno::ECHILD
87
+ # Puma's cluster reaps every child with wait2(-1), this one too.
88
+ begin
89
+ ::Process.kill(0, @pid)
90
+ true
91
+ rescue Errno::ESRCH
92
+ false
93
+ end
94
+ end
95
+
96
+ # Called every poll: starts the server when it is not running and its
97
+ # backoff has passed. Loud when it had died.
98
+ def ensure_running
99
+ if alive?
100
+ @standby ? took_over : check_health
101
+ return
102
+ end
103
+
104
+ failed("DIED") if @pid
105
+ return if monotonic < @next_start_at
106
+
107
+ start
108
+ end
109
+
110
+ def start
111
+ stop_orphan
112
+ write_settings
113
+ # Another server already answers on the port: another app process
114
+ # on this host and loopback (a second Puma) whose server owns the
115
+ # data dir. Ours waits on serve.lock and takes over when that one
116
+ # exits; meanwhile every agent here reports to it.
117
+ @standby = up?
118
+ log = File.open(@data_dir.log_file, "a")
119
+ @log_offset_at_spawn = log.size
120
+ @relay_log_offset = nil
121
+ @answered = false
122
+ @unanswered_since = nil
123
+ @pid = ::Process.spawn(env, @binary, "serve", chdir: @data_dir.path.to_s, in: File::NULL, out: log, err: log, pgroup: true)
124
+ log.close
125
+ renice
126
+ if @standby
127
+ @log.call("Railwatch server (pid #{@pid}) on standby: another server already answers on 127.0.0.1:#{@config.port} " \
128
+ "and owns #{@data_dir.path}; this one waits for its serve.lock and takes over when it exits. " \
129
+ "Until then this process's agent reports to that server.")
130
+ return
131
+ end
132
+ write_pid_file
133
+ started = monotonic
134
+ @log.call("Railwatch server starting (pid #{@pid}, 127.0.0.1:#{@config.port}, data #{@data_dir.path}, log #{@data_dir.log_file})")
135
+ if wait_until_ready(started)
136
+ relaying = relaying?
137
+ @standby = relaying
138
+ @log.call(relaying ? "Railwatch server (pid #{@pid}) answering on 127.0.0.1:#{@config.port} in #{((monotonic - started) * 1000).round} ms " \
139
+ "as a standby: another app process (the old container of a deploy) holds #{@data_dir.path}/serve.lock; " \
140
+ "this one relays to it through serve.sock and takes over when it exits" :
141
+ "Railwatch server ready in #{((monotonic - started) * 1000).round} ms; dashboard at #{@config.normalized_mount_path}")
142
+ answered
143
+ elsif alive?
144
+ if waiting_for_lock?
145
+ @log.call("Railwatch server (pid #{@pid}) is waiting for #{@data_dir.path}/serve.lock: another server owns this " \
146
+ "data dir and keeps serving; this one takes over when it exits")
147
+ else
148
+ @unanswered_since = started
149
+ @log.call("Railwatch server (pid #{@pid}) not answering /up after #{READY_TIMEOUT}s; not waiting longer for it. " \
150
+ "Killed and restarted if it has not answered #{STARTUP_TIMEOUT}s after starting; see #{@data_dir.log_file}")
151
+ end
152
+ end
153
+ rescue SystemCallError => e
154
+ @pid = nil
155
+ @failures += 1
156
+ @next_start_at = monotonic + BACKOFF[[ @failures - 1, BACKOFF.size - 1 ].min]
157
+ @log.call("Railwatch server could not be started (#{e.class}: #{e.message}); retrying")
158
+ end
159
+
160
+ # TERM: the server stops accepting, finishes in-flight requests and
161
+ # commits what its writers hold, then exits. KILL past the deadline.
162
+ def stop(timeout: @config.stop_timeout)
163
+ return unless @pid
164
+
165
+ started = monotonic
166
+ ::Process.kill(:TERM, @pid)
167
+ if reaped_within?(timeout)
168
+ @log.call("Railwatch server (pid #{@pid}) stopped in #{((monotonic - started) * 1000).round} ms")
169
+ else
170
+ @log.call("Railwatch server (pid #{@pid}) did not exit within #{timeout}s of TERM; killing it")
171
+ ::Process.kill(:KILL, @pid)
172
+ reaped_within?(1)
173
+ end
174
+ rescue Errno::ESRCH, Errno::ECHILD
175
+ nil
176
+ ensure
177
+ pid_file = @data_dir.path.join("server.pid")
178
+ pid_file.delete if @pid && pid_file.exist? && pid_file.read.split.values_at(0, 2) == [ @pid.to_s, Socket.gethostname ]
179
+ @pid = nil
180
+ end
181
+
182
+ private
183
+
184
+ def up?(timeout: PROBE_TIMEOUT)
185
+ Net::HTTP.start("127.0.0.1", @config.port, open_timeout: timeout, read_timeout: timeout) do |http|
186
+ http.get("/up").is_a?(Net::HTTPSuccess)
187
+ end
188
+ rescue SystemCallError, IOError, Timeout::Error, Net::ProtocolError, Net::HTTPBadResponse
189
+ # A wedged server (or something else on the port) may answer garbage:
190
+ # not answering, not a reason for the supervisor to stop.
191
+ false
192
+ end
193
+
194
+ # Logged when it stops answering and when it answers again, once each.
195
+ def check_health
196
+ asked_at = monotonic
197
+ if up?
198
+ @log.call("Railwatch server (pid #{@pid}) answering /up again after #{(monotonic - @unanswered_since).round}s") if @unanswered_since && @answered
199
+ answered unless @answered
200
+ @unanswered_since = nil
201
+ return
202
+ end
203
+ return if !@answered && waiting_for_lock?
204
+
205
+ limit = @answered ? HANG_TIMEOUT : STARTUP_TIMEOUT
206
+ unless @unanswered_since
207
+ @unanswered_since = asked_at
208
+ @log.call("Railwatch server (pid #{@pid}) is running but NOT ANSWERING /up within #{PROBE_TIMEOUT}s; " \
209
+ "telemetry is not being recorded. Killed and restarted if it stays silent for #{limit}s")
210
+ end
211
+ silent = monotonic - @unanswered_since
212
+ return if silent < limit
213
+
214
+ ::Process.kill(:KILL, @pid)
215
+ reaped_within?(1)
216
+ failed("HUNG (alive, no answer on /up for #{silent.round}s); KILLED it")
217
+ rescue Errno::ESRCH, Errno::ECHILD
218
+ nil
219
+ end
220
+
221
+ def failed(what)
222
+ @failures += 1
223
+ delay = BACKOFF[[ @failures - 1, BACKOFF.size - 1 ].min]
224
+ @log.call("Railwatch server (pid #{@pid}) #{what}; restarting in #{delay}s (failure #{@failures}). " \
225
+ "Last lines of #{@data_dir.log_file}:\n#{tail_log}")
226
+ @pid = nil
227
+ @next_start_at = monotonic + delay
228
+ end
229
+
230
+ def answered
231
+ @answered = true
232
+ @failures = 0
233
+ renice
234
+ end
235
+
236
+ # Below the app's own priority (RAILWATCH_SERVER_NICE), so a busy
237
+ # dashboard, which can keep every core busy, takes CPU from the app's
238
+ # requests last. Linux keeps a priority per thread: set on the child's
239
+ # process group (every thread in it) right after the spawn, and again
240
+ # once it answers, for any thread started in between; later threads
241
+ # inherit it.
242
+ def renice
243
+ return if @config.nice.zero?
244
+
245
+ ::Process.setpriority(::Process::PRIO_PGRP, @pid, [ ::Process.getpriority(::Process::PRIO_PROCESS, 0) + @config.nice, 19 ].min)
246
+ rescue SystemCallError => e
247
+ @log.call("Railwatch server (pid #{@pid}) runs at the app's priority: nice +#{@config.nice} failed (#{e.message})")
248
+ end
249
+
250
+ def waiting_for_lock?
251
+ lines = File.open(@data_dir.log_file) { |f| f.seek(@log_offset_at_spawn || 0); f.read(64 * 1024) }.to_s.lines
252
+ lines.grep(/waiting for .*serve\.lock/).any? && lines.grep(/serve\.lock acquired after/).none?
253
+ rescue SystemCallError
254
+ false
255
+ end
256
+
257
+ # Re-read on every start, so a restart picks up changed credentials.
258
+ # Names only in the log, never values.
259
+ def write_settings
260
+ settings = Settings.new(@config)
261
+ env = settings.env
262
+ settings.write!(@data_dir.settings_file.to_s)
263
+ groups = settings.groups(env)
264
+ @log.call("Railwatch server settings from the app: #{groups.join(', ')}") if groups.any? && groups != @logged_groups
265
+ @logged_groups = groups
266
+ rescue Settings::Invalid => e
267
+ @log.call("Railwatch server settings NOT applied: #{e.message}")
268
+ Settings.new(Configuration.new, rails: nil).write!(@data_dir.settings_file.to_s)
269
+ end
270
+
271
+ def write_pid_file
272
+ File.write(@data_dir.path.join("server.pid"), "#{@pid} #{::Process.pid} #{Socket.gethostname}\n")
273
+ end
274
+
275
+ # The other server exited and ours holds serve.lock now. Same
276
+ # loopback: the other deleted server.pid on a clean stop and ours
277
+ # answers on the port. Relaying (another container): ours said in its
278
+ # log that it acquired the lock.
279
+ def took_over
280
+ if @relay_log_offset
281
+ return if relaying?
282
+ else
283
+ return if @data_dir.path.join("server.pid").exist? || !up?
284
+ end
285
+
286
+ @standby = false
287
+ @relay_log_offset = nil
288
+ write_pid_file
289
+ @log.call("Railwatch server (pid #{@pid}) took over #{@data_dir.path}: the previous server exited")
290
+ end
291
+
292
+ # Whether our server is relaying to another one (railwatch-rs RAILWATCH_STANDBY_RELAY):
293
+ # its log says "standby: ... relaying" and not yet "acquired after".
294
+ # Read from where this server's lines start.
295
+ def relaying?
296
+ @relay_log_offset ||= relay_log_start
297
+ lines = File.open(@data_dir.log_file) { |f| f.seek(@relay_log_offset); f.read }.lines
298
+ started = lines.rindex { |l| l.include?("railwatch: standby:") && l.include?("relaying") }
299
+ return false unless started
300
+
301
+ lines[started..].none? { |l| l.include?("serve.lock acquired after") }
302
+ rescue SystemCallError
303
+ false
304
+ end
305
+
306
+ def relay_log_start
307
+ @log_offset_at_spawn || 0
308
+ end
309
+
310
+ def wait_until_ready(started)
311
+ while (left = READY_TIMEOUT - (monotonic - started)) > 0
312
+ return false unless alive?
313
+ return true if up?(timeout: [ left, PROBE_TIMEOUT ].min)
314
+
315
+ sleep 0.1
316
+ end
317
+ false
318
+ end
319
+
320
+ def stop_orphan
321
+ file = @data_dir.path.join("server.pid")
322
+ return unless file.exist?
323
+
324
+ child, parent, host = file.read.split
325
+ child, parent = child.to_i, parent.to_i
326
+ return if host != Socket.gethostname
327
+ return if child <= 0 || parent == ::Process.pid || process?(parent) || !process?(child)
328
+
329
+ @log.call("Railwatch server pid #{child} was left behind by pid #{parent}, which is gone; stopping it")
330
+ ::Process.kill(:TERM, child)
331
+ deadline = monotonic + @config.stop_timeout
332
+ sleep 0.1 while process?(child) && monotonic < deadline
333
+ ::Process.kill(:KILL, child) if process?(child)
334
+ rescue Errno::ESRCH, Errno::EPERM
335
+ nil
336
+ end
337
+
338
+ def process?(pid)
339
+ ::Process.kill(0, pid)
340
+ true
341
+ rescue Errno::ESRCH
342
+ false
343
+ rescue Errno::EPERM
344
+ true
345
+ end
346
+
347
+ def reaped_within?(seconds)
348
+ deadline = monotonic + seconds
349
+ loop do
350
+ return true if ::Process.waitpid(@pid, ::Process::WNOHANG)
351
+ return false if monotonic >= deadline
352
+
353
+ sleep 0.05
354
+ end
355
+ rescue Errno::ECHILD
356
+ # Reaped by Puma's cluster: gone iff signal 0 says so.
357
+ loop do
358
+ return true unless process?(@pid)
359
+ return false if monotonic >= deadline
360
+
361
+ sleep 0.05
362
+ end
363
+ end
364
+
365
+ def tail_log
366
+ File.exist?(@data_dir.log_file) ? File.read(@data_dir.log_file).lines.last(10).join : "(no log)"
367
+ end
368
+
369
+ def monotonic = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Railwatch
8
+ module Server
9
+ # The header the server trusts as "this request is user X with role Y"
10
+ # (railwatch-rs web/proxy_auth.rs, `trusted_proxy_secret_file`):
11
+ #
12
+ # v1.<base64url(claims JSON)>.<base64url(HMAC-SHA256(secret, "v1." + claims))>
13
+ #
14
+ # Signed per request and valid for TTL seconds; the server refuses
15
+ # anything valid for longer than 300.
16
+ module ProxyAuth
17
+ HEADER = "X-Railwatch-Proxy-Auth"
18
+ TTL = 60
19
+ ROLES = %w[admin member].freeze
20
+
21
+ module_function
22
+
23
+ def sign(secret, email:, name:, role:, now: Time.now.to_i)
24
+ raise ArgumentError, "role must be one of #{ROLES.join(', ')}" unless ROLES.include?(role.to_s)
25
+
26
+ claims = { email: email.to_s, name: name.to_s, role: role.to_s, exp: now + TTL }
27
+ signed = "v1.#{Base64.urlsafe_encode64(JSON.generate(claims), padding: false)}"
28
+ signature = Base64.urlsafe_encode64(OpenSSL::HMAC.digest("SHA256", secret, signed), padding: false)
29
+ "#{signed}.#{signature}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Server
5
+ # The last step of a proxied dashboard request. ProxyController#forward
6
+ # checks the gate, connects to the server and leaves the request here
7
+ # (env[ENV_KEY]); this middleware, outside every other one, takes the
8
+ # browser's socket from Puma and gives both to the Relay, which writes the
9
+ # request and passes the server's response back. The Puma thread returns
10
+ # at once instead of waiting for the page.
11
+ #
12
+ # Outside, not in the controller, because by now the app's session and
13
+ # cookie middleware have written their Set-Cookie (a rolling session,
14
+ # Devise's timeoutable), which goes out with the server's response, and
15
+ # every body proxy (the executor, the query cache) has finished.
16
+ class ProxyHandoff
17
+ ENV_KEY = "railwatch_server.handoff"
18
+ # The app's own headers that still apply to a response the server
19
+ # wrote; every other header of the app's empty 200 is dropped.
20
+ APP_HEADERS = %w[set-cookie strict-transport-security].freeze
21
+
22
+ Handoff = Struct.new(:upstream, :request, :body)
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ begin
30
+ status, headers, body = @app.call(env)
31
+ rescue Exception # rubocop:disable Lint/RescueException
32
+ # Raised after the controller: its server connection is ours to close.
33
+ env.delete(ENV_KEY)&.then { |h| [ h.upstream, h.body ].compact.each { |io| io.close rescue nil } }
34
+ raise
35
+ end
36
+ handoff = env.delete(ENV_KEY)
37
+ return [ status, headers, body ] unless handoff
38
+
39
+ body.close if body.respond_to?(:close)
40
+ unless status.to_i == 200
41
+ # Something after the controller answered instead (an error page).
42
+ [ handoff.upstream, handoff.body ].compact.each { |io| io.close rescue nil }
43
+ return [ status, headers, body ]
44
+ end
45
+
46
+ app_headers = APP_HEADERS.flat_map do |name|
47
+ Array(headers[name]).flat_map { _1.to_s.split("\n") }.map { [ name, _1 ] }
48
+ end
49
+ client = env["rack.hijack"].call
50
+ Relay.current.proxy(client, handoff.upstream, request: handoff.request, body: handoff.body, headers: app_headers)
51
+ [ 200, {}, [] ]
52
+ end
53
+ end
54
+ end
55
+ end