sitegoose 0.3.3
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 +21 -0
- data/README.md +342 -0
- data/lib/sitegoose/analytics.rb +113 -0
- data/lib/sitegoose/challenge.rb +177 -0
- data/lib/sitegoose/core.rb +523 -0
- data/lib/sitegoose/crawlers.rb +195 -0
- data/lib/sitegoose/errors.rb +456 -0
- data/lib/sitegoose/firewall.rb +417 -0
- data/lib/sitegoose/inventory.rb +131 -0
- data/lib/sitegoose/ip.rb +159 -0
- data/lib/sitegoose/middleware.rb +52 -0
- data/lib/sitegoose/performance.rb +114 -0
- data/lib/sitegoose/platform.rb +557 -0
- data/lib/sitegoose/posture.rb +272 -0
- data/lib/sitegoose/proxy.rb +260 -0
- data/lib/sitegoose/queries.rb +399 -0
- data/lib/sitegoose/query_text.rb +215 -0
- data/lib/sitegoose/railtie.rb +124 -0
- data/lib/sitegoose/request_rules.rb +390 -0
- data/lib/sitegoose/resources.rb +449 -0
- data/lib/sitegoose/sidekiq.rb +95 -0
- data/lib/sitegoose/small_modules.rb +250 -0
- data/lib/sitegoose/version.rb +5 -0
- data/lib/sitegoose.rb +261 -0
- metadata +92 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What every module shares: the site's credentials, sending to the
|
|
4
|
+
# collector, logging, background jobs, and the dashboard's module switches
|
|
5
|
+
# (Site settings -> Server, GET /events/platform/config about once a minute).
|
|
6
|
+
#
|
|
7
|
+
# A module the dashboard turns off stays loaded but does nothing; until the
|
|
8
|
+
# switches arrive, or when they can't be fetched, every loaded module stays on.
|
|
9
|
+
# Sending never raises into the app. Background jobs are threads that start
|
|
10
|
+
# again in a forked worker (Puma, Unicorn, Passenger), where threads don't
|
|
11
|
+
# survive: through Process._fork (Ruby 3.1+), and, for Ruby 3.0, by the
|
|
12
|
+
# middleware noticing the process ID changed.
|
|
13
|
+
|
|
14
|
+
require "json"
|
|
15
|
+
require "net/http"
|
|
16
|
+
require "openssl"
|
|
17
|
+
require "socket"
|
|
18
|
+
require "uri"
|
|
19
|
+
require "etc"
|
|
20
|
+
require "time"
|
|
21
|
+
|
|
22
|
+
module SiteGoose
|
|
23
|
+
TIMEOUT = 5
|
|
24
|
+
MIN_SYNC = 30
|
|
25
|
+
RETRY = 60
|
|
26
|
+
SESSION_HEADER = "x-analytics-session"
|
|
27
|
+
RUNTIME = "Ruby #{RUBY_VERSION}"
|
|
28
|
+
|
|
29
|
+
# A string as UTF-8 that JSON can carry: anything else (binary, a broken
|
|
30
|
+
# encoding) is mended rather than raising on the way out. max: characters.
|
|
31
|
+
def self.text(value, max = nil)
|
|
32
|
+
s = value.nil? ? +"" : value.to_s
|
|
33
|
+
s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
|
|
34
|
+
s = s.scrub("�") unless s.valid_encoding?
|
|
35
|
+
max ? s[0, max] : s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.iso_now(at = nil)
|
|
39
|
+
(at || Time.now).utc.iso8601(3)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.clock
|
|
43
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# At exit (and Sidekiq's shutdown) everything waiting gets one overall
|
|
47
|
+
# deadline, so the package never outlasts a container's or PM2's grace
|
|
48
|
+
# period: each send waits at most what's left, none starts after it, and
|
|
49
|
+
# what wasn't sent waits in the errors file.
|
|
50
|
+
EXIT_DEADLINE = 5
|
|
51
|
+
@deadline = nil
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# Runs the block with every send (and job stop) inside seconds from now.
|
|
55
|
+
def within(seconds = EXIT_DEADLINE)
|
|
56
|
+
before = @deadline
|
|
57
|
+
ends = clock + seconds
|
|
58
|
+
@deadline = before ? [before, ends].min : ends
|
|
59
|
+
yield
|
|
60
|
+
ensure
|
|
61
|
+
@deadline = before
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Seconds left before the deadline, or nil when there is none.
|
|
65
|
+
def time_left
|
|
66
|
+
ends = @deadline
|
|
67
|
+
ends ? [ends - clock, 0.0].max : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# How long one send may wait: normal, cut to what's left of a deadline,
|
|
71
|
+
# nil once it has passed (nothing more is sent).
|
|
72
|
+
def send_timeout(normal)
|
|
73
|
+
left = time_left
|
|
74
|
+
return normal if left.nil?
|
|
75
|
+
return nil if left < 0.05
|
|
76
|
+
|
|
77
|
+
[normal, left].min
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A header from a hash with lowercase names ("" when missing).
|
|
82
|
+
def self.header(headers, name)
|
|
83
|
+
value = headers && headers[name.downcase]
|
|
84
|
+
value.is_a?(String) ? value.strip : ""
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.hostname
|
|
88
|
+
Socket.gethostname.to_s
|
|
89
|
+
rescue StandardError
|
|
90
|
+
""
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Options given as a hash with string or symbol keys -> symbol keys.
|
|
94
|
+
def self.symbolize(options)
|
|
95
|
+
return {} unless options.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
options.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
Response = Struct.new(:status, :headers, :body) do
|
|
101
|
+
def ok?
|
|
102
|
+
status >= 200 && status < 300
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def json
|
|
106
|
+
JSON.parse(body.to_s.empty? ? "null" : body)
|
|
107
|
+
rescue JSON::ParserError, TypeError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Fork safety. Two things go wrong when a process forks while a background
|
|
113
|
+
# thread works: the thread is gone in the child, and a send in flight there
|
|
114
|
+
# may hold a lock the child then waits on forever. So a fork waits (at most
|
|
115
|
+
# a send's timeout) for the sends in flight, a send waits for a fork, and
|
|
116
|
+
# in the child every background job starts again.
|
|
117
|
+
module Forking
|
|
118
|
+
@mutex = Mutex.new
|
|
119
|
+
@cond = ConditionVariable.new
|
|
120
|
+
@in_flight = 0
|
|
121
|
+
@forking = false
|
|
122
|
+
@pid = Process.pid
|
|
123
|
+
@children = []
|
|
124
|
+
CHECK_LOCK = Mutex.new
|
|
125
|
+
|
|
126
|
+
class << self
|
|
127
|
+
attr_reader :pid
|
|
128
|
+
|
|
129
|
+
def register(item)
|
|
130
|
+
@mutex.synchronize { @children << item }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unregister(item)
|
|
134
|
+
@mutex.synchronize { @children.delete(item) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def sending
|
|
138
|
+
deadline = SiteGoose.clock + TIMEOUT
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
while @forking && (left = deadline - SiteGoose.clock) > 0
|
|
141
|
+
@cond.wait(@mutex, left)
|
|
142
|
+
end
|
|
143
|
+
@in_flight += 1
|
|
144
|
+
end
|
|
145
|
+
begin
|
|
146
|
+
yield
|
|
147
|
+
ensure
|
|
148
|
+
@mutex.synchronize do
|
|
149
|
+
@in_flight -= 1 if @in_flight.positive?
|
|
150
|
+
@cond.broadcast
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def before_fork
|
|
156
|
+
deadline = SiteGoose.clock + TIMEOUT + 1
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
@forking = true
|
|
159
|
+
while @in_flight.positive? && (left = deadline - SiteGoose.clock) > 0
|
|
160
|
+
@cond.wait(@mutex, left)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
rescue StandardError, ThreadError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def after_fork_in_parent
|
|
168
|
+
@mutex.synchronize do
|
|
169
|
+
@forking = false
|
|
170
|
+
@cond.broadcast
|
|
171
|
+
end
|
|
172
|
+
@mutex.synchronize { @children.dup }.each { |c| c.respond_to?(:after_fork_in_parent) && c.after_fork_in_parent }
|
|
173
|
+
rescue StandardError, ThreadError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# In the child (or, on Ruby 3.0, the first time a request sees a new
|
|
178
|
+
# process ID): fresh locks, and every job started again.
|
|
179
|
+
def after_fork_in_child
|
|
180
|
+
@mutex = Mutex.new
|
|
181
|
+
@cond = ConditionVariable.new
|
|
182
|
+
@in_flight = 0
|
|
183
|
+
@forking = false
|
|
184
|
+
@children.dup.each do |child|
|
|
185
|
+
child.after_fork_in_child
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
# Last, so a request that checks meanwhile waits for the restart
|
|
190
|
+
# rather than running on the parent's queues.
|
|
191
|
+
@pid = Process.pid
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The Ruby 3.0 path, and a safety net on every other: a fork that went
|
|
195
|
+
# around Process._fork. Under a lock, so two requests arriving at a new
|
|
196
|
+
# worker at once restart its threads once. (A thread that held it at
|
|
197
|
+
# the fork doesn't exist in the child, and Ruby releases what a thread
|
|
198
|
+
# left behind there.)
|
|
199
|
+
def check
|
|
200
|
+
return if @pid == Process.pid
|
|
201
|
+
|
|
202
|
+
CHECK_LOCK.synchronize { after_fork_in_child if @pid != Process.pid }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Process._fork is what fork, Process.fork and Kernel#fork all call (Ruby 3.1+).
|
|
207
|
+
module Hook
|
|
208
|
+
def _fork
|
|
209
|
+
Forking.before_fork
|
|
210
|
+
begin
|
|
211
|
+
pid = super
|
|
212
|
+
rescue Exception # rubocop:disable Lint/RescueException -- raised on
|
|
213
|
+
# No child (EAGAIN, out of memory): sends must not keep waiting for one.
|
|
214
|
+
Forking.after_fork_in_parent
|
|
215
|
+
raise
|
|
216
|
+
end
|
|
217
|
+
if pid.zero?
|
|
218
|
+
Forking.after_fork_in_child
|
|
219
|
+
else
|
|
220
|
+
Forking.after_fork_in_parent
|
|
221
|
+
end
|
|
222
|
+
pid
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
Process.singleton_class.prepend(Hook) if Process.respond_to?(:_fork)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Runs the block in a background thread; the block returns the seconds
|
|
230
|
+
# until its next run. Never lets the job die, and starts again after a fork.
|
|
231
|
+
class Periodic
|
|
232
|
+
@running = []
|
|
233
|
+
@lock = Mutex.new
|
|
234
|
+
|
|
235
|
+
class << self
|
|
236
|
+
def running
|
|
237
|
+
@lock.synchronize { @running.dup }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def add(job)
|
|
241
|
+
@lock.synchronize { @running << job }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def remove(job)
|
|
245
|
+
@lock.synchronize { @running.delete(job) }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
attr_reader :thread
|
|
250
|
+
|
|
251
|
+
def initialize(first = 0, &job)
|
|
252
|
+
@job = job
|
|
253
|
+
@first = first
|
|
254
|
+
@stopped = false
|
|
255
|
+
start
|
|
256
|
+
Periodic.add(self)
|
|
257
|
+
Forking.register(self)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def start
|
|
261
|
+
return if @stopped
|
|
262
|
+
|
|
263
|
+
@mutex = Mutex.new
|
|
264
|
+
@cond = ConditionVariable.new
|
|
265
|
+
@wake = false
|
|
266
|
+
@thread = Thread.new { run }
|
|
267
|
+
@thread.name = "analytics-platform" if @thread.respond_to?(:name=)
|
|
268
|
+
@thread.report_on_exception = false
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def stopped?
|
|
272
|
+
@stopped
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def run
|
|
276
|
+
wait = @first
|
|
277
|
+
until @stopped
|
|
278
|
+
@mutex.synchronize do
|
|
279
|
+
@cond.wait(@mutex, wait) if !@wake && !@stopped && wait.positive?
|
|
280
|
+
@wake = false
|
|
281
|
+
end
|
|
282
|
+
break if @stopped
|
|
283
|
+
|
|
284
|
+
wait = begin
|
|
285
|
+
@job.call
|
|
286
|
+
rescue SignalException, SystemExit
|
|
287
|
+
break
|
|
288
|
+
rescue Exception # rubocop:disable Lint/RescueException -- a job never dies
|
|
289
|
+
RETRY
|
|
290
|
+
end
|
|
291
|
+
wait = RETRY unless wait.is_a?(Numeric) && wait >= 0
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def run_now
|
|
296
|
+
@mutex.synchronize do
|
|
297
|
+
@wake = true
|
|
298
|
+
@cond.signal
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Stops the job and waits (up to wait seconds) for its thread to end.
|
|
303
|
+
def stop(wait = 2.0)
|
|
304
|
+
@stopped = true
|
|
305
|
+
begin
|
|
306
|
+
@mutex.synchronize do
|
|
307
|
+
@wake = true
|
|
308
|
+
@cond.signal
|
|
309
|
+
end
|
|
310
|
+
rescue ThreadError
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
Periodic.remove(self)
|
|
314
|
+
Forking.unregister(self)
|
|
315
|
+
thread = @thread
|
|
316
|
+
left = SiteGoose.time_left
|
|
317
|
+
wait = [wait, left].min if wait && left
|
|
318
|
+
thread.join(wait) if wait && wait.positive? && thread&.alive? && thread != Thread.current
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def after_fork_in_child
|
|
322
|
+
start
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# This process's resident memory in MB (Linux /proc), or nil.
|
|
327
|
+
def self.rss_mb
|
|
328
|
+
statm = File.read("/proc/self/statm")
|
|
329
|
+
pages = statm.split[1].to_i
|
|
330
|
+
(pages * page_size / 1_048_576.0).round
|
|
331
|
+
rescue StandardError
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def self.page_size
|
|
336
|
+
@page_size ||= begin
|
|
337
|
+
Etc.sysconf(Etc::SC_PAGESIZE)
|
|
338
|
+
rescue StandardError
|
|
339
|
+
4096
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
class Core
|
|
344
|
+
attr_reader :site_id, :secret, :endpoint, :server_name, :user_agent
|
|
345
|
+
|
|
346
|
+
def initialize(site_id, secret, endpoint, debug: false, server_name: nil, logger: nil)
|
|
347
|
+
@site_id = site_id.to_s
|
|
348
|
+
# The name this service checks in under (X-Platform-App, on the switches
|
|
349
|
+
# request): server_name, as every module sends it, else the machine's.
|
|
350
|
+
@server_name = SiteGoose.text(server_name || SiteGoose.hostname, 100)
|
|
351
|
+
@started = Time.now.to_f
|
|
352
|
+
@secret = secret.to_s
|
|
353
|
+
@endpoint = endpoint.to_s.sub(%r{/+\z}, "")
|
|
354
|
+
@valid = @site_id.match?(/\A[a-fA-F0-9]{24}\z/) && !@secret.empty? && !@endpoint.empty?
|
|
355
|
+
@debug = debug ? true : false
|
|
356
|
+
@logger = logger
|
|
357
|
+
@user_agent = "analytics-platform-ruby/#{VERSION}"
|
|
358
|
+
@switches = {}
|
|
359
|
+
@headers = []
|
|
360
|
+
@flags = {}
|
|
361
|
+
@maintenance = { "on" => false }
|
|
362
|
+
@etag = ""
|
|
363
|
+
@loaded = []
|
|
364
|
+
@job = nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def valid?
|
|
368
|
+
@valid
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def log(mod, *args)
|
|
372
|
+
return unless @debug
|
|
373
|
+
|
|
374
|
+
line = "[analytics-platform:#{mod}] #{args.join(' ')}"
|
|
375
|
+
@logger ? @logger.warn(line) : warn(line)
|
|
376
|
+
rescue StandardError
|
|
377
|
+
nil
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# JSON to the collector. auth :body puts site_id and sitesecret in the
|
|
381
|
+
# body (the event endpoints), :bearer sends the secret as a header
|
|
382
|
+
# (firewall, switches). Returns a Response, or nil when it couldn't be sent.
|
|
383
|
+
def request(mod, path, method: "POST", body: nil, auth: :body, headers: nil)
|
|
384
|
+
return nil unless @valid
|
|
385
|
+
|
|
386
|
+
uri = URI.parse(@endpoint + path)
|
|
387
|
+
all = { "User-Agent" => @user_agent }
|
|
388
|
+
data = nil
|
|
389
|
+
unless body.nil?
|
|
390
|
+
body = body.merge("site_id" => @site_id, "sitesecret" => @secret) if auth == :body
|
|
391
|
+
data = JSON.generate(body)
|
|
392
|
+
all["Content-Type"] = "application/json"
|
|
393
|
+
end
|
|
394
|
+
all["Authorization"] = "Bearer #{@secret}" if auth == :bearer
|
|
395
|
+
all.merge!(headers) if headers
|
|
396
|
+
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
|
|
397
|
+
req = klass.new(uri.request_uri, all)
|
|
398
|
+
req.body = data if data
|
|
399
|
+
wait = SiteGoose.send_timeout(TIMEOUT)
|
|
400
|
+
return nil if wait.nil?
|
|
401
|
+
|
|
402
|
+
Forking.sending do
|
|
403
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
404
|
+
http.use_ssl = uri.scheme == "https"
|
|
405
|
+
http.open_timeout = wait
|
|
406
|
+
http.read_timeout = wait
|
|
407
|
+
http.write_timeout = wait if http.respond_to?(:write_timeout=)
|
|
408
|
+
res = http.start { |h| h.request(req) }
|
|
409
|
+
status = res.code.to_i
|
|
410
|
+
log(mod, "collector answered #{status} to #{path}") if status >= 400
|
|
411
|
+
Response.new(status, res.each_header.to_h { |k, v| [k.downcase, v] }, res.body.to_s)
|
|
412
|
+
end
|
|
413
|
+
rescue StandardError, Timeout::Error => e
|
|
414
|
+
log(mod, "could not reach the collector (#{path}): #{e.class}: #{e.message}")
|
|
415
|
+
nil
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# ---- Module switches ----
|
|
419
|
+
|
|
420
|
+
# False only when the dashboard turned the module off.
|
|
421
|
+
def on?(name)
|
|
422
|
+
@switches[name.to_s] != false
|
|
423
|
+
end
|
|
424
|
+
alias is_on on?
|
|
425
|
+
|
|
426
|
+
# This service's check-in, form-encoded so any name is plain ASCII:
|
|
427
|
+
# Health -> All systems shows it running and when it was last heard from.
|
|
428
|
+
# nil when it can't be built: the switches still sync, without it.
|
|
429
|
+
def check_in
|
|
430
|
+
return nil if @server_name.empty?
|
|
431
|
+
|
|
432
|
+
fields = [["server", @server_name], ["kind", "process"], ["pid", Process.pid.to_s],
|
|
433
|
+
["up_s", (Time.now.to_f - @started).to_i.to_s], ["runtime", RUNTIME]]
|
|
434
|
+
rss = SiteGoose.rss_mb
|
|
435
|
+
fields << ["rss_mb", rss.to_s] if rss
|
|
436
|
+
URI.encode_www_form(fields)
|
|
437
|
+
rescue StandardError
|
|
438
|
+
nil
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
HEADER_NAME = /\A[A-Za-z][A-Za-z0-9-]{0,63}\z/.freeze
|
|
442
|
+
|
|
443
|
+
def sync_switches
|
|
444
|
+
headers = { "X-Platform-Modules" => @loaded.join(",") }
|
|
445
|
+
app = check_in
|
|
446
|
+
headers["X-Platform-App"] = app if app
|
|
447
|
+
headers["If-None-Match"] = @etag unless @etag.empty?
|
|
448
|
+
res = request("core", "/events/platform/config?site_id=#{@site_id}", method: "GET", auth: :bearer, headers: headers)
|
|
449
|
+
return RETRY unless res && res.status == 200
|
|
450
|
+
|
|
451
|
+
data = res.json
|
|
452
|
+
data = {} unless data.is_a?(Hash)
|
|
453
|
+
if data["v"] == 1 && data["modules"].is_a?(Hash)
|
|
454
|
+
@switches = data["modules"]
|
|
455
|
+
@flags = data["flags"].is_a?(Hash) ? data["flags"] : {}
|
|
456
|
+
@maintenance = data["maintenance"].is_a?(Hash) ? data["maintenance"] : { "on" => false }
|
|
457
|
+
# Only well-formed headers (no line breaks can get through).
|
|
458
|
+
@headers = (data["headers"].is_a?(Array) ? data["headers"] : []).select do |pair|
|
|
459
|
+
pair.is_a?(Array) && pair.size == 2 && pair[0].is_a?(String) && pair[0].match?(HEADER_NAME) &&
|
|
460
|
+
pair[1].is_a?(String) && !pair[1].include?("\r") && !pair[1].include?("\n") && pair[1].size <= 4000
|
|
461
|
+
end
|
|
462
|
+
@etag = res.headers["etag"].to_s
|
|
463
|
+
end
|
|
464
|
+
[MIN_SYNC, (data["sync"].is_a?(Numeric) && data["sync"].positive? ? data["sync"].to_i : RETRY)].max
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def flags
|
|
468
|
+
@flags
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def maintenance
|
|
472
|
+
@maintenance
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# The security headers picked on the dashboard, when the module is on.
|
|
476
|
+
def security_headers
|
|
477
|
+
on?("headers") ? @headers.dup : []
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Starts fetching the switches; names are the modules the code loaded.
|
|
481
|
+
def start(names)
|
|
482
|
+
@loaded = names.map(&:to_s)
|
|
483
|
+
@job = Periodic.new { sync_switches } if @valid
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def stop
|
|
487
|
+
@job&.stop
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# For tests: what the dashboard said.
|
|
491
|
+
def switches=(value)
|
|
492
|
+
@switches = value
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# The characters a path may carry unencoded (RFC 3986).
|
|
497
|
+
PATH_SAFE = %r{[^A-Za-z0-9/:@!$&'()*+,;=\-._~%]}.freeze
|
|
498
|
+
|
|
499
|
+
# The request target as the visitor sent it, query string and all, which
|
|
500
|
+
# is what the request rules match (sdk/protocols/firewall.md). Rack's
|
|
501
|
+
# PATH_INFO is not decoded, and Puma, Unicorn and WEBrick keep the target
|
|
502
|
+
# itself as REQUEST_URI, so it is taken as it came; an absolute-form target
|
|
503
|
+
# (a proxy request) loses its scheme and host.
|
|
504
|
+
def self.request_target(env)
|
|
505
|
+
raw = env["REQUEST_URI"]
|
|
506
|
+
if raw.is_a?(String) && !raw.empty?
|
|
507
|
+
target = raw.sub(%r{\A[a-zA-Z][a-zA-Z0-9+.-]*://[^/?#]*}, "")
|
|
508
|
+
target = "/#{target}" unless target.start_with?("/") || target.start_with?("*")
|
|
509
|
+
else
|
|
510
|
+
target = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
511
|
+
target = target.b.gsub(PATH_SAFE) { |c| format("%%%02X", c.ord) }
|
|
512
|
+
end
|
|
513
|
+
query = env["QUERY_STRING"].to_s
|
|
514
|
+
target = "#{target}?#{query}" if !query.empty? && !target.include?("?")
|
|
515
|
+
text(target)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# A path with its percent-escapes decoded once, as text (for display and
|
|
519
|
+
# route names, never for the rules).
|
|
520
|
+
def self.decode_path(path)
|
|
521
|
+
text(path.to_s.b.gsub(/%[0-9a-fA-F]{2}/) { |m| m[1, 2].to_i(16).chr })
|
|
522
|
+
end
|
|
523
|
+
end
|