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.
@@ -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