railwatch-server 0.1.0-aarch64-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/aarch64-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,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Server
|
|
5
|
+
# Sockets the proxy has taken from Puma, moved by one thread per app
|
|
6
|
+
# process with IO.select instead of a Puma thread (or two threads) per
|
|
7
|
+
# connection. Two kinds:
|
|
8
|
+
#
|
|
9
|
+
# - live: the live channel (the dashboard's websocket, one per open tab),
|
|
10
|
+
# spliced both ways until either side closes (#add).
|
|
11
|
+
# - proxied: every other dashboard request (#proxy). The request, which
|
|
12
|
+
# Puma has read in full, is written to the server; the response is
|
|
13
|
+
# written back to the browser as it arrives, byte for byte except its
|
|
14
|
+
# head (Connection: close, the app's own Set-Cookie added: ProxyHandoff), and then
|
|
15
|
+
# both sockets close. A slow page waits here, not on a Puma thread.
|
|
16
|
+
#
|
|
17
|
+
# Each kind has its own cap (Configuration#max_live_connections,
|
|
18
|
+
# #max_proxied_requests). Each direction buffers at most MAX_BUFFER bytes
|
|
19
|
+
# for a slow reader before its source stops being read.
|
|
20
|
+
#
|
|
21
|
+
# A proxied request the server has not started answering within
|
|
22
|
+
# first_byte_timeout gets a 504, one it stops answering before sending a
|
|
23
|
+
# byte a 502, both written here; one that goes quiet for idle_timeout
|
|
24
|
+
# after its first byte is cut off.
|
|
25
|
+
class Relay
|
|
26
|
+
CHUNK = 16 * 1024
|
|
27
|
+
MAX_BUFFER = 1024 * 1024
|
|
28
|
+
MAX_HEAD = 64 * 1024
|
|
29
|
+
HOP_BY_HOP = %w[connection keep-alive proxy-connection].freeze
|
|
30
|
+
# The server's own HSTS (sent behind a trusted proxy with an https
|
|
31
|
+
# url): the host is the app's, and so is its policy (ProxyHandoff
|
|
32
|
+
# passes the app's on).
|
|
33
|
+
APP_OWNED = %w[strict-transport-security].freeze
|
|
34
|
+
REASONS = { 502 => "Bad Gateway", 504 => "Gateway Timeout" }.freeze
|
|
35
|
+
|
|
36
|
+
# input: what this side's peer is sent instead of what is read from io
|
|
37
|
+
# (a proxied request's body, from Puma's tempfile).
|
|
38
|
+
Side = Struct.new(:io, :peer, :pending, :eof, :input, :pair)
|
|
39
|
+
# head: the proxied response's head while it arrives; nil once it has
|
|
40
|
+
# been passed on (or the relay answered instead).
|
|
41
|
+
Pair = Struct.new(:kind, :left, :right, :started_at, :active_at, :head, :extra_headers)
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# One per process: a forked Puma worker starts its own.
|
|
45
|
+
def current
|
|
46
|
+
@mutex ||= Mutex.new
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@current = nil unless @pid == ::Process.pid
|
|
49
|
+
@pid = ::Process.pid
|
|
50
|
+
config = Railwatch::Server.config
|
|
51
|
+
@current ||= new(max_live: config.max_live_connections, max_proxied: config.max_proxied_requests,
|
|
52
|
+
first_byte_timeout: config.proxy_first_byte_timeout, idle_timeout: config.proxy_idle_timeout)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(max_live: 64, max_proxied: 64, first_byte_timeout: 120, idle_timeout: 60)
|
|
58
|
+
@max = { live: max_live, proxied: max_proxied }
|
|
59
|
+
@first_byte_timeout = first_byte_timeout
|
|
60
|
+
@idle_timeout = idle_timeout
|
|
61
|
+
@sides = {}
|
|
62
|
+
@pairs = []
|
|
63
|
+
@mutex = Mutex.new
|
|
64
|
+
@wake_r, @wake_w = IO.pipe
|
|
65
|
+
@thread = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def max(kind = :live) = @max.fetch(kind)
|
|
69
|
+
|
|
70
|
+
def size(kind = :live) = @mutex.synchronize { @pairs.count { _1.kind == kind } }
|
|
71
|
+
|
|
72
|
+
def full?(kind) = size(kind) >= @max.fetch(kind)
|
|
73
|
+
|
|
74
|
+
# Splices `a` and `b` until either closes. False when the live cap is
|
|
75
|
+
# reached; the caller still owns both sockets then.
|
|
76
|
+
def add(a, b)
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
return false if @pairs.count { _1.kind == :live } >= @max[:live]
|
|
79
|
+
|
|
80
|
+
register(:live, a, b)
|
|
81
|
+
end
|
|
82
|
+
wake
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# One proxied request: `request` (its head, and its body when small) and
|
|
87
|
+
# then `body` (an IO, read to its end) go to the server on `upstream`;
|
|
88
|
+
# the server's response goes to `client` with `headers` ([name, value]
|
|
89
|
+
# pairs: the app's Set-Cookie) added to its head; then all three close. The relay owns them
|
|
90
|
+
# from here. Never refuses: the proxy checks #full? first, so the cap
|
|
91
|
+
# can be passed by as many requests as are checked at the same moment.
|
|
92
|
+
def proxy(client, upstream, request:, body: nil, headers: [])
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
pair = register(:proxied, client, upstream)
|
|
95
|
+
pair.head = +"".b
|
|
96
|
+
pair.extra_headers = headers
|
|
97
|
+
pair.right.pending << request.b
|
|
98
|
+
pair.left.eof = body.nil?
|
|
99
|
+
if body
|
|
100
|
+
pair.left.input = body
|
|
101
|
+
@sides[body] = pair.left
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
wake
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def register(kind, a, b)
|
|
111
|
+
left = Side.new(a, nil, +"".b, false)
|
|
112
|
+
right = Side.new(b, left, +"".b, false)
|
|
113
|
+
left.peer = right
|
|
114
|
+
now = monotonic
|
|
115
|
+
pair = Pair.new(kind, left, right, now, now)
|
|
116
|
+
left.pair = right.pair = pair
|
|
117
|
+
@sides[a] = left
|
|
118
|
+
@sides[b] = right
|
|
119
|
+
@pairs << pair
|
|
120
|
+
start_thread
|
|
121
|
+
pair
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def start_thread
|
|
125
|
+
return if @thread&.alive?
|
|
126
|
+
|
|
127
|
+
@thread = Thread.new { run }
|
|
128
|
+
@thread.name = "railwatch-server-relay"
|
|
129
|
+
@thread.report_on_exception = false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def wake
|
|
133
|
+
@wake_w.write_nonblock(".", exception: false)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def run
|
|
137
|
+
loop do
|
|
138
|
+
readers, writers, timed = @mutex.synchronize do
|
|
139
|
+
sides = @pairs.flat_map { [ _1.left, _1.right ] }
|
|
140
|
+
[ sides.select { |s| !s.eof && s.peer.pending.bytesize < MAX_BUFFER }.map { |s| s.input || s.io },
|
|
141
|
+
sides.reject { |s| s.pending.empty? }.map(&:io),
|
|
142
|
+
@pairs.any? { _1.kind == :proxied } ]
|
|
143
|
+
end
|
|
144
|
+
readable, writable, = IO.select(readers + [ @wake_r ], writers, nil, timed ? 0.5 : 30)
|
|
145
|
+
if readable
|
|
146
|
+
@wake_r.read_nonblock(1024, exception: false) if readable.delete(@wake_r)
|
|
147
|
+
writable.each { |io| flush(io) }
|
|
148
|
+
readable.each { |io| pull(io) }
|
|
149
|
+
end
|
|
150
|
+
expire if timed
|
|
151
|
+
end
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
warn("[railwatch-server] relay stopped: #{e.class}: #{e.message}")
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
@sides.each_key { |io| io.close rescue nil }.clear
|
|
156
|
+
@pairs.clear
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pull(io)
|
|
161
|
+
side = @mutex.synchronize { @sides[io] }
|
|
162
|
+
return unless side
|
|
163
|
+
|
|
164
|
+
pair = side.pair
|
|
165
|
+
data = io.read_nonblock(CHUNK, exception: false)
|
|
166
|
+
case data
|
|
167
|
+
when :wait_readable, :wait_writable then nil
|
|
168
|
+
when nil
|
|
169
|
+
side.eof = true
|
|
170
|
+
if side.input
|
|
171
|
+
@mutex.synchronize { @sides.delete(io) }
|
|
172
|
+
io.close
|
|
173
|
+
side.input = nil
|
|
174
|
+
end
|
|
175
|
+
return fail_pair(pair, 502, "closed the connection without answering") if side.equal?(pair.right) && pair.head
|
|
176
|
+
|
|
177
|
+
close_if_done(side)
|
|
178
|
+
else
|
|
179
|
+
pair.active_at = monotonic
|
|
180
|
+
if side.equal?(pair.right) && pair.head
|
|
181
|
+
pair.head << data
|
|
182
|
+
split = pair.head.index("\r\n\r\n")
|
|
183
|
+
unless split
|
|
184
|
+
fail_pair(pair, 502, "sent a response head over #{MAX_HEAD} bytes") if pair.head.bytesize > MAX_HEAD
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
data = response_head(pair.head.byteslice(0, split), pair.extra_headers) + pair.head.byteslice((split + 4)..)
|
|
188
|
+
pair.head = nil
|
|
189
|
+
end
|
|
190
|
+
side.peer.pending << data
|
|
191
|
+
flush(side.peer.io)
|
|
192
|
+
end
|
|
193
|
+
rescue IOError, SystemCallError => e
|
|
194
|
+
side.equal?(pair.right) ? fail_pair(pair, 502, "dropped the connection (#{e.class})") : close_pair(pair)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def flush(io)
|
|
198
|
+
side = @mutex.synchronize { @sides[io] }
|
|
199
|
+
return unless side
|
|
200
|
+
return if side.pending.empty?
|
|
201
|
+
|
|
202
|
+
written = io.write_nonblock(side.pending, exception: false)
|
|
203
|
+
if written.is_a?(Integer)
|
|
204
|
+
side.pending = side.pending.byteslice(written..)
|
|
205
|
+
side.pair.active_at = monotonic
|
|
206
|
+
end
|
|
207
|
+
close_if_done(side)
|
|
208
|
+
rescue IOError, SystemCallError => e
|
|
209
|
+
side.equal?(side.pair.right) ? fail_pair(side.pair, 502, "dropped the connection (#{e.class})") : close_pair(side.pair)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Live: one side closed, what it sent is delivered to the other, then
|
|
213
|
+
# both go (a websocket's close frame is data, so nothing is cut short).
|
|
214
|
+
# Proxied: the server has finished its response and the browser has all
|
|
215
|
+
# of it.
|
|
216
|
+
def close_if_done(side)
|
|
217
|
+
pair = side.pair
|
|
218
|
+
done = if pair.kind == :live
|
|
219
|
+
(side.eof && side.peer.pending.empty?) || (side.peer.eof && side.pending.empty?)
|
|
220
|
+
else
|
|
221
|
+
pair.right.eof && pair.left.pending.empty?
|
|
222
|
+
end
|
|
223
|
+
close_pair(pair) if done
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The server's response head, for a connection that closes after it:
|
|
227
|
+
# the body is passed on as sent (its Content-Length or chunked framing
|
|
228
|
+
# included), so only the connection headers change.
|
|
229
|
+
def response_head(head, extra)
|
|
230
|
+
status, *fields = head.split("\r\n")
|
|
231
|
+
fields.reject! { |field| (HOP_BY_HOP + APP_OWNED).include?(field[/\A[^:]*/].strip.downcase) }
|
|
232
|
+
fields.concat(extra.map { |name, value| "#{name}: #{value}" })
|
|
233
|
+
"#{status}\r\n#{fields.map { "#{_1}\r\n" }.join}connection: close\r\n\r\n".b
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A proxied request the server failed: before the response started, the
|
|
237
|
+
# browser gets `status` with why, then the connection closes; after, it
|
|
238
|
+
# is cut short (a browser sees a truncated body as a failed load).
|
|
239
|
+
def fail_pair(pair, status, why)
|
|
240
|
+
return close_pair(pair) unless pair.kind == :proxied && pair.head && @mutex.synchronize { @pairs.include?(pair) }
|
|
241
|
+
|
|
242
|
+
message = "The Railwatch server #{why}. Its log is in the app's storage/railwatch/<env>/server.log.\n"
|
|
243
|
+
warn("[railwatch-server] dashboard request: #{status}: the server #{why}")
|
|
244
|
+
pair.head = nil
|
|
245
|
+
pair.active_at = monotonic
|
|
246
|
+
pair.right.eof = true
|
|
247
|
+
pair.right.pending.clear
|
|
248
|
+
pair.left.eof = true
|
|
249
|
+
if (input = pair.left.input)
|
|
250
|
+
@mutex.synchronize { @sides.delete(input) }
|
|
251
|
+
input.close rescue nil
|
|
252
|
+
pair.left.input = nil
|
|
253
|
+
end
|
|
254
|
+
pair.left.pending << "HTTP/1.1 #{status} #{REASONS[status]}\r\nContent-Type: text/plain; charset=utf-8\r\n" \
|
|
255
|
+
"Content-Length: #{message.bytesize}\r\nConnection: close\r\n\r\n#{message}"
|
|
256
|
+
flush(pair.left.io)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def expire
|
|
260
|
+
now = monotonic
|
|
261
|
+
@mutex.synchronize { @pairs.select { _1.kind == :proxied } }.each do |pair|
|
|
262
|
+
if pair.head && now - pair.started_at > @first_byte_timeout
|
|
263
|
+
fail_pair(pair, 504, "did not answer within #{@first_byte_timeout} s (proxy_first_byte_timeout)")
|
|
264
|
+
elsif !pair.head && now - pair.active_at > @idle_timeout
|
|
265
|
+
warn("[railwatch-server] dashboard request: nothing moved for #{@idle_timeout} s (proxy_idle_timeout); closing it")
|
|
266
|
+
close_pair(pair)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def close_pair(pair)
|
|
272
|
+
@mutex.synchronize do
|
|
273
|
+
@pairs.delete(pair)
|
|
274
|
+
[ pair.left, pair.right ].each do |side|
|
|
275
|
+
[ side.io, side.input ].compact.each do |io|
|
|
276
|
+
@sides.delete(io)
|
|
277
|
+
io.close rescue nil
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def monotonic = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Railwatch
|
|
6
|
+
module Server
|
|
7
|
+
# The server's own settings that come from the app: SMTP for alert and
|
|
8
|
+
# digest email, S3 backups, Google sign-in, retention, whether alerts are
|
|
9
|
+
# sent. Written to `<data dir>/server.env` (0600, replaced atomically on
|
|
10
|
+
# every start) and handed to the server as RAILWATCH_ENV_FILE, so secrets
|
|
11
|
+
# are neither on the server's command line nor in its environment, and
|
|
12
|
+
# never in a log: only the names of what was set are logged.
|
|
13
|
+
#
|
|
14
|
+
# Each comes from Railwatch::Server.config first, then from the app:
|
|
15
|
+
#
|
|
16
|
+
# smtp config.action_mailer.smtp_settings, when delivery_method is :smtp
|
|
17
|
+
# mail_from ActionMailer::Base.default[:from]
|
|
18
|
+
# backup credentials.railwatch_server.backup
|
|
19
|
+
# google credentials.railwatch_server.google
|
|
20
|
+
#
|
|
21
|
+
# Anything the server reads that has no setting here goes in
|
|
22
|
+
# config.server_env ("RAILWATCH_..." => value). The server's own process
|
|
23
|
+
# environment still wins over this file (its rule for env files).
|
|
24
|
+
class Settings
|
|
25
|
+
Invalid = Class.new(StandardError)
|
|
26
|
+
|
|
27
|
+
def initialize(config, rails: (defined?(::Rails) && ::Rails.respond_to?(:application) ? ::Rails.application : nil))
|
|
28
|
+
@config = config
|
|
29
|
+
@rails = rails
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# { "RAILWATCH_SMTP_ADDRESS" => "smtp.example.com", ... }, and which
|
|
33
|
+
# groups were set, for the log.
|
|
34
|
+
def env
|
|
35
|
+
out = {}
|
|
36
|
+
if (smtp = smtp_settings)
|
|
37
|
+
out.merge!(smtp_env(smtp))
|
|
38
|
+
end
|
|
39
|
+
if (from = mail_from)
|
|
40
|
+
out["RAILWATCH_SMTP_FROM"] = from
|
|
41
|
+
end
|
|
42
|
+
if (backup = backup_settings)
|
|
43
|
+
out.merge!(backup_env(backup))
|
|
44
|
+
end
|
|
45
|
+
if (google = google_settings)
|
|
46
|
+
out.merge!(google_env(google))
|
|
47
|
+
end
|
|
48
|
+
if (r = given(@config.retention))
|
|
49
|
+
out["RAILWATCH_DETAIL_RETENTION_DAYS"] = r[:detail_days] if r[:detail_days]
|
|
50
|
+
out["RAILWATCH_CORE_RETENTION_DAYS_MAX"] = r[:core_days_max] if r[:core_days_max]
|
|
51
|
+
end
|
|
52
|
+
out["RAILWATCH_ALERT_DELIVERY"] = @config.alert_delivery ? "on" : "off" unless @config.alert_delivery.nil?
|
|
53
|
+
out.merge!(@config.server_env.to_h.transform_keys(&:to_s))
|
|
54
|
+
out.transform_values { |v| v.to_s }.reject { |_, v| v.empty? }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def groups(env = self.env)
|
|
58
|
+
{
|
|
59
|
+
"smtp" => env.key?("RAILWATCH_SMTP_ADDRESS"),
|
|
60
|
+
"backup" => env.key?("RAILWATCH_BACKUP_S3_BUCKET"),
|
|
61
|
+
"google" => env.key?("RAILWATCH_GOOGLE_CLIENT_ID"),
|
|
62
|
+
"retention" => env.keys.any? { _1.include?("RETENTION") },
|
|
63
|
+
"alert_delivery" => env.key?("RAILWATCH_ALERT_DELIVERY")
|
|
64
|
+
}.select { |_, v| v }.keys + (@config.server_env.to_h.keys.map(&:to_s) - KNOWN)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Writes the file the server reads; returns its path.
|
|
68
|
+
def write!(path)
|
|
69
|
+
body = +"# Written by railwatch-server on every start from the app's settings. Do not edit.\n"
|
|
70
|
+
env.each do |key, value|
|
|
71
|
+
raise Invalid, "#{key}: not a variable name" unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
72
|
+
raise Invalid, "#{key}: a value cannot contain a line break" if value.match?(/[\r\n]/)
|
|
73
|
+
|
|
74
|
+
# The server strips one pair of matching quotes and keeps the rest
|
|
75
|
+
# verbatim (no escapes), so quoting keeps edge spaces and '#'.
|
|
76
|
+
body << "#{key}=\"#{value}\"\n"
|
|
77
|
+
end
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
79
|
+
tmp = "#{path}.tmp"
|
|
80
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(body) }
|
|
81
|
+
File.chmod(0o600, tmp)
|
|
82
|
+
File.rename(tmp, path)
|
|
83
|
+
path
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
KNOWN = %w[
|
|
89
|
+
RAILWATCH_SMTP_ADDRESS RAILWATCH_SMTP_PORT RAILWATCH_SMTP_SECURITY RAILWATCH_SMTP_USERNAME RAILWATCH_SMTP_PASSWORD
|
|
90
|
+
RAILWATCH_SMTP_FROM RAILWATCH_BACKUP_S3_ENDPOINT RAILWATCH_BACKUP_S3_BUCKET RAILWATCH_BACKUP_S3_ACCESS_KEY_ID
|
|
91
|
+
RAILWATCH_BACKUP_S3_SECRET_ACCESS_KEY RAILWATCH_BACKUP_S3_REGION RAILWATCH_BACKUP_S3_PREFIX
|
|
92
|
+
RAILWATCH_BACKUP_INTERVAL_SECONDS RAILWATCH_GOOGLE_CLIENT_ID RAILWATCH_GOOGLE_CLIENT_SECRET
|
|
93
|
+
RAILWATCH_DETAIL_RETENTION_DAYS RAILWATCH_CORE_RETENTION_DAYS_MAX RAILWATCH_ALERT_DELIVERY
|
|
94
|
+
].freeze
|
|
95
|
+
|
|
96
|
+
# nil: from the app. false or {}: off. A hash: that.
|
|
97
|
+
def given(value) = value.nil? ? nil : (value.respond_to?(:to_h) && value.to_h.any? ? symbolize(value) : false)
|
|
98
|
+
|
|
99
|
+
def smtp_settings
|
|
100
|
+
return given(@config.smtp) || nil unless @config.smtp.nil?
|
|
101
|
+
return unless @rails && defined?(::ActionMailer::Base)
|
|
102
|
+
return unless ::ActionMailer::Base.delivery_method == :smtp
|
|
103
|
+
|
|
104
|
+
settings = symbolize(::ActionMailer::Base.smtp_settings)
|
|
105
|
+
settings[:address] ? settings : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def mail_from
|
|
109
|
+
return @config.mail_from if @config.mail_from
|
|
110
|
+
return unless @rails && defined?(::ActionMailer::Base)
|
|
111
|
+
|
|
112
|
+
from = ::ActionMailer::Base.default[:from]
|
|
113
|
+
from.is_a?(String) ? from : nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def backup_settings
|
|
117
|
+
@config.backup.nil? ? credential(:backup) : given(@config.backup) || nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def google_settings
|
|
121
|
+
@config.google.nil? ? credential(:google) : given(@config.google) || nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def credential(name)
|
|
125
|
+
value = @rails&.credentials&.dig(:railwatch_server, name)
|
|
126
|
+
value ? symbolize(value) : nil
|
|
127
|
+
rescue StandardError
|
|
128
|
+
# Unreadable credentials (no master key on this machine) are the app's
|
|
129
|
+
# problem to report; the server runs without these settings.
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Action Mailer's names, as the mail gem reads them.
|
|
134
|
+
def smtp_env(s)
|
|
135
|
+
security = if s[:tls] || s[:ssl]
|
|
136
|
+
"tls"
|
|
137
|
+
elsif s[:enable_starttls] == false && s[:enable_starttls_auto] == false
|
|
138
|
+
"none"
|
|
139
|
+
else
|
|
140
|
+
"starttls"
|
|
141
|
+
end
|
|
142
|
+
{
|
|
143
|
+
"RAILWATCH_SMTP_ADDRESS" => s[:address],
|
|
144
|
+
"RAILWATCH_SMTP_PORT" => s[:port] || 25,
|
|
145
|
+
"RAILWATCH_SMTP_SECURITY" => security,
|
|
146
|
+
"RAILWATCH_SMTP_USERNAME" => s[:user_name],
|
|
147
|
+
"RAILWATCH_SMTP_PASSWORD" => s[:password]
|
|
148
|
+
}.compact
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def backup_env(b)
|
|
152
|
+
missing = %i[endpoint bucket access_key_id secret_access_key].reject { b[_1] }
|
|
153
|
+
raise Invalid, "backup needs #{missing.join(', ')} (Railwatch::Server.config.backup or credentials.railwatch_server.backup)" if missing.any?
|
|
154
|
+
|
|
155
|
+
{
|
|
156
|
+
"RAILWATCH_BACKUP_S3_ENDPOINT" => b[:endpoint],
|
|
157
|
+
"RAILWATCH_BACKUP_S3_BUCKET" => b[:bucket],
|
|
158
|
+
"RAILWATCH_BACKUP_S3_ACCESS_KEY_ID" => b[:access_key_id],
|
|
159
|
+
"RAILWATCH_BACKUP_S3_SECRET_ACCESS_KEY" => b[:secret_access_key],
|
|
160
|
+
"RAILWATCH_BACKUP_S3_REGION" => b[:region],
|
|
161
|
+
"RAILWATCH_BACKUP_S3_PREFIX" => b[:prefix],
|
|
162
|
+
"RAILWATCH_BACKUP_INTERVAL_SECONDS" => b[:interval_seconds]
|
|
163
|
+
}.compact
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def google_env(g)
|
|
167
|
+
raise Invalid, "google needs client_id and client_secret" unless g[:client_id] && g[:client_secret]
|
|
168
|
+
|
|
169
|
+
{ "RAILWATCH_GOOGLE_CLIENT_ID" => g[:client_id], "RAILWATCH_GOOGLE_CLIENT_SECRET" => g[:client_secret] }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def symbolize(hash) = hash.to_h.transform_keys(&:to_sym)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Server
|
|
5
|
+
VERSION = "0.1.0"
|
|
6
|
+
# The published railwatch-rs commit the bundled binaries are built from
|
|
7
|
+
# (README, "The server side"); the Corresponding Source under AGPL-3.0.
|
|
8
|
+
RAILWATCH_RS = "d4c842c2f02e1cd56aeedb1e9ad1403313f0aed8"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "railwatch"
|
|
5
|
+
require_relative "server/version"
|
|
6
|
+
require_relative "server/configuration"
|
|
7
|
+
require_relative "server/binary"
|
|
8
|
+
require_relative "server/proxy_auth"
|
|
9
|
+
require_relative "server/data_dir"
|
|
10
|
+
require_relative "server/settings"
|
|
11
|
+
require_relative "server/process"
|
|
12
|
+
require_relative "server/relay"
|
|
13
|
+
require_relative "server/proxy_handoff"
|
|
14
|
+
|
|
15
|
+
module Railwatch
|
|
16
|
+
# The Railwatch server (the AGPL Rust binary, bundled) run next to this
|
|
17
|
+
# app: started by Puma's master (`plugin :railwatch_server`) or
|
|
18
|
+
# `bin/rails railwatch_server:start`, never inside a Puma worker; the
|
|
19
|
+
# dashboard proxied at /railwatch behind the app's own gate; the railwatch
|
|
20
|
+
# agent pointed at it.
|
|
21
|
+
module Server
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def config
|
|
26
|
+
@config ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure
|
|
30
|
+
yield config
|
|
31
|
+
config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def data_dir
|
|
35
|
+
DataDir.new(config.data_path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enabled?
|
|
39
|
+
config.enabled
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the app reports somewhere of its own choosing, in which case
|
|
43
|
+
# the agent is left alone: a token set (RAILWATCH_TOKEN or the
|
|
44
|
+
# initializer), or the embedded store (`transport = :local`).
|
|
45
|
+
def agent_configured_elsewhere?
|
|
46
|
+
return true if Railwatch.config.local?
|
|
47
|
+
return false if Railwatch.config.ingest_url == config.local_url
|
|
48
|
+
|
|
49
|
+
Railwatch.config.token.to_s.strip != ""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether this process starts the server, the only kind that may create
|
|
53
|
+
# the data dir: Puma with `plugin :railwatch_server` (its master and
|
|
54
|
+
# workers; under a bare `puma` the plugin is loaded before the app),
|
|
55
|
+
# `bin/rails server` (which loads the app before Puma reads its config),
|
|
56
|
+
# and `bin/rails railwatch_server:start`. Every other process (a job
|
|
57
|
+
# worker, a console, `assets:precompile` in a Dockerfile) only reads
|
|
58
|
+
# what the server's process wrote: provisioning in an asset build put
|
|
59
|
+
# the token and the dashboard's HMAC key in the image, shared by every
|
|
60
|
+
# volume made from it.
|
|
61
|
+
def serving?
|
|
62
|
+
return true if defined?(::Rails::Server)
|
|
63
|
+
return true if $LOADED_FEATURES.any? { _1.end_with?("/puma/plugin/railwatch_server.rb") }
|
|
64
|
+
|
|
65
|
+
rake_tasks.include?("railwatch_server:start")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# An image build (`SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile`),
|
|
69
|
+
# or the database step a container runs before its server (Rails'
|
|
70
|
+
# docker-entrypoint: `bin/rails db:prepare`, then `rails server`, which
|
|
71
|
+
# provisions), where an unprovisioned data dir is expected and not
|
|
72
|
+
# worth a warning: on a fresh volume it was logged right before the
|
|
73
|
+
# server's process provisioned it.
|
|
74
|
+
def building?
|
|
75
|
+
ENV["SECRET_KEY_BASE_DUMMY"].to_s.strip != "" || rake_tasks.any? { _1.start_with?("assets:", "db:") }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def rake_tasks
|
|
79
|
+
defined?(::Rake.application) ? ::Rake.application.top_level_tasks : []
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Why this process could not provision the data dir (the dashboard
|
|
83
|
+
# says so), or nil.
|
|
84
|
+
attr_reader :provision_error
|
|
85
|
+
|
|
86
|
+
# Once per data dir, before the first server starts: `railwatch setup`
|
|
87
|
+
# and the proxy secret. Cheap after the first time (two files exist).
|
|
88
|
+
# Only from a process that starts the server (serving?).
|
|
89
|
+
#
|
|
90
|
+
# Tried once per process: under `bin/rails server` the Railtie and then
|
|
91
|
+
# the Puma plugin both get here before Puma listens, and a setup that
|
|
92
|
+
# failed or hit setup_timeout must not hold up the boot twice. The next
|
|
93
|
+
# start tries again.
|
|
94
|
+
def provision!(log: ->(m) { warn("[railwatch-server] #{m}") })
|
|
95
|
+
dir = data_dir
|
|
96
|
+
return dir if dir.provisioned?
|
|
97
|
+
raise @provision_error if @provision_error
|
|
98
|
+
|
|
99
|
+
app = defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : "App"
|
|
100
|
+
env = config.environment
|
|
101
|
+
# setup needs none of the app's settings (server.env is written at
|
|
102
|
+
# the first start, after this).
|
|
103
|
+
server_env = Process.new(config: config, data_dir: dir, binary: Binary.path, log: log).env.merge("RAILWATCH_ENV_FILE" => File::NULL)
|
|
104
|
+
done = dir.provision!(binary: Binary.path, env: server_env, app_name: app, env_name: env, timeout: config.setup_timeout)
|
|
105
|
+
log.call("provisioned #{dir.path}: #{done.join(', ')}") if done.any?
|
|
106
|
+
dir
|
|
107
|
+
rescue Error => e
|
|
108
|
+
@provision_error = e
|
|
109
|
+
raise
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The railwatch agent's settings for the bundled server. Called from
|
|
113
|
+
# the Railtie after the app's initializers have run.
|
|
114
|
+
def wire_agent!
|
|
115
|
+
return :disabled unless enabled? && config.wire_agent
|
|
116
|
+
return :configured_elsewhere if agent_configured_elsewhere?
|
|
117
|
+
|
|
118
|
+
dir = serving? ? provision! : data_dir
|
|
119
|
+
return building? ? :building : :not_provisioned unless dir.provisioned?
|
|
120
|
+
|
|
121
|
+
held = dir.environment
|
|
122
|
+
if held && held != config.environment
|
|
123
|
+
raise Error, "#{dir.path} holds the #{held} environment, and this is #{config.environment}: the agent is not wired, " \
|
|
124
|
+
"so #{config.environment}'s telemetry does not land in #{held}'s. One data dir per environment: " \
|
|
125
|
+
"unset RAILWATCH_SERVER_DATA_DIR (the default is storage/railwatch/<env>) or point it elsewhere"
|
|
126
|
+
end
|
|
127
|
+
token = dir.token
|
|
128
|
+
Railwatch.config.token = token
|
|
129
|
+
Railwatch.config.ingest_url = config.local_url
|
|
130
|
+
:wired
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The foreground supervisor for setups without Puma (Procfile,
|
|
134
|
+
# systemd, a container's second process): runs until TERM/INT.
|
|
135
|
+
def run!(log: ->(m) { $stdout.puts("[railwatch-server] #{m}"); $stdout.flush })
|
|
136
|
+
provision!(log: log)
|
|
137
|
+
server = Process.new(config: config, data_dir: data_dir, binary: Binary.path, log: log)
|
|
138
|
+
stopping = false
|
|
139
|
+
%w[TERM INT].each { |sig| trap(sig) { stopping = true } }
|
|
140
|
+
begin
|
|
141
|
+
loop do
|
|
142
|
+
sleep 0.5
|
|
143
|
+
# Checked after the sleep: a TERM during it must not start a
|
|
144
|
+
# server on the way out.
|
|
145
|
+
break if stopping
|
|
146
|
+
|
|
147
|
+
server.ensure_running
|
|
148
|
+
end
|
|
149
|
+
ensure
|
|
150
|
+
server.stop
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
require_relative "server/engine" if defined?(Rails::Engine)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :railwatch_server do
|
|
4
|
+
desc "Run the bundled Railwatch server in the foreground (for a Procfile or a supervisor; Puma users add `plugin :railwatch_server`)"
|
|
5
|
+
task start: :environment do
|
|
6
|
+
Railwatch::Server.run!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
desc "Whether the bundled Railwatch server is running and the agent reports to it"
|
|
10
|
+
task status: :environment do
|
|
11
|
+
config = Railwatch::Server.config
|
|
12
|
+
dir = Railwatch::Server.data_dir
|
|
13
|
+
up = begin
|
|
14
|
+
Net::HTTP.get_response(URI("#{config.local_url}/up")).is_a?(Net::HTTPSuccess)
|
|
15
|
+
rescue SystemCallError, IOError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
pid = dir.path.join("server.pid")
|
|
19
|
+
binary = begin
|
|
20
|
+
Railwatch::Server::Binary.path
|
|
21
|
+
rescue Railwatch::Server::Binary::NotFound => e
|
|
22
|
+
"MISSING: #{e.message.lines.first.strip}"
|
|
23
|
+
end
|
|
24
|
+
puts "binary #{binary}"
|
|
25
|
+
puts "data dir #{dir.path}#{' (not provisioned)' unless dir.provisioned?}"
|
|
26
|
+
puts "server #{up ? 'UP' : 'DOWN'} on #{config.local_url}#{" (pid #{pid.read.split.first})" if pid.exist?}"
|
|
27
|
+
puts "dashboard #{config.normalized_mount_path}"
|
|
28
|
+
puts "agent " + (Railwatch::Server.agent_configured_elsewhere? ? "configured elsewhere (#{Railwatch.config.local? ? 'transport :local' : Railwatch.config.ingest_url})" :
|
|
29
|
+
"reports to #{Railwatch.config.ingest_url}")
|
|
30
|
+
puts "log #{dir.log_file}"
|
|
31
|
+
exit(up ? 0 : 1)
|
|
32
|
+
end
|
|
33
|
+
end
|