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,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# How the server is set up (protocol in sdk/protocols/posture.md): the
|
|
4
|
+
# settings that leave the app open (Rails', when it's a Rails app), whether it
|
|
5
|
+
# runs as root, code anyone on the machine can write, and files in the folders
|
|
6
|
+
# it serves to the web that should never be public. Sent to POST
|
|
7
|
+
# /events/posture a few minutes after start and then once a day (the
|
|
8
|
+
# collector's `every`), so Security -> Security check can say what to change.
|
|
9
|
+
#
|
|
10
|
+
# It only reads: nothing here changes a setting, a permission or a file, and
|
|
11
|
+
# no file's contents are sent -- paths, sizes, times and short values only.
|
|
12
|
+
# The walk is bounded and runs in a background thread, never in a request;
|
|
13
|
+
# with no folder served to the web there is nothing to walk and only the
|
|
14
|
+
# settings are reported.
|
|
15
|
+
|
|
16
|
+
module SiteGoose
|
|
17
|
+
class Posture
|
|
18
|
+
FIRST = 180
|
|
19
|
+
RETRY_EVERY = 60 * 60
|
|
20
|
+
EVERY = 24 * 60 * 60
|
|
21
|
+
MAX_CHECKS = 200
|
|
22
|
+
MAX_EXPOSURE = 50
|
|
23
|
+
MAX_ENTRIES = 20_000
|
|
24
|
+
MAX_DEPTH = 6
|
|
25
|
+
MAX_PERM_CHECKS = 3000
|
|
26
|
+
SKIP_DIRS = %w[.git .svn .hg node_modules vendor cache .cache .history __pycache__].freeze
|
|
27
|
+
VCS_DIRS = %w[.git .svn .hg .bzr].freeze
|
|
28
|
+
CODE_FILE = /\.(py|pyw|js|cjs|mjs|php[0-9]?|phtml|rb|erb|pl|sh)\z/i.freeze
|
|
29
|
+
# Files that shouldn't be downloadable, by name; first match wins.
|
|
30
|
+
EXPOSURE = [
|
|
31
|
+
["database_dump", /\.sql(\.(gz|bz2|xz|zip))?\z/i],
|
|
32
|
+
["private_key", /\A(id_rsa|id_dsa|id_ecdsa|id_ed25519)\z|\.(pem|key|p12|pfx|jks)\z/i],
|
|
33
|
+
["env", /\A\.env(\z|\.)|\Aenv\.(ini|txt)\z/i],
|
|
34
|
+
["archive", /\.(zip|tar|tgz|tar\.gz|tar\.bz2|rar|7z)\z/i],
|
|
35
|
+
["backup", /\.(bak|old|orig|save|swp|copy|backup)\z|~\z|\.(py|rb|yml|json|conf|config|ini|ya?ml)\.(bak|old|orig|save|txt)\z/i],
|
|
36
|
+
["info", /\A(phpinfo|info|adminer)\.php\z/i],
|
|
37
|
+
["log", /\A(debug|error|access)[-_.]?log\z|\.log\z/i]
|
|
38
|
+
].freeze
|
|
39
|
+
# What a secret looks like when nobody set one.
|
|
40
|
+
INSECURE_KEYS = %w[changeme secret your-secret-key put-your-secret-key-here].freeze
|
|
41
|
+
|
|
42
|
+
def self.kind_of(name)
|
|
43
|
+
EXPOSURE.each { |kind, pattern| return kind if pattern.match?(name) }
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A folder inside another folder on the list is already covered: without
|
|
48
|
+
# this, every file under it would be reported twice.
|
|
49
|
+
def self.without_nested(roots)
|
|
50
|
+
unique = {}
|
|
51
|
+
roots.each { |root| unique[root["dir"]] ||= root }
|
|
52
|
+
unique.values.reject do |root|
|
|
53
|
+
unique.keys.any? { |other| other != root["dir"] && "#{root['dir']}/".start_with?("#{other}/") }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# [{"dir", "prefix"}]: prefix is where the web serves the folder, so the
|
|
58
|
+
# path we report is the one the dashboard can ask the site for. Not told:
|
|
59
|
+
# Rails' public/ (served at /), when it's a Rails app and it's there.
|
|
60
|
+
def self.roots_of(given)
|
|
61
|
+
roots = []
|
|
62
|
+
Array(given).each do |entry|
|
|
63
|
+
folder = entry.is_a?(Hash) ? (entry[:dir] || entry["dir"]) : entry
|
|
64
|
+
next if folder.nil? || folder.to_s.empty?
|
|
65
|
+
|
|
66
|
+
prefix = entry.is_a?(Hash) ? (entry[:prefix] || entry["prefix"]).to_s.gsub(%r{\A/+|/+\z}, "") : ""
|
|
67
|
+
roots << { "dir" => File.expand_path(folder.to_s), "prefix" => prefix }
|
|
68
|
+
end
|
|
69
|
+
return without_nested(roots) unless roots.empty?
|
|
70
|
+
|
|
71
|
+
folder = rails_public
|
|
72
|
+
roots << { "dir" => File.expand_path(folder), "prefix" => "" } if folder && File.directory?(folder)
|
|
73
|
+
without_nested(roots)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.rails_app
|
|
77
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
78
|
+
|
|
79
|
+
::Rails.application
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.rails_public
|
|
85
|
+
return nil unless rails_app
|
|
86
|
+
|
|
87
|
+
::Rails.public_path.to_s
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.check(checks, id, failed, value = nil)
|
|
93
|
+
row = { "id" => id, "state" => failed.nil? ? "unknown" : (failed ? "fail" : "pass") }
|
|
94
|
+
row["value"] = value.to_s[0, 200] if value && !value.to_s.empty?
|
|
95
|
+
checks << row
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The Rails settings that decide how much a hole in the site is worth.
|
|
99
|
+
def self.rails_checks
|
|
100
|
+
app = rails_app
|
|
101
|
+
return [] unless app
|
|
102
|
+
|
|
103
|
+
config = app.config
|
|
104
|
+
checks = []
|
|
105
|
+
env = ::Rails.env.to_s
|
|
106
|
+
check(checks, "app.rails_env", env != "production", env)
|
|
107
|
+
# Full error pages, with code and backtraces, for every visitor.
|
|
108
|
+
check(checks, "app.rails_debug", config.consider_all_requests_local ? true : false)
|
|
109
|
+
key = begin
|
|
110
|
+
app.secret_key_base.to_s
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
check(checks, "app.secret_key", key.nil? ? nil : (key.size < 32 || INSECURE_KEYS.any? { |mark| key.downcase.include?(mark) }))
|
|
115
|
+
hosts = config.respond_to?(:hosts) ? Array(config.hosts) : nil
|
|
116
|
+
unless hosts.nil?
|
|
117
|
+
# An empty list switches host checking off: any Host header is served.
|
|
118
|
+
shown = hosts.first(10).map { |h| h.respond_to?(:source) ? h.source : h.to_s }
|
|
119
|
+
check(checks, "app.allowed_hosts", hosts.empty? || hosts.any? { |h| h.to_s == "*" }, shown.join(", "))
|
|
120
|
+
end
|
|
121
|
+
session = config.respond_to?(:session_options) ? (config.session_options || {}) : {}
|
|
122
|
+
check(checks, "app.session_cookie_httponly", session[:httponly] == false)
|
|
123
|
+
# Cookie flags only matter once the site is on HTTPS, which these say it is.
|
|
124
|
+
force_ssl = config.respond_to?(:force_ssl) && config.force_ssl ? true : false
|
|
125
|
+
if force_ssl || session[:secure]
|
|
126
|
+
check(checks, "app.session_cookie_secure", !(force_ssl || session[:secure]))
|
|
127
|
+
check(checks, "app.https_redirect", !force_ssl)
|
|
128
|
+
end
|
|
129
|
+
checks
|
|
130
|
+
rescue StandardError
|
|
131
|
+
checks || []
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.app_checks
|
|
135
|
+
checks = rails_checks
|
|
136
|
+
# Running the web app as root turns any hole in it into the whole machine.
|
|
137
|
+
check(checks, "server.runs_as_root", Process.uid.zero?) if Process.respond_to?(:uid)
|
|
138
|
+
checks
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# One bounded walk per root: [exposure, writable or nil, examples, truncated].
|
|
142
|
+
def self.walk(roots)
|
|
143
|
+
exposure = []
|
|
144
|
+
examples = []
|
|
145
|
+
writable = checked = entries = 0
|
|
146
|
+
roots.each do |root|
|
|
147
|
+
queue = [[root["dir"], 0]]
|
|
148
|
+
until queue.empty?
|
|
149
|
+
folder, depth = queue.shift
|
|
150
|
+
names = begin
|
|
151
|
+
Dir.children(folder)
|
|
152
|
+
rescue SystemCallError
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
names.each do |name|
|
|
156
|
+
entries += 1
|
|
157
|
+
return [exposure, checked.positive? ? writable : nil, examples, true] if entries > MAX_ENTRIES
|
|
158
|
+
|
|
159
|
+
path = File.join(folder, name)
|
|
160
|
+
info = begin
|
|
161
|
+
File.lstat(path)
|
|
162
|
+
rescue SystemCallError
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
next if info.symlink?
|
|
166
|
+
|
|
167
|
+
relative = [root["prefix"], path.delete_prefix("#{root['dir']}/")].reject(&:empty?).join("/")
|
|
168
|
+
if info.directory?
|
|
169
|
+
if VCS_DIRS.include?(name)
|
|
170
|
+
exposure << { "path" => "#{relative}/config", "kind" => "vcs" } if exposure.size < MAX_EXPOSURE
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
queue << [path, depth + 1] if depth + 1 <= MAX_DEPTH && !SKIP_DIRS.include?(name)
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
next unless info.file?
|
|
177
|
+
|
|
178
|
+
kind = kind_of(name)
|
|
179
|
+
if kind && exposure.size < MAX_EXPOSURE
|
|
180
|
+
exposure << { "path" => relative, "kind" => kind, "size" => info.size, "mtime" => info.mtime.utc.iso8601(3) }
|
|
181
|
+
end
|
|
182
|
+
next unless checked < MAX_PERM_CHECKS && CODE_FILE.match?(name)
|
|
183
|
+
|
|
184
|
+
checked += 1
|
|
185
|
+
if info.world_writable?
|
|
186
|
+
writable += 1
|
|
187
|
+
examples << relative if examples.size < 3
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
[exposure, checked.positive? ? writable : nil, examples, false]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Options: roots (the folders the web serves, as "public" or
|
|
196
|
+
# {dir: ..., prefix: "assets"}; default: Rails' public/), host (the
|
|
197
|
+
# server's name), checks (a callable returning more checks of your own).
|
|
198
|
+
def initialize(core, roots: nil, host: nil, checks: nil, **_)
|
|
199
|
+
@core = core
|
|
200
|
+
@given = roots
|
|
201
|
+
@roots = nil
|
|
202
|
+
@extra = checks
|
|
203
|
+
@host = SiteGoose.text(host.nil? ? SiteGoose.hostname : host).gsub(/[^A-Za-z0-9._-]/, "-")[0, 100]
|
|
204
|
+
@host = "server" if @host.empty?
|
|
205
|
+
@job = core.valid? ? Periodic.new(FIRST) { send_report } : nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Worked out on first use, once the app (Rails above all) has booted.
|
|
209
|
+
def roots
|
|
210
|
+
@roots ||= Posture.roots_of(@given)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# What would be reported now (also used by the job and by tests).
|
|
214
|
+
def report
|
|
215
|
+
exposure, writable, examples, truncated = Posture.walk(roots)
|
|
216
|
+
checks = Posture.app_checks
|
|
217
|
+
unless writable.nil?
|
|
218
|
+
row = { "id" => "server.code_writable", "state" => writable.positive? ? "fail" : "pass" }
|
|
219
|
+
row["value"] = "#{writable} files, e.g. #{examples.join(', ')}" if writable.positive?
|
|
220
|
+
checks << row
|
|
221
|
+
end
|
|
222
|
+
begin
|
|
223
|
+
extra = @extra.respond_to?(:call) ? @extra.call : []
|
|
224
|
+
Array(extra).each do |c|
|
|
225
|
+
next unless c.is_a?(Hash)
|
|
226
|
+
|
|
227
|
+
c = c.to_h { |k, v| [k.to_s, v] }
|
|
228
|
+
next unless c["id"] && %w[pass fail unknown].include?(c["state"].to_s)
|
|
229
|
+
|
|
230
|
+
row = { "id" => c["id"].to_s.downcase[0, 60], "state" => c["state"].to_s }
|
|
231
|
+
row["value"] = c["value"].to_s[0, 200] if c["value"]
|
|
232
|
+
checks << row
|
|
233
|
+
end
|
|
234
|
+
rescue StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
body = {
|
|
238
|
+
"site_id" => @core.site_id,
|
|
239
|
+
"host" => @host,
|
|
240
|
+
"at" => SiteGoose.iso_now,
|
|
241
|
+
"checks" => checks.first(MAX_CHECKS),
|
|
242
|
+
"facts" => { "agent" => "ruby/#{RUBY_VERSION}" }
|
|
243
|
+
}
|
|
244
|
+
unless roots.empty?
|
|
245
|
+
# Left out when there's no folder to walk: the dashboard then says
|
|
246
|
+
# this wasn't checked, instead of taking an empty walk for a clean one.
|
|
247
|
+
body["exposure"] = exposure.first(MAX_EXPOSURE)
|
|
248
|
+
body["facts"]["webroot"] = roots[0]["dir"][0, 100]
|
|
249
|
+
end
|
|
250
|
+
body["truncated"] = true if truncated
|
|
251
|
+
body
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def send_report
|
|
255
|
+
# The set-up checks ride the software inventory switch.
|
|
256
|
+
return RETRY_EVERY unless @core.on?("inventory")
|
|
257
|
+
|
|
258
|
+
res = @core.request("posture", "/events/posture", auth: :bearer, body: report)
|
|
259
|
+
return RETRY_EVERY if res.nil? || !res.ok?
|
|
260
|
+
|
|
261
|
+
answer = res.json
|
|
262
|
+
every = answer.is_a?(Hash) && answer["every"].is_a?(Integer) && answer["every"].positive? ? answer["every"] : EVERY
|
|
263
|
+
[RETRY_EVERY, every].max
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def flush; end
|
|
267
|
+
|
|
268
|
+
def close
|
|
269
|
+
@job&.stop
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# First-party proxy: serving the tracker from the site's own domain, so ad
|
|
4
|
+
# blockers don't hide visitors.
|
|
5
|
+
#
|
|
6
|
+
# Blockers stop analytics by recognising the address a script comes from and
|
|
7
|
+
# what it is called ("track.js", "collect"). This app is already a server on
|
|
8
|
+
# the site's domain, so the package serves both itself:
|
|
9
|
+
#
|
|
10
|
+
# <prefix>/js/<rest> -> <endpoint>/<rest> (tracker files)
|
|
11
|
+
# <prefix>/api/<rest> -> <endpoint>/collectanalytics/<rest> (the collector)
|
|
12
|
+
#
|
|
13
|
+
# The same two rules as the hand-written Nginx/Cloudflare ones, so one install
|
|
14
|
+
# check covers both. The prefix comes from the site secret: not guessable from
|
|
15
|
+
# the page, and the same every time, so a browser's cache and a CDN in front
|
|
16
|
+
# stay valid. It answers before every other module: a visitor's beacon is our
|
|
17
|
+
# own traffic, not a page of the site, so it isn't counted, timed or rate-limited.
|
|
18
|
+
|
|
19
|
+
require "digest"
|
|
20
|
+
require "net/http"
|
|
21
|
+
require "uri"
|
|
22
|
+
|
|
23
|
+
module SiteGoose
|
|
24
|
+
class Proxy
|
|
25
|
+
# The tracker files, by name: anything else is refused rather than forwarded.
|
|
26
|
+
FILES = %w[track.js s.js snapshot.js replay.js survey.js banner.js leadform.js notice.js editor.js picker.js
|
|
27
|
+
feedback.js shield.js widget.js share.js].freeze
|
|
28
|
+
# A pinned version never changes; /v<major>/ follows new releases, so it is
|
|
29
|
+
# held for an hour, the same as the collector serves it with.
|
|
30
|
+
TTL_PINNED_S = 7 * 24 * 60 * 60
|
|
31
|
+
TTL_MAJOR_S = 60 * 60
|
|
32
|
+
MAX_BODY = 6 * 1024 * 1024
|
|
33
|
+
# The collector reads these; everything else, cookies above all, stays here.
|
|
34
|
+
PASS_HEADERS = %w[content-type user-agent accept-language referer origin sec-fetch-site sec-ch-ua
|
|
35
|
+
sec-ch-ua-mobile sec-ch-ua-platform].freeze
|
|
36
|
+
SAFE_PATH = %r{\A[A-Za-z0-9/_.-]{1,120}\z}.freeze
|
|
37
|
+
FILE_PATH = %r{\Av(\d+(?:\.\d+\.\d+)?)/([a-z]+\.js)\z}.freeze
|
|
38
|
+
# These run on the app's own request threads, so they are short, and a
|
|
39
|
+
# collector that stops answering is left alone for a while rather than
|
|
40
|
+
# costing every beacon a timeout: a black-holed collector must not use
|
|
41
|
+
# up Puma's threads.
|
|
42
|
+
CONNECT_TIMEOUT = 1
|
|
43
|
+
READ_TIMEOUT = 2
|
|
44
|
+
BREAKER_FAILURES = 3
|
|
45
|
+
BREAKER_OPEN_S = 30
|
|
46
|
+
# A tracker file that couldn't be fetched isn't asked for again this soon.
|
|
47
|
+
FILE_RETRY_S = 30
|
|
48
|
+
|
|
49
|
+
def self.not_found
|
|
50
|
+
[404, { "content-type" => "text/plain; charset=utf-8" }, "Not found"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.prefix_for(secret)
|
|
54
|
+
Digest::SHA256.hexdigest("proxy|#{secret}")[0, 8]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sent as X-Proxy-Key, so the collector believes the visitor addresses
|
|
58
|
+
# this proxy passes on: anyone can write X-Forwarded-For. Worked out from
|
|
59
|
+
# the secret as the collector does (expresscollect/utils/proxyKey.js).
|
|
60
|
+
def self.key_for(secret)
|
|
61
|
+
Digest::SHA256.hexdigest("proxykey|#{secret}")[0, 32]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
attr_reader :prefix, :key
|
|
65
|
+
|
|
66
|
+
# prefix: the path it answers on (default: from the secret).
|
|
67
|
+
def initialize(core, prefix: nil, enabled: true, **_)
|
|
68
|
+
@core = core
|
|
69
|
+
@enabled = enabled
|
|
70
|
+
@prefix = (prefix || Proxy.prefix_for(core.secret)).to_s.gsub(/[^A-Za-z0-9_-]/, "")
|
|
71
|
+
@key = core.secret.empty? ? "" : Proxy.key_for(core.secret)
|
|
72
|
+
# One copy of each tracker file, so a repeat visitor costs no upstream
|
|
73
|
+
# request. A handful of small files: it never grows.
|
|
74
|
+
@files = {}
|
|
75
|
+
@failed = {}
|
|
76
|
+
@lock = Mutex.new
|
|
77
|
+
@failures = 0
|
|
78
|
+
@open_until = 0.0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# True while the collector is being left alone (after BREAKER_FAILURES
|
|
82
|
+
# failures in a row). When the pause ends one request tries again; the
|
|
83
|
+
# others keep the short answer until it has.
|
|
84
|
+
def resting?
|
|
85
|
+
now = SiteGoose.clock
|
|
86
|
+
@lock.synchronize do
|
|
87
|
+
next false if @failures < BREAKER_FAILURES
|
|
88
|
+
next true if now < @open_until
|
|
89
|
+
|
|
90
|
+
@open_until = now + READ_TIMEOUT + CONNECT_TIMEOUT
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def worked
|
|
96
|
+
@lock.synchronize { @failures = 0 }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def failed
|
|
100
|
+
@lock.synchronize do
|
|
101
|
+
@failures += 1
|
|
102
|
+
@open_until = SiteGoose.clock + BREAKER_OPEN_S if @failures >= BREAKER_FAILURES
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def on?
|
|
107
|
+
@enabled && @core.valid? && !@prefix.empty? && @core.on?("proxy")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The tracker's address on this site, for the page's script tag.
|
|
111
|
+
def script_url(version = "1")
|
|
112
|
+
"/#{@prefix}/js/v#{version}/s.js"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Where visits are sent, for the tag's data-collect.
|
|
116
|
+
def collect_url
|
|
117
|
+
"/#{@prefix}/api/e"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# [kind ("js" | "api" | "bad"), the rest] when the address is ours, else nil.
|
|
121
|
+
def match(path)
|
|
122
|
+
return nil unless on?
|
|
123
|
+
|
|
124
|
+
head = "/#{@prefix}/"
|
|
125
|
+
path = path.to_s
|
|
126
|
+
return nil unless path.start_with?(head)
|
|
127
|
+
|
|
128
|
+
rest = path[head.size..]
|
|
129
|
+
%w[js api].each do |kind|
|
|
130
|
+
next unless rest.start_with?("#{kind}/")
|
|
131
|
+
|
|
132
|
+
tail = rest[(kind.size + 1)..]
|
|
133
|
+
return [kind, tail] if !tail.empty? && !tail.include?("..") && tail.match?(SAFE_PATH)
|
|
134
|
+
|
|
135
|
+
break
|
|
136
|
+
end
|
|
137
|
+
["bad", ""]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# [status, headers, body]. Blocking (up to the timeout).
|
|
141
|
+
def handle(kind, rest, method: "GET", query: "", headers: {}, body: nil, client_ip: "")
|
|
142
|
+
return file(rest) if kind == "js"
|
|
143
|
+
return Proxy.not_found unless kind == "api"
|
|
144
|
+
|
|
145
|
+
collector(rest, method, query, headers || {}, body, client_ip)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fetch(url, method: "GET", headers: {}, body: nil)
|
|
149
|
+
uri = URI.parse(url)
|
|
150
|
+
klass = method == "POST" ? Net::HTTP::Post : Net::HTTP::Get
|
|
151
|
+
req = klass.new(uri.request_uri, headers)
|
|
152
|
+
req.body = body if body && method == "POST"
|
|
153
|
+
Forking.sending do
|
|
154
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
155
|
+
http.use_ssl = uri.scheme == "https"
|
|
156
|
+
http.open_timeout = CONNECT_TIMEOUT
|
|
157
|
+
http.read_timeout = READ_TIMEOUT
|
|
158
|
+
http.write_timeout = READ_TIMEOUT if http.respond_to?(:write_timeout=)
|
|
159
|
+
http.start { |h| h.request(req) }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Any version number fits the file pattern, so the list is kept short.
|
|
164
|
+
def remember_failure(rest)
|
|
165
|
+
@lock.synchronize do
|
|
166
|
+
@failed.delete(rest)
|
|
167
|
+
@failed.shift while @failed.size >= 200
|
|
168
|
+
@failed[rest] = SiteGoose.clock
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def file(rest)
|
|
173
|
+
found = FILE_PATH.match(rest)
|
|
174
|
+
return Proxy.not_found if found.nil? || !FILES.include?(found[2])
|
|
175
|
+
|
|
176
|
+
pinned = found[1].include?(".")
|
|
177
|
+
held = @lock.synchronize { @files[rest] }
|
|
178
|
+
body = nil
|
|
179
|
+
body = held[0] if held && held[1] > Time.now.to_f - (pinned ? TTL_PINNED_S : TTL_MAJOR_S)
|
|
180
|
+
recently_failed = @lock.synchronize { (at = @failed[rest]) && at > SiteGoose.clock - FILE_RETRY_S }
|
|
181
|
+
if body.nil? && !recently_failed && !resting?
|
|
182
|
+
begin
|
|
183
|
+
answer = fetch("#{@core.endpoint}/#{rest}")
|
|
184
|
+
fetched = answer.body.to_s
|
|
185
|
+
answer.code.to_i >= 500 ? failed : worked
|
|
186
|
+
if answer.code.to_i == 200 && fetched.bytesize > 500 && fetched.bytesize <= MAX_BODY
|
|
187
|
+
body = fetched
|
|
188
|
+
@lock.synchronize do
|
|
189
|
+
@files[rest] = [body, Time.now.to_f]
|
|
190
|
+
@failed.delete(rest)
|
|
191
|
+
end
|
|
192
|
+
else
|
|
193
|
+
remember_failure(rest)
|
|
194
|
+
end
|
|
195
|
+
rescue StandardError, Timeout::Error => e
|
|
196
|
+
failed
|
|
197
|
+
remember_failure(rest)
|
|
198
|
+
@core.log("proxy", "could not fetch #{rest}: #{e.message}")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
# Upstream is unreachable: an old copy beats no tracker at all.
|
|
202
|
+
body = held[0] if body.nil? && held
|
|
203
|
+
return [502, {}, ""] if body.nil?
|
|
204
|
+
|
|
205
|
+
[200, {
|
|
206
|
+
"content-type" => "application/javascript; charset=utf-8",
|
|
207
|
+
"cache-control" => pinned ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
208
|
+
"x-content-type-options" => "nosniff"
|
|
209
|
+
}, body]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def collector(rest, method, query, headers, body, client_ip)
|
|
213
|
+
method = (method.to_s.empty? ? "GET" : method.to_s).upcase
|
|
214
|
+
if method == "OPTIONS"
|
|
215
|
+
# Same origin as the page, so a preflight shouldn't happen; answer it
|
|
216
|
+
# cheaply rather than passing it on.
|
|
217
|
+
return [204, { "access-control-allow-methods" => "GET, POST, OPTIONS", "access-control-allow-headers" => "Content-Type",
|
|
218
|
+
"access-control-max-age" => "86400" }, ""]
|
|
219
|
+
end
|
|
220
|
+
return Proxy.not_found unless %w[GET POST].include?(method)
|
|
221
|
+
return [413, {}, ""] if body && body.bytesize > MAX_BODY
|
|
222
|
+
|
|
223
|
+
# The visitor's address: the collector needs it for the country, the
|
|
224
|
+
# daily visitor ID and the per-visitor rate limit.
|
|
225
|
+
out = { "X-Forwarded-For" => client_ip.to_s }
|
|
226
|
+
out["X-Proxy-Key"] = @key unless @key.empty?
|
|
227
|
+
PASS_HEADERS.each do |name|
|
|
228
|
+
value = headers[name]
|
|
229
|
+
next if value.nil? || value.to_s.empty?
|
|
230
|
+
|
|
231
|
+
out[name.split("-").map(&:capitalize).join("-")] = value.to_s.delete("\r\n")
|
|
232
|
+
end
|
|
233
|
+
url = "#{@core.endpoint}/collectanalytics/#{rest}#{query.to_s.empty? ? '' : "?#{query}"}"
|
|
234
|
+
# Left alone for now: answered straight away; the tracker treats a
|
|
235
|
+
# failed beacon as lost and carries on.
|
|
236
|
+
return [502, { "retry-after" => BREAKER_OPEN_S.to_s }, ""] if resting?
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
answer = fetch(url, method: method, headers: out, body: method == "POST" ? body.to_s : nil)
|
|
240
|
+
answer.code.to_i >= 500 ? failed : worked
|
|
241
|
+
passed = {}
|
|
242
|
+
%w[content-type cache-control retry-after].each do |name|
|
|
243
|
+
value = answer[name]
|
|
244
|
+
passed[name] = value if value && !value.empty?
|
|
245
|
+
end
|
|
246
|
+
text = answer.body.to_s
|
|
247
|
+
[answer.code.to_i, passed, text.bytesize > MAX_BODY ? text.byteslice(0, MAX_BODY) : text]
|
|
248
|
+
rescue StandardError, Timeout::Error => e
|
|
249
|
+
# The tracker already treats a failed beacon as lost and carries on.
|
|
250
|
+
failed
|
|
251
|
+
@core.log("proxy", "could not reach the collector: #{e.message}")
|
|
252
|
+
[502, {}, ""]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def flush; end
|
|
257
|
+
|
|
258
|
+
def close; end
|
|
259
|
+
end
|
|
260
|
+
end
|