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
data/lib/sitegoose/ip.rb
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module SiteGoose
|
|
6
|
+
# Addresses as integers in IPv6 space, IPv4 kept as ::ffff:a.b.c.d, so one
|
|
7
|
+
# sorted list holds both (sdk/protocols/firewall.md: treat IPv4-mapped IPv6
|
|
8
|
+
# as IPv4).
|
|
9
|
+
module IP
|
|
10
|
+
MAPPED = 0xFFFF << 32
|
|
11
|
+
V4 = /\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# An address as an Integer in IPv6 space, or nil when it isn't one.
|
|
16
|
+
def key(text)
|
|
17
|
+
s = text.to_s.strip
|
|
18
|
+
s = s.split("%", 2)[0] if s.include?("%")
|
|
19
|
+
return nil if s.empty? || s.include?("/")
|
|
20
|
+
|
|
21
|
+
if (m = V4.match(s))
|
|
22
|
+
octets = m.captures
|
|
23
|
+
return nil if octets.any? { |o| (o.size > 1 && o.start_with?("0")) || o.to_i > 255 }
|
|
24
|
+
|
|
25
|
+
return MAPPED | octets.reduce(0) { |acc, o| (acc << 8) | o.to_i }
|
|
26
|
+
end
|
|
27
|
+
return nil unless s.include?(":")
|
|
28
|
+
|
|
29
|
+
IPAddr.new(s).to_i
|
|
30
|
+
rescue IPAddr::Error, ArgumentError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# "1.2.3.0/24" -> [start, end] as key()s, or nil. nil too for an IPv6
|
|
35
|
+
# range that holds all of ::ffff:0:0/96 (::/64, ::/0): with IPv4 kept as
|
|
36
|
+
# ::ffff:a.b.c.d it would match every IPv4 visitor, a /64 that is one
|
|
37
|
+
# machine in IPv6 being the whole IPv4 internet here.
|
|
38
|
+
def range(entry)
|
|
39
|
+
s = entry.to_s.strip
|
|
40
|
+
return nil if s.empty?
|
|
41
|
+
|
|
42
|
+
address, bits = s.split("/", 2)
|
|
43
|
+
net = IPAddr.new(address.to_s.split("%", 2)[0])
|
|
44
|
+
if bits
|
|
45
|
+
return nil unless bits.match?(/\A\d{1,3}\z/)
|
|
46
|
+
|
|
47
|
+
prefix = bits.to_i
|
|
48
|
+
return nil if prefix > (net.ipv4? ? 32 : 128)
|
|
49
|
+
|
|
50
|
+
net = net.mask(prefix)
|
|
51
|
+
end
|
|
52
|
+
span = net.to_range
|
|
53
|
+
first = span.first.to_i
|
|
54
|
+
last = span.last.to_i
|
|
55
|
+
return [MAPPED | first, MAPPED | last] if net.ipv4?
|
|
56
|
+
return nil if first <= MAPPED && last >= (MAPPED | 0xFFFFFFFF)
|
|
57
|
+
|
|
58
|
+
[first, last]
|
|
59
|
+
rescue IPAddr::Error, ArgumentError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Addresses and ranges, merged and sorted, looked up by binary search.
|
|
65
|
+
class IpSet
|
|
66
|
+
def initialize(entries = [])
|
|
67
|
+
ranges = Array(entries).filter_map { |e| e.is_a?(String) ? IP.range(e) : nil }.sort
|
|
68
|
+
@starts = []
|
|
69
|
+
@ends = []
|
|
70
|
+
ranges.each do |first, last|
|
|
71
|
+
if !@ends.empty? && first <= @ends[-1] + 1
|
|
72
|
+
@ends[-1] = last if last > @ends[-1]
|
|
73
|
+
else
|
|
74
|
+
@starts << first
|
|
75
|
+
@ends << last
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def has?(key)
|
|
81
|
+
return false if key.nil? || @starts.empty?
|
|
82
|
+
|
|
83
|
+
after = @starts.bsearch_index { |start| start > key } || @starts.size
|
|
84
|
+
i = after - 1
|
|
85
|
+
i >= 0 && @ends[i] >= key
|
|
86
|
+
end
|
|
87
|
+
alias include? has?
|
|
88
|
+
|
|
89
|
+
def size
|
|
90
|
+
@starts.size
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Cloudflare's published ranges (cloudflare.com/ips), the same list as
|
|
95
|
+
# sdk/protocols/cloudflare-ranges.json (test/firewall_test.rb compares them):
|
|
96
|
+
# CF-Connecting-IP is believed only from these, since anyone who reaches the
|
|
97
|
+
# origin directly can send it.
|
|
98
|
+
CLOUDFLARE = %w[
|
|
99
|
+
173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18
|
|
100
|
+
108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17
|
|
101
|
+
162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
|
|
102
|
+
2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32
|
|
103
|
+
2a06:98c0::/29 2c0f:f248::/32
|
|
104
|
+
].freeze
|
|
105
|
+
|
|
106
|
+
def self.from_cloudflare?(ip)
|
|
107
|
+
key = IP.key(ip)
|
|
108
|
+
return false if key.nil?
|
|
109
|
+
|
|
110
|
+
@cloudflare ||= IpSet.new(CLOUDFLARE)
|
|
111
|
+
@cloudflare.has?(key)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The visitor's address. trust_proxy: nil/0 the connection's address; a
|
|
115
|
+
# number of proxies in X-Forwarded-For (true = 1); "cloudflare" reads
|
|
116
|
+
# CF-Connecting-IP, believed only when the connection comes from one of
|
|
117
|
+
# Cloudflare's addresses; a callable gets the headers and returns it.
|
|
118
|
+
#
|
|
119
|
+
# A hop that isn't an address (a name, "unknown", garbage a client sent)
|
|
120
|
+
# gives the connection's address instead, so the firewall still checks
|
|
121
|
+
# the request; a port on a hop ("1.2.3.4:5678", "[2001:db8::1]:443", as
|
|
122
|
+
# Azure's load balancer sends) is dropped.
|
|
123
|
+
def self.client_ip(remote, headers, trust_proxy)
|
|
124
|
+
remote = remote.to_s
|
|
125
|
+
if trust_proxy.respond_to?(:call)
|
|
126
|
+
given = hop(trust_proxy.call(headers))
|
|
127
|
+
return IP.key(given) ? given : remote
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if trust_proxy.to_s == "cloudflare"
|
|
131
|
+
forwarded = hop(header(headers, "cf-connecting-ip"))
|
|
132
|
+
return from_cloudflare?(remote) && IP.key(forwarded) ? forwarded : remote
|
|
133
|
+
end
|
|
134
|
+
hops = if trust_proxy == true then 1
|
|
135
|
+
elsif trust_proxy.is_a?(Integer) then trust_proxy
|
|
136
|
+
elsif trust_proxy.is_a?(String) && trust_proxy.match?(/\A\d+\z/) then trust_proxy.to_i
|
|
137
|
+
else 0
|
|
138
|
+
end
|
|
139
|
+
if hops.positive?
|
|
140
|
+
chain = header(headers, "x-forwarded-for").split(",").map(&:strip).reject(&:empty?)
|
|
141
|
+
unless chain.empty?
|
|
142
|
+
chosen = hop(chain[[0, chain.size - hops].max])
|
|
143
|
+
return chosen if IP.key(chosen)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
remote
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One hop as an address: "[v6]:port" and "v4:port" lose the port.
|
|
150
|
+
def self.hop(text)
|
|
151
|
+
s = text.to_s.strip
|
|
152
|
+
if s.start_with?("[")
|
|
153
|
+
close = s.index("]")
|
|
154
|
+
return close ? s[1...close] : s
|
|
155
|
+
end
|
|
156
|
+
found = /\A(\d{1,3}(?:\.\d{1,3}){3}):\d{1,5}\z/.match(s)
|
|
157
|
+
found ? found[1] : s
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SiteGoose
|
|
4
|
+
# Rack middleware: the proxy, the firewall and the browser check,
|
|
5
|
+
# maintenance mode, then the app, timed, with its errors captured and the
|
|
6
|
+
# dashboard's security headers added. Put it first.
|
|
7
|
+
#
|
|
8
|
+
# use SiteGoose::Middleware # configured elsewhere (Rails, configure, ENV)
|
|
9
|
+
# use SiteGoose::Middleware, site_id: "...", secret: "...", endpoint: "..."
|
|
10
|
+
# use SiteGoose::Middleware, platform # one you made: SiteGoose::Platform.new(...)
|
|
11
|
+
#
|
|
12
|
+
# Never breaks a request: when the package itself fails, the request goes
|
|
13
|
+
# on to the app as if it weren't there.
|
|
14
|
+
class Middleware
|
|
15
|
+
def initialize(app, platform = nil, **options)
|
|
16
|
+
@app = app
|
|
17
|
+
@given = platform
|
|
18
|
+
SiteGoose.configure(**options) unless options.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def platform
|
|
22
|
+
@given || SiteGoose.platform
|
|
23
|
+
rescue StandardError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Set on the env by the first copy: a second (the Railtie's and one added
|
|
28
|
+
# by hand, or an engine's) passes the request straight on, so nothing is
|
|
29
|
+
# checked, timed or reported twice.
|
|
30
|
+
SEEN = "sitegoose.seen"
|
|
31
|
+
|
|
32
|
+
def call(env)
|
|
33
|
+
return @app.call(env) if env[SEEN]
|
|
34
|
+
|
|
35
|
+
env[SEEN] = true
|
|
36
|
+
made = platform
|
|
37
|
+
return @app.call(env) if made.nil?
|
|
38
|
+
|
|
39
|
+
reached = false
|
|
40
|
+
made.rack_call(env) do
|
|
41
|
+
reached = true
|
|
42
|
+
@app.call(env)
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError, ScriptError => e
|
|
45
|
+
# The app's own errors are raised on; ours never reach the visitor.
|
|
46
|
+
raise if reached
|
|
47
|
+
|
|
48
|
+
made&.core&.log("middleware", "failed, request passed:", e.message)
|
|
49
|
+
@app.call(env)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Performance & API monitoring module: times every request (from arriving to
|
|
4
|
+
# the response being ready) and sends one row per minute, method, route and
|
|
5
|
+
# status code (how many, total and slowest time, and a count per time bucket)
|
|
6
|
+
# to POST /events/server-requests. Nothing about visitors is sent.
|
|
7
|
+
#
|
|
8
|
+
# The route is the framework's own pattern when the integration knows it
|
|
9
|
+
# (Rails, Sinatra); otherwise numbers and IDs in the path become ":id", and
|
|
10
|
+
# unmatched 404s are one route.
|
|
11
|
+
|
|
12
|
+
module SiteGoose
|
|
13
|
+
class Performance
|
|
14
|
+
# Keep in step with the collector (expresscollect/Analytics/serverRequests.js).
|
|
15
|
+
BUCKET_EDGES_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000].freeze
|
|
16
|
+
FLUSH_EVERY = 60
|
|
17
|
+
MAX_KEYS = 300
|
|
18
|
+
# Past MAX_KEYS new routes fold into "(other)"; past this nothing new is
|
|
19
|
+
# kept at all, whatever the requests carry.
|
|
20
|
+
HARD_MAX_KEYS = 400
|
|
21
|
+
# The method is the visitor's to choose: anything else is one "OTHER".
|
|
22
|
+
METHODS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS JOB].freeze
|
|
23
|
+
MAX_ROWS = 500
|
|
24
|
+
NOT_FOUND = "/(not found)"
|
|
25
|
+
ID = /\A(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,})\z/i.freeze
|
|
26
|
+
|
|
27
|
+
# "/orders/123/items/9f8e..." -> "/orders/:id/items/:id"
|
|
28
|
+
def self.normalize_route(path)
|
|
29
|
+
path = SiteGoose.text(path).split(/[?#]/, 2)[0].to_s
|
|
30
|
+
path = "/" if path.empty?
|
|
31
|
+
parts = path.split("/", -1).map { |s| s.match?(ID) || (s.size >= 20 && s.match?(/\d/)) ? ":id" : s }
|
|
32
|
+
route = parts.join("/")
|
|
33
|
+
(route.start_with?("/") ? route : "/#{route}")[0, 200]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.method_of(method)
|
|
37
|
+
name = SiteGoose.text(method).upcase
|
|
38
|
+
return "GET" if name.empty?
|
|
39
|
+
|
|
40
|
+
METHODS.include?(name) ? name : "OTHER"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.bucket_of(ms)
|
|
44
|
+
BUCKET_EDGES_MS.index { |edge| ms <= edge } || BUCKET_EDGES_MS.size
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :rows, :server_name
|
|
48
|
+
|
|
49
|
+
# Options: server_name (default the host name).
|
|
50
|
+
def initialize(core, server_name: nil, **_)
|
|
51
|
+
@core = core
|
|
52
|
+
@server_name = SiteGoose.text(server_name || SiteGoose.hostname, 100)
|
|
53
|
+
@rows = {}
|
|
54
|
+
@lock = Mutex.new
|
|
55
|
+
@job = core.valid? ? Periodic.new(FLUSH_EVERY) { flush(everything: false); FLUSH_EVERY } : nil
|
|
56
|
+
Forking.register(self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# In a forked worker the parent's rows are the parent's to send.
|
|
60
|
+
def after_fork_in_child
|
|
61
|
+
@lock = Mutex.new
|
|
62
|
+
@rows = {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# One finished request. route: the framework's pattern, or "" to work it
|
|
66
|
+
# out from path. -> [method, route] as recorded (the database queries go
|
|
67
|
+
# under the same), or nil when Server speed is off.
|
|
68
|
+
def record(method, route, status, ms, path = "")
|
|
69
|
+
return unless @core.on?("performance")
|
|
70
|
+
|
|
71
|
+
status = status.to_i
|
|
72
|
+
route = SiteGoose.text(route)
|
|
73
|
+
route = status == 404 ? NOT_FOUND : Performance.normalize_route(path) if route.empty?
|
|
74
|
+
method = Performance.method_of(method)
|
|
75
|
+
minute = (Time.now.to_i / 60) * 60
|
|
76
|
+
ms = ms.to_f
|
|
77
|
+
@lock.synchronize do
|
|
78
|
+
key = [minute, method, route[0, 200], status]
|
|
79
|
+
key = [minute, method, "(other)", status] if !@rows.key?(key) && @rows.size >= MAX_KEYS
|
|
80
|
+
return key[1, 2] if !@rows.key?(key) && @rows.size >= HARD_MAX_KEYS
|
|
81
|
+
|
|
82
|
+
row = (@rows[key] ||= { count: 0, sum_ms: 0.0, max_ms: 0.0, buckets: Array.new(BUCKET_EDGES_MS.size + 1, 0) })
|
|
83
|
+
row[:count] += 1
|
|
84
|
+
row[:sum_ms] += ms
|
|
85
|
+
row[:max_ms] = ms if ms > row[:max_ms]
|
|
86
|
+
row[:buckets][Performance.bucket_of(ms)] += 1
|
|
87
|
+
key[1, 2]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def flush(everything: true)
|
|
92
|
+
current = (Time.now.to_i / 60) * 60
|
|
93
|
+
ready = @lock.synchronize do
|
|
94
|
+
taken = @rows.select { |k, _| everything || k[0] < current }
|
|
95
|
+
taken.each_key { |k| @rows.delete(k) }
|
|
96
|
+
taken
|
|
97
|
+
end
|
|
98
|
+
rows = ready.map do |k, v|
|
|
99
|
+
{ "minute" => Time.at(k[0]).utc.iso8601(3), "method" => k[1], "route" => k[2], "status" => k[3],
|
|
100
|
+
"count" => v[:count], "sum_ms" => v[:sum_ms].round(1), "max_ms" => v[:max_ms].round(1), "buckets" => v[:buckets] }
|
|
101
|
+
end
|
|
102
|
+
rows.each_slice(MAX_ROWS) do |batch|
|
|
103
|
+
@core.request("performance", "/events/server-requests",
|
|
104
|
+
body: { "server_name" => @server_name, "runtime" => RUNTIME, "rows" => batch })
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def close
|
|
109
|
+
@job&.stop
|
|
110
|
+
Forking.unregister(self)
|
|
111
|
+
flush
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|