camada 0.1.0
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 +199 -0
- data/lib/camada/beacon_js.rb +10 -0
- data/lib/camada/body_proxy.rb +48 -0
- data/lib/camada/challenge/format.rb +108 -0
- data/lib/camada/challenge/page.rb +107 -0
- data/lib/camada/challenge/verify.rb +68 -0
- data/lib/camada/config.rb +38 -0
- data/lib/camada/constants.rb +35 -0
- data/lib/camada/engine.rb +364 -0
- data/lib/camada/env.rb +47 -0
- data/lib/camada/events/build.rb +82 -0
- data/lib/camada/events/queue.rb +184 -0
- data/lib/camada/guarded.rb +34 -0
- data/lib/camada/ip.rb +80 -0
- data/lib/camada/ipparse.rb +100 -0
- data/lib/camada/rack.rb +205 -0
- data/lib/camada/railtie.rb +17 -0
- data/lib/camada/redact.rb +64 -0
- data/lib/camada/snapshot/client.rb +199 -0
- data/lib/camada/snapshot/match.rb +221 -0
- data/lib/camada/snapshot/parse.rb +363 -0
- data/lib/camada/transport.rb +58 -0
- data/lib/camada/version.rb +10 -0
- data/lib/camada.rb +99 -0
- metadata +74 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "../constants"
|
|
5
|
+
require_relative "../ipparse"
|
|
6
|
+
|
|
7
|
+
module Camada
|
|
8
|
+
# BLK snapshot parser (v3, v4, v5), ported from @camada/core src/snapshot/parse.ts, itself a
|
|
9
|
+
# port of edge-analyst src/blocklist.js load() (the reference implementation).
|
|
10
|
+
#
|
|
11
|
+
# Container: sectioned little-endian uint32 —
|
|
12
|
+
# [0] magic 0x424c4b3<version> [1] section count K
|
|
13
|
+
# K x [type, offset(words), length(words)] then the sections.
|
|
14
|
+
# Types: 1 V4_STARTS 2 V4_ENDS 3 V4_IDX16 4 V4_BM24 5 V6_STARTS 6 V6_ENDS 7 V6_BM24
|
|
15
|
+
# 8 ASN_BM 9 ASN_EXTRA.
|
|
16
|
+
# v4 (contracts §A3) adds two side lists as INTERLEAVED range pairs:
|
|
17
|
+
# 10 ALLOW_V4 11 ALLOW_V6 12 CHALLENGE_V4 13 CHALLENGE_V6
|
|
18
|
+
# *_V4: [start, end, …] (2 words per range, sorted by start)
|
|
19
|
+
# *_V6: [s0,s1,s2,s3, e0,e1,e2,e3, …] (8 words per range, big-endian word order, sorted by start)
|
|
20
|
+
# v5 (contracts §D3) adds the tenant's ordered custom rules, which run BEFORE the three sides:
|
|
21
|
+
# 14 RULE_V4 15 RULE_V6 — repeated, word 0 = the rule's index into meta.rules, then range
|
|
22
|
+
# pairs exactly as 10/11. One 14 + one 15 per `ip` condition, in condition order (an empty half
|
|
23
|
+
# still ships its index word), so a rule with two ip conditions reads two pairs.
|
|
24
|
+
# Meta travels separately: { version, country[], tls[], pathsExact[], pathsPrefix[], pathsRegex[],
|
|
25
|
+
# allow?: side, challenge?: side, rules?: [] } with side = { asn[], country[], pathsExact[], pathsPrefix[] }.
|
|
26
|
+
# The version byte is advisory: sections 10-15 are read whenever they are present.
|
|
27
|
+
#
|
|
28
|
+
# A Uint32Array is a `Words` view: one String of bytes read a word at a time with
|
|
29
|
+
# String#unpack1("V", offset:), so a 5 MB container never becomes 1.3 M Integers, and the
|
|
30
|
+
# bitmaps are indexed, never unpacked wholesale. Ruby integers are unbounded, so every
|
|
31
|
+
# `>>> 0` of the original is simply absent.
|
|
32
|
+
module Snapshot
|
|
33
|
+
FORMATS = { 0x424C4B33 => 3, 0x424C4B34 => 4, 0x424C4B35 => 5 }.freeze
|
|
34
|
+
ACTIONS = %w[skip block challenge warn].freeze
|
|
35
|
+
|
|
36
|
+
# A read-only uint32 little-endian view over a byte String, from a byte offset, `length` words long.
|
|
37
|
+
class Words
|
|
38
|
+
attr_reader :length
|
|
39
|
+
|
|
40
|
+
def initialize(buf, byte_off = 0, length = nil)
|
|
41
|
+
@buf = buf
|
|
42
|
+
@off = byte_off
|
|
43
|
+
@length = length || [(buf.bytesize - byte_off) / 4, 0].max # a trailing partial word is dropped, as the Uint32Array view does
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def [](i) = @buf.unpack1("V", offset: @off + (4 * i))
|
|
47
|
+
def slice(start, len) = Words.new(@buf, @off + (4 * start), len)
|
|
48
|
+
def empty? = @length == 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A zero-filled section of `length` words: what an absent bitmap/index reads as.
|
|
52
|
+
class Zeros
|
|
53
|
+
attr_reader :length
|
|
54
|
+
|
|
55
|
+
def initialize(length)
|
|
56
|
+
@length = length
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def [](_i) = 0
|
|
60
|
+
def empty? = @length == 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
EMPTY = Words.new("".b, 0, 0)
|
|
64
|
+
|
|
65
|
+
# A v4 side list. `empty` short-circuits the matcher on the (common) v3 snapshot.
|
|
66
|
+
RangeSet = Struct.new(:r4, :r6, :n6, :asn, :country, :paths_exact, :paths_prefix, :empty, keyword_init: true)
|
|
67
|
+
|
|
68
|
+
# The request a compiled condition reads. `ip6` is the parsed address words, or nil; `header`
|
|
69
|
+
# is called with an already lower-cased name, absent where the tap cannot read headers.
|
|
70
|
+
RuleRequest = Struct.new(:n4, :ip6, :asn, :country, :tlsx, :path, :ua, :header, keyword_init: true)
|
|
71
|
+
|
|
72
|
+
CompiledRule = Struct.new(:id, :action, :conds)
|
|
73
|
+
|
|
74
|
+
Snap = Struct.new(
|
|
75
|
+
:version, :format, :s4, :e4, :idx4, :bm4, :s6, :e6, :n6, :bm6, :asn_bm, :asn_extra,
|
|
76
|
+
:country, :tls, :paths_exact, :paths_prefix, :paths_regex, :allow, :challenge,
|
|
77
|
+
:rules, # v5 only; empty on v3/v4, and the matcher then skips them
|
|
78
|
+
keyword_init: true
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def self.range_set(r4, r6, m)
|
|
82
|
+
m ||= {}
|
|
83
|
+
asn = (m["asn"] || []).to_set(&:to_i)
|
|
84
|
+
country = (m["country"] || []).to_set
|
|
85
|
+
exact = (m["pathsExact"] || []).to_set
|
|
86
|
+
prefix = (m["pathsPrefix"] || []).to_set
|
|
87
|
+
empty = r4.empty? && r6.empty? && asn.empty? && country.empty? && exact.empty? && prefix.empty?
|
|
88
|
+
RangeSet.new(r4: r4, r6: r6, n6: r6.length >> 3, asn: asn, country: country, paths_exact: exact, paths_prefix: prefix, empty: empty)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Binary search over interleaved [start, end] uint32 pairs sorted by start.
|
|
92
|
+
def self.in_range4?(r, n)
|
|
93
|
+
lo = 0
|
|
94
|
+
hi = (r.length >> 1) - 1
|
|
95
|
+
return false if hi < 0
|
|
96
|
+
|
|
97
|
+
while lo < hi
|
|
98
|
+
m = (lo + hi + 1) >> 1
|
|
99
|
+
if r[m * 2] <= n
|
|
100
|
+
lo = m
|
|
101
|
+
else
|
|
102
|
+
hi = m - 1
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
n.between?(r[lo * 2], r[(lo * 2) + 1])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Compares the 4 words at a[o..o+3] against the address words.
|
|
109
|
+
def self.cmp_words(a, o, w)
|
|
110
|
+
4.times do |k|
|
|
111
|
+
x = a[o + k]
|
|
112
|
+
y = w[k]
|
|
113
|
+
return x < y ? -1 : 1 if x != y
|
|
114
|
+
end
|
|
115
|
+
0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Binary search over an interleaved [4-word start, 4-word end] side section.
|
|
119
|
+
def self.in_range6?(r, n, w)
|
|
120
|
+
return false if n < 1
|
|
121
|
+
|
|
122
|
+
lo = 0
|
|
123
|
+
hi = n - 1
|
|
124
|
+
while lo < hi
|
|
125
|
+
m = (lo + hi + 1) >> 1
|
|
126
|
+
if cmp_words(r, m * 8, w) <= 0
|
|
127
|
+
lo = m
|
|
128
|
+
else
|
|
129
|
+
hi = m - 1
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
o = lo * 8
|
|
133
|
+
cmp_words(r, o, w) <= 0 && cmp_words(r, o + 4, w) >= 0 # rubocop:disable Style/ComparableBetween -- two different comparisons
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A pattern this runtime rejects never matches, and never throws (fail open). Patterns are
|
|
137
|
+
# authored as JS regexes (the analyst validates them with `new RegExp`), so the JS spellings
|
|
138
|
+
# Onigmo reads differently are translated first — see js_to_onigmo; \d \w \b are ASCII in
|
|
139
|
+
# Ruby by default, as JS reads them.
|
|
140
|
+
def self.compile_regex(pattern)
|
|
141
|
+
Regexp.new(js_to_onigmo(pattern))
|
|
142
|
+
rescue RegexpError, TypeError, ArgumentError, EncodingError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The JS-only spellings a tenant is likely to author: `[^]` (any char) -> `[\s\S]`, `\cX` ->
|
|
147
|
+
# the control character, and the anchors: JS `^`/`$` bind the whole input while Ruby's are
|
|
148
|
+
# line anchors, so outside a class they become `\A`/`\z`. `(?<name>` is native to Onigmo.
|
|
149
|
+
# Anything else Ruby rejects still fails open.
|
|
150
|
+
def self.js_to_onigmo(pattern)
|
|
151
|
+
out = +""
|
|
152
|
+
i = 0
|
|
153
|
+
n = pattern.length
|
|
154
|
+
in_class = false
|
|
155
|
+
while i < n
|
|
156
|
+
ch = pattern[i]
|
|
157
|
+
if ch == "\\" && i + 1 < n
|
|
158
|
+
if pattern[i + 1] == "c" && i + 2 < n && pattern[i + 2].match?(/[A-Za-z]/)
|
|
159
|
+
out << Regexp.escape((pattern[i + 2].upcase.ord - 64).chr)
|
|
160
|
+
i += 3
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
out << pattern[i, 2]
|
|
164
|
+
i += 2
|
|
165
|
+
next
|
|
166
|
+
end
|
|
167
|
+
if in_class
|
|
168
|
+
in_class = ch != "]"
|
|
169
|
+
elsif ch == "["
|
|
170
|
+
if pattern[i, 3] == "[^]"
|
|
171
|
+
out << "[\\s\\S]"
|
|
172
|
+
i += 3
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
in_class = true
|
|
176
|
+
elsif ch == "^"
|
|
177
|
+
out << "\\A"
|
|
178
|
+
i += 1
|
|
179
|
+
next
|
|
180
|
+
elsif ch == "$"
|
|
181
|
+
out << "\\z"
|
|
182
|
+
i += 1
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
out << ch
|
|
186
|
+
i += 1
|
|
187
|
+
end
|
|
188
|
+
out
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A regex test that never raises: a value in an encoding the pattern cannot read (a binary
|
|
192
|
+
# PATH_INFO with high bytes, say) reads as "no match" rather than an exception on the request path.
|
|
193
|
+
def self.regex_hit?(rx, value)
|
|
194
|
+
rx.match?(value)
|
|
195
|
+
rescue StandardError
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The same envelope for the plain string ops: Puma hands every env value to Rack as
|
|
200
|
+
# ASCII-8BIT while a rule's needle arrives from JSON as UTF-8, and `include?` /
|
|
201
|
+
# `start_with?` across that pair raise Encoding::CompatibilityError once either side carries a
|
|
202
|
+
# high byte. The adapter scrubs its values, but the matcher must not depend on it: an
|
|
203
|
+
# exception here escapes match(), handle() falls back to INERT and a blocked client passes.
|
|
204
|
+
def self.text_hit?
|
|
205
|
+
yield
|
|
206
|
+
rescue StandardError
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ---------- custom rules (v5) ----------
|
|
211
|
+
|
|
212
|
+
# The string one condition reads, or nil when this request cannot answer the field.
|
|
213
|
+
# `header` is not here: it needs the condition's own name, so compile_cond builds its reader.
|
|
214
|
+
def self.field_value(f, r)
|
|
215
|
+
case f
|
|
216
|
+
when "asn" then r.asn&.to_s
|
|
217
|
+
when "country" then Camada.present(r.country)
|
|
218
|
+
when "tlsx" then Camada.present(r.tlsx)
|
|
219
|
+
when "path" then r.path
|
|
220
|
+
when "ua" then Camada.present(r.ua)
|
|
221
|
+
end
|
|
222
|
+
# an entity-plane field (bot.verified, rule) answers nil: never true here
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# One condition -> a predicate. `sets` yields this rule's (v4, v6) section pair per ip
|
|
226
|
+
# condition, in condition order, so an ip condition consumes the next one.
|
|
227
|
+
def self.compile_cond(c, sets)
|
|
228
|
+
f = c["f"].to_s
|
|
229
|
+
op = c["op"].to_s
|
|
230
|
+
negate = op == "is_not" || op == "not_in"
|
|
231
|
+
# A header condition reads the request through the caller's getter. The name is lower-cased
|
|
232
|
+
# once, here; a tap that cannot read headers (no getter) and a header the request does not
|
|
233
|
+
# carry are both nil, and nil is false for every op — the rule simply does not fire (fail
|
|
234
|
+
# open, §A4). The getter is app code: one that raises, or answers something other than a
|
|
235
|
+
# String, is read as "no header" rather than allowed to take the whole match() down.
|
|
236
|
+
read =
|
|
237
|
+
if f == "header"
|
|
238
|
+
hname = c["name"].to_s.downcase
|
|
239
|
+
lambda do |r|
|
|
240
|
+
next nil if hname.empty? || r.header.nil?
|
|
241
|
+
|
|
242
|
+
v = begin
|
|
243
|
+
r.header.call(hname)
|
|
244
|
+
rescue StandardError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
v.is_a?(String) ? v : nil
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
->(r) { field_value(f, r) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if f == "ip"
|
|
254
|
+
p4, p6 = sets.empty? ? [EMPTY, EMPTY] : sets.shift
|
|
255
|
+
n6 = p6.length >> 3
|
|
256
|
+
return lambda do |r|
|
|
257
|
+
next false if r.n4 < 0 && r.ip6.nil? # no address: false for every op, negatives included
|
|
258
|
+
|
|
259
|
+
hit = (r.n4 >= 0 && in_range4?(p4, r.n4)) || (!r.ip6.nil? && in_range6?(p6, n6, r.ip6))
|
|
260
|
+
negate ? !hit : hit
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
raw = c["v"]
|
|
264
|
+
values = raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s]
|
|
265
|
+
case op
|
|
266
|
+
when "matches"
|
|
267
|
+
rx = compile_regex(values[0])
|
|
268
|
+
lambda do |r|
|
|
269
|
+
v = read.call(r)
|
|
270
|
+
!v.nil? && !rx.nil? && regex_hit?(rx, v)
|
|
271
|
+
end
|
|
272
|
+
when "contains"
|
|
273
|
+
needle = values[0]
|
|
274
|
+
lambda do |r|
|
|
275
|
+
v = read.call(r)
|
|
276
|
+
!v.nil? && text_hit? { v.include?(needle) }
|
|
277
|
+
end
|
|
278
|
+
when "starts_with"
|
|
279
|
+
prefix = values[0]
|
|
280
|
+
lambda do |r|
|
|
281
|
+
v = read.call(r)
|
|
282
|
+
!v.nil? && text_hit? { v.start_with?(prefix) }
|
|
283
|
+
end
|
|
284
|
+
else # is | is_not | is_in | not_in
|
|
285
|
+
members = values.to_set
|
|
286
|
+
lambda do |r|
|
|
287
|
+
v = read.call(r)
|
|
288
|
+
next false if v.nil?
|
|
289
|
+
|
|
290
|
+
negate ? !members.include?(v) : members.include?(v)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# meta.rules + the repeated 14/15 sections -> predicates, in evaluation order. A rule this SDK
|
|
296
|
+
# cannot compile (unknown action, no conditions) is dropped rather than guessed at.
|
|
297
|
+
def self.compile_rules(meta, v4s, v6s)
|
|
298
|
+
out = []
|
|
299
|
+
(meta["rules"] || []).each_with_index do |r, i|
|
|
300
|
+
action = r["action"].to_s
|
|
301
|
+
next unless ACTIONS.include?(action) # an action this SDK does not know: ignore the rule rather than guess
|
|
302
|
+
|
|
303
|
+
v4 = v4s.select { |s| s[0] == i }
|
|
304
|
+
v6 = v6s.select { |s| s[0] == i }
|
|
305
|
+
sets = Array.new([v4.length, v6.length].max) do |k|
|
|
306
|
+
[k < v4.length ? v4[k].slice(1, v4[k].length - 1) : EMPTY, k < v6.length ? v6[k].slice(1, v6[k].length - 1) : EMPTY]
|
|
307
|
+
end
|
|
308
|
+
conds = begin
|
|
309
|
+
(r["conds"] || []).map { |c| compile_cond(c, sets) }
|
|
310
|
+
rescue StandardError
|
|
311
|
+
next # a malformed rule is dropped, never enforced
|
|
312
|
+
end
|
|
313
|
+
out << CompiledRule.new(r["id"].to_s, action, conds) unless conds.empty? # a rule with no conditions would match everything
|
|
314
|
+
end
|
|
315
|
+
out
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Parses a BLK container (a byte String, or a Words view into one) + meta into a Snap. Raises
|
|
319
|
+
# ArgumentError on a malformed container — callers keep the previous snapshot, exactly like
|
|
320
|
+
# the edge collector does.
|
|
321
|
+
def self.parse_snapshot(binary, meta)
|
|
322
|
+
u = binary.is_a?(Words) ? binary : Words.new(binary)
|
|
323
|
+
fmt = u.length >= 2 ? FORMATS[u[0]] : nil
|
|
324
|
+
raise ArgumentError, "camada: not a BLK3 snapshot" unless fmt
|
|
325
|
+
|
|
326
|
+
count = u[1]
|
|
327
|
+
sec = {}
|
|
328
|
+
rule4 = []
|
|
329
|
+
rule6 = []
|
|
330
|
+
raise ArgumentError, "camada: truncated BLK3 header" if u.length < 2 + (count * 3)
|
|
331
|
+
|
|
332
|
+
count.times do |i|
|
|
333
|
+
t = u[2 + (i * 3)]
|
|
334
|
+
off = u[3 + (i * 3)]
|
|
335
|
+
ln = u[4 + (i * 3)]
|
|
336
|
+
raise ArgumentError, "camada: truncated BLK3 section" if off + ln > u.length
|
|
337
|
+
|
|
338
|
+
s = u.slice(off, ln)
|
|
339
|
+
case t
|
|
340
|
+
when 14 then rule4 << s # repeated, one per ip condition: kept in container order
|
|
341
|
+
when 15 then rule6 << s
|
|
342
|
+
else sec[t] = s
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
s6 = sec.fetch(5, EMPTY)
|
|
346
|
+
Snap.new(
|
|
347
|
+
version: meta["version"].to_s,
|
|
348
|
+
format: fmt,
|
|
349
|
+
s4: sec.fetch(1, EMPTY), e4: sec.fetch(2, EMPTY), idx4: sec[3] || Zeros.new(65_537), bm4: sec[4] || Zeros.new(524_288),
|
|
350
|
+
s6: s6, e6: sec.fetch(6, EMPTY), n6: s6.length / 4, bm6: sec[7] || Zeros.new(524_288),
|
|
351
|
+
asn_bm: sec[8] || Zeros.new(131_072), asn_extra: sec.fetch(9, EMPTY),
|
|
352
|
+
country: (meta["country"] || []).to_set,
|
|
353
|
+
tls: (meta["tls"] || []).to_set,
|
|
354
|
+
paths_exact: (meta["pathsExact"] || []).to_set,
|
|
355
|
+
paths_prefix: (meta["pathsPrefix"] || []).to_set,
|
|
356
|
+
paths_regex: (meta["pathsRegex"] || []).filter_map { |p| compile_regex(p) },
|
|
357
|
+
allow: range_set(sec.fetch(10, EMPTY), sec.fetch(11, EMPTY), meta["allow"]),
|
|
358
|
+
challenge: range_set(sec.fetch(12, EMPTY), sec.fetch(13, EMPTY), meta["challenge"]),
|
|
359
|
+
rules: compile_rules(meta, rule4, rule6)
|
|
360
|
+
)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "stringio"
|
|
7
|
+
|
|
8
|
+
module Camada
|
|
9
|
+
# The one HTTP seam. The engine, the snapshot client and the event queue speak to the analyst
|
|
10
|
+
# through a transport (anything responding to #call(HttpRequest) -> HttpResponse), so tests
|
|
11
|
+
# inject an in-process fake and production uses Net::HTTP. A transport never raises: a network
|
|
12
|
+
# failure is a status-0 response, which every caller treats as "keep what we have".
|
|
13
|
+
HttpRequest = Struct.new(:method, :url, :headers, :body, :timeout_s, keyword_init: true)
|
|
14
|
+
HttpResponse = Struct.new(
|
|
15
|
+
:status, # 0 when the request never got an answer
|
|
16
|
+
:headers, # lower-cased names
|
|
17
|
+
:body, # a binary String
|
|
18
|
+
keyword_init: true
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
module Transport
|
|
22
|
+
# Net::HTTP over the stdlib, gzip-aware (GET /snapshot ships ~5 MB that gzips to a few KB).
|
|
23
|
+
# Net::HTTP negotiates and decodes gzip on its own as long as the caller does not set
|
|
24
|
+
# accept-encoding by hand (that switches decode_content off), so that header is dropped here
|
|
25
|
+
# and the response is inflated below should a server still label it.
|
|
26
|
+
def self.net_http(req)
|
|
27
|
+
uri = URI.parse(req.url)
|
|
28
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
29
|
+
http.use_ssl = uri.scheme == "https"
|
|
30
|
+
http.open_timeout = req.timeout_s
|
|
31
|
+
http.read_timeout = req.timeout_s
|
|
32
|
+
http.write_timeout = req.timeout_s
|
|
33
|
+
r = Net::HTTPGenericRequest.new(req.method, !req.body.nil?, true, uri.request_uri)
|
|
34
|
+
(req.headers || {}).each { |k, v| r[k] = v unless k.downcase == "accept-encoding" }
|
|
35
|
+
r.body = req.body if req.body
|
|
36
|
+
res = http.start { |h| h.request(r) }
|
|
37
|
+
headers = {}
|
|
38
|
+
res.each_header { |k, v| headers[k.downcase] = v }
|
|
39
|
+
response(res.code.to_i, headers, (res.body || "").b)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
HttpResponse.new(status: 0, headers: {}, body: "".b)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.response(status, headers, body)
|
|
45
|
+
if headers["content-encoding"].to_s.downcase == "gzip"
|
|
46
|
+
begin
|
|
47
|
+
body = Zlib::GzipReader.new(StringIO.new(body)).read.b
|
|
48
|
+
rescue StandardError
|
|
49
|
+
return HttpResponse.new(status: 0, headers: headers, body: "".b) # a body we cannot read is no answer at all
|
|
50
|
+
end
|
|
51
|
+
headers.delete("content-encoding")
|
|
52
|
+
end
|
|
53
|
+
HttpResponse.new(status: status, headers: headers, body: body)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
DEFAULT = method(:net_http)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Camada
|
|
4
|
+
# The SDK's wire identity: x-camada-sdk: @camada/ruby/<version>. This literal is the single
|
|
5
|
+
# source: camada.gemspec reads it at build, and the sibling drift guards (camada-backend,
|
|
6
|
+
# camada-web, camada-mkt) parse this file with /^\s*VERSION = "([^"]+)"/m the way they parse
|
|
7
|
+
# a package.json. Plain X.Y.Z only: the analyst's SDK_RE drops anything else.
|
|
8
|
+
VERSION = "0.1.0"
|
|
9
|
+
SDK_ID = "@camada/ruby/#{VERSION}".freeze
|
|
10
|
+
end
|
data/lib/camada.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# camada for Ruby: inline enforcement of your tenant snapshot (ordered custom rules, then
|
|
4
|
+
# allow, block, challenge), a first-party proof-of-work challenge and beacon, app-context events,
|
|
5
|
+
# and batched wire events shipped off the request path. Fails open by design.
|
|
6
|
+
#
|
|
7
|
+
# Quickstart (env: CAMADA_KEY, plus CAMADA_INGEST_URL in dev):
|
|
8
|
+
#
|
|
9
|
+
# # config.ru — any Rack app (Sinatra, Hanami, plain Rack)
|
|
10
|
+
# use Camada::Rack
|
|
11
|
+
# run App
|
|
12
|
+
#
|
|
13
|
+
# # Rails: nothing to add — the Railtie inserts Camada::Rack first
|
|
14
|
+
#
|
|
15
|
+
# # in a route
|
|
16
|
+
# Camada.script_tag(env) # the beacon tag for your HTML
|
|
17
|
+
# Camada.track(env, "login_failed", user: email) # an outcome your handler knows
|
|
18
|
+
# halt(*Camada.serve_challenge(env)) if ... # gate a route yourself
|
|
19
|
+
require_relative "camada/version"
|
|
20
|
+
require_relative "camada/constants"
|
|
21
|
+
require_relative "camada/config"
|
|
22
|
+
require_relative "camada/env"
|
|
23
|
+
require_relative "camada/redact"
|
|
24
|
+
require_relative "camada/ipparse"
|
|
25
|
+
require_relative "camada/ip"
|
|
26
|
+
require_relative "camada/guarded"
|
|
27
|
+
require_relative "camada/transport"
|
|
28
|
+
require_relative "camada/snapshot/parse"
|
|
29
|
+
require_relative "camada/snapshot/match"
|
|
30
|
+
require_relative "camada/snapshot/client"
|
|
31
|
+
require_relative "camada/events/build"
|
|
32
|
+
require_relative "camada/events/queue"
|
|
33
|
+
require_relative "camada/challenge/format"
|
|
34
|
+
require_relative "camada/challenge/verify"
|
|
35
|
+
require_relative "camada/challenge/page"
|
|
36
|
+
require_relative "camada/beacon_js"
|
|
37
|
+
require_relative "camada/engine"
|
|
38
|
+
require_relative "camada/body_proxy"
|
|
39
|
+
require_relative "camada/rack"
|
|
40
|
+
require_relative "camada/railtie"
|
|
41
|
+
|
|
42
|
+
module Camada
|
|
43
|
+
@default = nil
|
|
44
|
+
@default_lock = Mutex.new
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# The lazy singleton wired from the environment on first use (what the middleware shares).
|
|
48
|
+
def default(**opts)
|
|
49
|
+
@default_lock.synchronize { @default ||= create_engine(**opts) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Replaces the default engine (stopping the old one) — for tests and explicit wiring.
|
|
53
|
+
def configure(**opts)
|
|
54
|
+
@default_lock.synchronize do
|
|
55
|
+
@default&.stop
|
|
56
|
+
@default = create_engine(**opts)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stops and forgets the default engine; the next request builds a new one (tests).
|
|
61
|
+
def reset!
|
|
62
|
+
@default_lock.synchronize do
|
|
63
|
+
@default&.stop
|
|
64
|
+
@default = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The engine that produced a request context, else the default: what the helpers resolve through.
|
|
69
|
+
def engine_for(ctx)
|
|
70
|
+
engine_of(ctx) || default
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The helpers take the Rack env (Sinatra's `env`, Rails' `request.env`); the middleware left
|
|
74
|
+
# the request context in env["camada"]. Each stands down silently when the middleware did not run.
|
|
75
|
+
def script_tag(env)
|
|
76
|
+
ctx = ctx_of(env)
|
|
77
|
+
engine_for(ctx).script_tag(ctx)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def track(env, event, user: nil)
|
|
81
|
+
ctx = ctx_of(env)
|
|
82
|
+
engine_for(ctx).track(ctx, event, user: user)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A Rack triple [status, headers, [body]] to answer with (Sinatra: `halt(*answer)`) until the
|
|
86
|
+
# browser holds a valid _cch, then nil.
|
|
87
|
+
def serve_challenge(env)
|
|
88
|
+
ctx = ctx_of(env)
|
|
89
|
+
engine_for(ctx).serve_challenge(ctx)&.to_rack
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def ctx_of(env)
|
|
95
|
+
ctx = env.is_a?(Hash) ? env["camada"] : nil
|
|
96
|
+
ctx.is_a?(Hash) ? ctx : nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: camada
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- camada
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'camada SDK for Ruby: inline enforcement of your snapshot (ordered custom
|
|
14
|
+
rules, then allow, block, challenge), first-party beacon and proof-of-work challenge,
|
|
15
|
+
batched event shipping. A Rack middleware for any Rack app (Sinatra, Rails via the
|
|
16
|
+
Railtie, Hanami, plain Rack).'
|
|
17
|
+
email:
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/camada.rb
|
|
25
|
+
- lib/camada/beacon_js.rb
|
|
26
|
+
- lib/camada/body_proxy.rb
|
|
27
|
+
- lib/camada/challenge/format.rb
|
|
28
|
+
- lib/camada/challenge/page.rb
|
|
29
|
+
- lib/camada/challenge/verify.rb
|
|
30
|
+
- lib/camada/config.rb
|
|
31
|
+
- lib/camada/constants.rb
|
|
32
|
+
- lib/camada/engine.rb
|
|
33
|
+
- lib/camada/env.rb
|
|
34
|
+
- lib/camada/events/build.rb
|
|
35
|
+
- lib/camada/events/queue.rb
|
|
36
|
+
- lib/camada/guarded.rb
|
|
37
|
+
- lib/camada/ip.rb
|
|
38
|
+
- lib/camada/ipparse.rb
|
|
39
|
+
- lib/camada/rack.rb
|
|
40
|
+
- lib/camada/railtie.rb
|
|
41
|
+
- lib/camada/redact.rb
|
|
42
|
+
- lib/camada/snapshot/client.rb
|
|
43
|
+
- lib/camada/snapshot/match.rb
|
|
44
|
+
- lib/camada/snapshot/parse.rb
|
|
45
|
+
- lib/camada/transport.rb
|
|
46
|
+
- lib/camada/version.rb
|
|
47
|
+
homepage: https://github.com/camada-app/camada-ruby
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
52
|
+
homepage_uri: https://github.com/camada-app/camada-ruby
|
|
53
|
+
source_code_uri: https://github.com/camada-app/camada-ruby
|
|
54
|
+
post_install_message:
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.1'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.5.22
|
|
70
|
+
signing_key:
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: 'camada SDK for Ruby: inline enforcement of your snapshot, first-party beacon
|
|
73
|
+
and challenge, batched events'
|
|
74
|
+
test_files: []
|