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,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
|