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,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "beacon_js"
|
|
6
|
+
require_relative "challenge/format"
|
|
7
|
+
require_relative "challenge/page"
|
|
8
|
+
require_relative "challenge/verify"
|
|
9
|
+
require_relative "constants"
|
|
10
|
+
require_relative "env"
|
|
11
|
+
require_relative "events/build"
|
|
12
|
+
require_relative "events/queue"
|
|
13
|
+
require_relative "guarded"
|
|
14
|
+
require_relative "ip"
|
|
15
|
+
require_relative "redact"
|
|
16
|
+
require_relative "snapshot/client"
|
|
17
|
+
require_relative "snapshot/match"
|
|
18
|
+
require_relative "version"
|
|
19
|
+
|
|
20
|
+
module Camada
|
|
21
|
+
# What an adapter hands the engine. Header names are lower-cased; the list keeps the order the
|
|
22
|
+
# host gave (the Rack env's order). `route` is the matched route pattern, set by the adapter at
|
|
23
|
+
# finish time when the host knows it.
|
|
24
|
+
class Req
|
|
25
|
+
attr_reader :method, :path, :query, :host, :http_version, :peer, :https, :headers
|
|
26
|
+
attr_accessor :route
|
|
27
|
+
|
|
28
|
+
def initialize(method:, path:, query: "", host: "", http_version: nil, peer: nil, https: false, headers: [], route: nil)
|
|
29
|
+
@method = method
|
|
30
|
+
@path = path # no query
|
|
31
|
+
@query = query # with the leading '?', or ''
|
|
32
|
+
@host = host
|
|
33
|
+
@http_version = http_version
|
|
34
|
+
@peer = peer # the socket peer the host vouches for
|
|
35
|
+
@https = https
|
|
36
|
+
@headers = headers # [[lowercased name, value], ...]
|
|
37
|
+
@route = route
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A header the client repeated is joined the way node:http does it: cookies with '; ' (HTTP/2
|
|
41
|
+
# clients split them into several fields; cookie_value looks for '; name='), the rest with ', '.
|
|
42
|
+
def header(name)
|
|
43
|
+
vals = @headers.filter_map { |k, v| v if k == name }
|
|
44
|
+
return nil if vals.empty?
|
|
45
|
+
|
|
46
|
+
vals.join(name == "cookie" ? "; " : ", ")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# camada answered the request; the adapter writes exactly this. `headers` is a Hash of
|
|
51
|
+
# lower-cased names (Rack 3 style), `body` a String.
|
|
52
|
+
Answer = Struct.new(:status, :headers, :body, keyword_init: true) do
|
|
53
|
+
# The Rack triple. content-length is stamped except where a body is forbidden (1xx, 204,
|
|
54
|
+
# 304): Rack::Lint — in every `rackup` development stack — rejects it there, and the beacon's
|
|
55
|
+
# 204 would 500 on every page in dev.
|
|
56
|
+
def to_rack
|
|
57
|
+
h = status < 200 || status == 204 || status == 304 ? headers : headers.merge("content-length" => body.bytesize.to_s)
|
|
58
|
+
[status, h, [body]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Run the app. rid/set_cookie ride the response; ctx is stored on the host request
|
|
63
|
+
# (env["camada"]); on_finish.call(status) is called once at the end. A nil on_finish means inert.
|
|
64
|
+
Passed = Struct.new(:rid, :set_cookie, :ctx, :on_finish, keyword_init: true)
|
|
65
|
+
|
|
66
|
+
INERT = Passed.new(rid: nil, set_cookie: nil, ctx: nil, on_finish: nil).freeze
|
|
67
|
+
|
|
68
|
+
def self.cookie_value(cookie, name)
|
|
69
|
+
src = "; #{cookie}"
|
|
70
|
+
i = src.index("; #{name}=")
|
|
71
|
+
return nil if i.nil?
|
|
72
|
+
|
|
73
|
+
start = i + name.length + 3
|
|
74
|
+
j = src.index(";", start)
|
|
75
|
+
j.nil? ? src[start..] : src[start...j]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The engine: the host-neutral request handling every adapter delegates to (the Ruby twin of
|
|
79
|
+
# camada-python's Camada class). An adapter turns its request into a Req, asks wants_body and
|
|
80
|
+
# reads at most that many bytes, then calls handle: an Answer means camada fully answered the
|
|
81
|
+
# request (block, challenge, verify, beacon endpoints); a Passed means run the app, stamp the
|
|
82
|
+
# rid header and session cookie on its response, and call on_finish(status) once when it is
|
|
83
|
+
# done. Everything runs inside the fail-open envelope: a camada bug must never 5xx the customer,
|
|
84
|
+
# and CAMADA_DISABLED=1 bypasses the SDK entirely.
|
|
85
|
+
class Engine
|
|
86
|
+
CHALLENGE_HEADERS = { "cache-control" => "no-store", "x-camada-challenge" => "1" }.freeze
|
|
87
|
+
|
|
88
|
+
attr_reader :env, :snap, :queue, :kit, :script_path, :fp_path, :challenge_path, :challenge_on
|
|
89
|
+
|
|
90
|
+
def initialize(env: ENV, transport: nil, refresh_s: nil, script_path: SCRIPT_PATH, fp_path: FP_PATH,
|
|
91
|
+
challenge: true, challenge_path: CHALLENGE_PATH, snapshot_version: DEFAULT_SNAPSHOT_VERSION)
|
|
92
|
+
# env: where CAMADA_* are read from (ENV, or a Hash); transport: threaded into the snapshot
|
|
93
|
+
# client and event queue (tests); refresh_s: pinned poll cadence; challenge: enforce
|
|
94
|
+
# `challenge` verdicts with the first-party page (CAMADA_CHALLENGE=0 also off).
|
|
95
|
+
@env_source = env
|
|
96
|
+
@script_path = script_path
|
|
97
|
+
@fp_path = fp_path
|
|
98
|
+
@challenge_path = challenge_path
|
|
99
|
+
@challenge_on = challenge && env["CAMADA_CHALLENGE"] != "0"
|
|
100
|
+
@env = Env.resolve(env)
|
|
101
|
+
@snap = nil
|
|
102
|
+
@queue = nil
|
|
103
|
+
@kit = nil
|
|
104
|
+
return if @env.nil? || env[KILL_SWITCH_ENV] == "1" # unconfigured or killed at boot: no threads, no exit hooks, truly silent
|
|
105
|
+
|
|
106
|
+
@snap = Snapshot::Client.new(
|
|
107
|
+
@env.snapshot_url, @env.snap_token, refresh_s: refresh_s, mode: @env.serverless ? :lazy : :timer,
|
|
108
|
+
transport: transport, sdk: SDK_ID, snapshot_version: snapshot_version
|
|
109
|
+
)
|
|
110
|
+
@queue = Events::Queue.new(@env.ingest_url, @env.ingest_token, transport: transport, sdk: SDK_ID)
|
|
111
|
+
@kit = Challenge.create_challenge(@env.secret)
|
|
112
|
+
@snap.start
|
|
113
|
+
@queue.install_exit_flush
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def disabled?
|
|
117
|
+
@env.nil? || @env_source[KILL_SWITCH_ENV] == "1"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def now_ms = Events.now_ms
|
|
121
|
+
|
|
122
|
+
# ---- the adapter contract ----
|
|
123
|
+
|
|
124
|
+
# The byte cap to read the body under, when camada itself may answer this request.
|
|
125
|
+
def wants_body(method, path)
|
|
126
|
+
return nil if disabled? || method != "POST"
|
|
127
|
+
return FP_MAX if path == @fp_path && beacon_enabled?
|
|
128
|
+
return BODY_MAX if path == @challenge_path && @challenge_on
|
|
129
|
+
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Never raises. `body` is the request body when wants_body asked for one, or nil when the
|
|
134
|
+
# adapter refused to read it (declared or actual size over the cap).
|
|
135
|
+
def handle(req, body = nil)
|
|
136
|
+
decide(req, body)
|
|
137
|
+
rescue StandardError => e # a camada bug costs the join, never the request
|
|
138
|
+
Guarded.log_rate_limited(e)
|
|
139
|
+
INERT
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# For HTML templates: the first-party beacon tag with the request's rid.
|
|
143
|
+
def script_tag(ctx)
|
|
144
|
+
return "" if disabled? || !beacon_enabled?
|
|
145
|
+
|
|
146
|
+
rid = ctx && ctx["rid"]
|
|
147
|
+
%(<script src="#{@script_path}#{"?r=#{rid}" if rid}" async></script>)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Serve the challenge for this request on demand — for a route the app wants to gate itself.
|
|
151
|
+
# nil when the client already holds a valid _cch (render your own page), or when the client
|
|
152
|
+
# cannot be identified (fail open).
|
|
153
|
+
def serve_challenge(ctx)
|
|
154
|
+
return nil if disabled? || @kit.nil? || ctx.nil?
|
|
155
|
+
|
|
156
|
+
req = ctx["_req"]
|
|
157
|
+
ip = ctx["ip"]
|
|
158
|
+
return nil if req.nil? || ip.nil? || ip.empty? || challenge_passed?(req, ip)
|
|
159
|
+
|
|
160
|
+
ctx["challenged"] = true
|
|
161
|
+
serve_challenge_answer(req, ip, ctx["sid"])
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
Guarded.log_rate_limited(e)
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# App-context outcome events (login failed, signup, ...). The identifier is HMAC-hashed
|
|
168
|
+
# in-process; the raw value never reaches the queue.
|
|
169
|
+
def track(ctx, event, user: nil)
|
|
170
|
+
return if disabled? || @queue.nil?
|
|
171
|
+
|
|
172
|
+
uid = user.nil? || user.to_s.empty? ? nil : Redact.hash_user_id(user.to_s, @env.ingest_token)
|
|
173
|
+
c = ctx || {}
|
|
174
|
+
row = { "tap" => TAP, "et" => event, "uid" => uid, "rid" => c["rid"], "sid" => c["sid"], "ip" => c["ip"], "ts" => now_ms }
|
|
175
|
+
@queue.push(row)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
Guarded.log_rate_limited(e)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def stop
|
|
181
|
+
@snap&.stop
|
|
182
|
+
@queue&.stop
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def trusted_proxy
|
|
188
|
+
return @env.trusted_proxy if @env && !@env.trusted_proxy.nil? # explicit local override wins
|
|
189
|
+
|
|
190
|
+
@snap && (@snap.config || {})["trusted_proxy"]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def beacon_enabled?
|
|
194
|
+
!@snap.nil? && (@snap.config || {})["beacon"] != false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def client_ip(req)
|
|
198
|
+
Ip.resolve_client_ip(req.peer, req.header("x-forwarded-for"), trusted_proxy)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def secure?(req) = req.https || req.header("x-forwarded-proto") == "https"
|
|
202
|
+
|
|
203
|
+
def decide(req, body)
|
|
204
|
+
return INERT if disabled? || @snap.nil?
|
|
205
|
+
|
|
206
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
207
|
+
@snap.ensure_fresh
|
|
208
|
+
ip = client_ip(req)
|
|
209
|
+
|
|
210
|
+
# Enforce before anything else, beacon endpoints included — fail open while cold. The
|
|
211
|
+
# custom rules read the user agent and the request headers (§D3).
|
|
212
|
+
v = @snap.verdict(Snapshot::MatchInput.new(ip: ip, path: req.path, ua: req.header("user-agent"), header: ->(n) { req.header(n) }))
|
|
213
|
+
if v.block
|
|
214
|
+
headers = { "content-type" => "text/plain", "x-block-reason" => v.reason || "", "x-block-version" => v.version || "" }
|
|
215
|
+
headers["x-block-rule"] = v.rule if v.rule # a custom rule blocked: name it, so the customer knows which row to edit
|
|
216
|
+
ev = event(req, SecureRandom.uuid, nil, false, ip)
|
|
217
|
+
ev["st"] = 403 # blocked requests always ship: silent expiry makes blocks oscillate
|
|
218
|
+
ev["blk"] = v.reason # the reason rides the event so the analyst counts SDK blocks, not the app's own 403s
|
|
219
|
+
ev["rl"] = v.rule if v.rule
|
|
220
|
+
@queue.push(ev)
|
|
221
|
+
return Answer.new(status: 403, headers: headers, body: "Forbidden")
|
|
222
|
+
end
|
|
223
|
+
# `warn` passes the request and only marks its event (below, on finish); a skip passes
|
|
224
|
+
# with nothing stamped at all — it is the absence of enforcement.
|
|
225
|
+
|
|
226
|
+
# A challenge needs a resolved client IP: the nonce and the _cch cookie are bound to it,
|
|
227
|
+
# so without one a single solve would mint a cookie every unidentified client could
|
|
228
|
+
# present. No ip -> no challenge (fail open), the same stance ip rules take.
|
|
229
|
+
if @challenge_on && ip && !ip.empty?
|
|
230
|
+
# The verify endpoint answers first: a challenged client must be able to reach it.
|
|
231
|
+
return verify(req, body, ip) if req.method == "POST" && req.path == @challenge_path
|
|
232
|
+
if v.challenge && !challenge_passed?(req, ip)
|
|
233
|
+
return serve_challenge_answer(req, ip, Camada.cookie_value(req.header("cookie"), SESSION_COOKIE))
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
if beacon_enabled?
|
|
238
|
+
if req.method == "GET" && req.path == @script_path
|
|
239
|
+
return Answer.new(status: 200, headers: { "content-type" => "application/javascript", "cache-control" => "public, max-age=3600" },
|
|
240
|
+
body: BEACON_JS)
|
|
241
|
+
end
|
|
242
|
+
return relay_beacon(body, ip) if req.method == "POST" && req.path == @fp_path
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
rid = SecureRandom.uuid
|
|
246
|
+
sid = Camada.cookie_value(req.header("cookie"), SESSION_COOKIE)
|
|
247
|
+
new_session = sid.nil? || sid.empty?
|
|
248
|
+
set_cookie = nil
|
|
249
|
+
if new_session
|
|
250
|
+
sid = SecureRandom.uuid
|
|
251
|
+
set_cookie = "#{SESSION_COOKIE}=#{sid}; Path=/; Max-Age=#{SESSION_MAX_AGE}; HttpOnly; SameSite=Lax"
|
|
252
|
+
set_cookie += "; Secure" if secure?(req)
|
|
253
|
+
end
|
|
254
|
+
ctx = { "rid" => rid, "sid" => sid, "ip" => ip, "_req" => req, "_engine" => self }
|
|
255
|
+
|
|
256
|
+
cfg = @snap.config || {}
|
|
257
|
+
excluded = (cfg["exclude"] || []).any? { |x| req.path.start_with?(x.to_s) }
|
|
258
|
+
sample = cfg["sample"]
|
|
259
|
+
sampled = rand < (sample.nil? ? 1.0 : sample.to_f) # sampling, not crypto
|
|
260
|
+
warn_rule = v.warn ? v.rule : nil
|
|
261
|
+
|
|
262
|
+
on_finish = lambda do |status|
|
|
263
|
+
# serve_challenge may have answered from inside the app, and it already shipped the
|
|
264
|
+
# `blk: "challenge"` row — one request, one event.
|
|
265
|
+
next if ctx["challenged"] || excluded || !sampled
|
|
266
|
+
|
|
267
|
+
ev = event(req, rid, sid, new_session, ip)
|
|
268
|
+
ev["st"] = status
|
|
269
|
+
ev["dur"] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).to_i
|
|
270
|
+
ev["rt"] = req.route if req.route
|
|
271
|
+
ev["wrn"] = warn_rule if warn_rule # §D3: the warn rule that let this request through
|
|
272
|
+
@queue.push(ev)
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
Guarded.log_rate_limited(e)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
Passed.new(rid: rid, set_cookie: set_cookie, ctx: ctx, on_finish: on_finish)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def event(req, rid, sid, new_session, ip)
|
|
281
|
+
info = Events::RequestInfo.new(method: req.method, host: req.host, path: req.path, query: req.query, headers: req.headers,
|
|
282
|
+
ip: ip, http_version: req.http_version)
|
|
283
|
+
Events.build_wire_event(info, tap: TAP, rid: rid, sid: sid, new_session: new_session)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# ---- beacon ----
|
|
287
|
+
|
|
288
|
+
# Answers 204, and queues the beacon as a `sig: 1` row with the trusted-proxy-resolved
|
|
289
|
+
# client IP: it rides the next event batch. Junk bodies are dropped, never shipped.
|
|
290
|
+
def relay_beacon(body, ip)
|
|
291
|
+
return Answer.new(status: 413, headers: {}, body: "") if body.nil?
|
|
292
|
+
|
|
293
|
+
answer = Answer.new(status: 204, headers: { "cache-control" => "no-store" }, body: "")
|
|
294
|
+
parsed = Camada.parse_json(body)
|
|
295
|
+
return answer unless parsed.is_a?(Hash)
|
|
296
|
+
|
|
297
|
+
@queue.push(parsed.merge("sig" => 1, "ip" => ip, "tap" => TAP)) # spread first: ip and tap are the server's word
|
|
298
|
+
answer
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# ---- challenge ----
|
|
302
|
+
|
|
303
|
+
def challenge_passed?(req, ip)
|
|
304
|
+
!@kit.nil? && @kit.token_valid?(ip, now_ms, Camada.cookie_value(req.header("cookie"), Challenge::CHALLENGE_COOKIE))
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def page(ip, to)
|
|
308
|
+
html = Challenge.challenge_page(nonce: @kit.nonce(ip, now_ms), action: @challenge_path, to: to)
|
|
309
|
+
Answer.new(status: 403, headers: CHALLENGE_HEADERS.merge("content-type" => "text/html; charset=utf-8"), body: html)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
|
|
313
|
+
# `blk: "challenge"` event — a served challenge is reported like a block (contract §D2).
|
|
314
|
+
def serve_challenge_answer(req, ip, sid)
|
|
315
|
+
to = Challenge.safe_return_to(req.path + req.query)
|
|
316
|
+
answer =
|
|
317
|
+
if Challenge.wants_html?(req.header("accept"), req.header("sec-fetch-dest"))
|
|
318
|
+
page(ip, to)
|
|
319
|
+
else
|
|
320
|
+
Answer.new(status: 403, headers: CHALLENGE_HEADERS.merge("content-type" => "application/json"),
|
|
321
|
+
body: '{"error":"challenge_required"}')
|
|
322
|
+
end
|
|
323
|
+
begin
|
|
324
|
+
ev = event(req, SecureRandom.uuid, sid, false, ip)
|
|
325
|
+
ev["st"] = 403
|
|
326
|
+
ev["blk"] = "challenge"
|
|
327
|
+
@queue.push(ev)
|
|
328
|
+
rescue StandardError => e # the response is decided; telemetry must never undo that
|
|
329
|
+
Guarded.log_rate_limited(e)
|
|
330
|
+
end
|
|
331
|
+
answer
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# POST from the challenge page: validate the nonce and the proof of work, set _cch, 302
|
|
335
|
+
# back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`.
|
|
336
|
+
def verify(req, body, ip)
|
|
337
|
+
return Answer.new(status: 413, headers: {}, body: "") if body.nil?
|
|
338
|
+
|
|
339
|
+
form = Challenge.parse_form_body(body)
|
|
340
|
+
to = Challenge.safe_return_to(form["to"])
|
|
341
|
+
now = now_ms
|
|
342
|
+
return page(ip, to) unless @kit.verify?(ip, now, form["nonce"], form["solution"])
|
|
343
|
+
|
|
344
|
+
cookie = Challenge.challenge_cookie(@kit.issue(ip, now), secure?(req))
|
|
345
|
+
ev = event(req, SecureRandom.uuid, Camada.cookie_value(req.header("cookie"), SESSION_COOKIE), false, ip)
|
|
346
|
+
ev["st"] = 200
|
|
347
|
+
ev["ch"] = 1 # challenge passed (contract §A3 ingest field)
|
|
348
|
+
@queue.push(ev)
|
|
349
|
+
Answer.new(status: 302, headers: { "location" => to, "set-cookie" => cookie, "cache-control" => "no-store" }, body: "")
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The engine that produced a request context (the helpers resolve script_tag/track through it).
|
|
354
|
+
def self.engine_of(ctx)
|
|
355
|
+
eng = ctx && ctx["_engine"]
|
|
356
|
+
eng.is_a?(Engine) ? eng : nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def self.create_engine(**opts)
|
|
360
|
+
e = Engine.new(**opts)
|
|
361
|
+
Guarded.log_rate_limited("CAMADA_KEY (or CAMADA_TOKEN + CAMADA_SNAPSHOT_TOKEN) not set — camada is inactive") if e.env.nil?
|
|
362
|
+
e
|
|
363
|
+
end
|
|
364
|
+
end
|
data/lib/camada/env.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "constants"
|
|
5
|
+
|
|
6
|
+
module Camada
|
|
7
|
+
# Environment wiring. The two-line quickstart depends on this doing the right thing:
|
|
8
|
+
# CAMADA_KEY=<ingest_token>.<snap_token> (printed by `reconcile instructions` and seed)
|
|
9
|
+
# CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL (dev: http://localhost:8787[/snapshot])
|
|
10
|
+
# CAMADA_DISABLED=1 kill switch, checked at boot and per request
|
|
11
|
+
# CAMADA_SERVERLESS=1 lazy snapshot mode (no poll thread)
|
|
12
|
+
# CAMADA_TRUSTED_PROXY local override: none | vercel | hops:N | cidrs:a,b
|
|
13
|
+
# CAMADA_CHALLENGE=0 do not enforce challenge verdicts
|
|
14
|
+
Env = Struct.new(
|
|
15
|
+
:ingest_token, :snap_token,
|
|
16
|
+
:secret, # HMAC key for the challenge nonce/cookie — never leaves the process
|
|
17
|
+
:ingest_url, :snapshot_url,
|
|
18
|
+
:serverless,
|
|
19
|
+
:trusted_proxy, # nil = defer to server-delivered config
|
|
20
|
+
keyword_init: true
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
class Env
|
|
24
|
+
# PLACEHOLDER default, the same one @camada/node and camada-python carry — confirm the
|
|
25
|
+
# production ingest domain before any RubyGems publish.
|
|
26
|
+
DEFAULT_INGEST_URL = "https://in.camada.dev"
|
|
27
|
+
|
|
28
|
+
# nil (SDK stays inert, one log line) rather than raising on bad config. `env` is anything
|
|
29
|
+
# answering #[] with strings: ENV, or a Hash.
|
|
30
|
+
def self.resolve(env)
|
|
31
|
+
key = Config.parse_key(env["CAMADA_KEY"])
|
|
32
|
+
ingest_token = key ? key[0] : env["CAMADA_TOKEN"]
|
|
33
|
+
snap_token = key ? key[1] : env["CAMADA_SNAPSHOT_TOKEN"]
|
|
34
|
+
return nil if Camada.present(ingest_token).nil? || Camada.present(snap_token).nil?
|
|
35
|
+
|
|
36
|
+
ingest_url = (Camada.present(env["CAMADA_INGEST_URL"]) || DEFAULT_INGEST_URL).sub(%r{/+\z}, "")
|
|
37
|
+
new(
|
|
38
|
+
ingest_token: ingest_token, snap_token: snap_token,
|
|
39
|
+
secret: Camada.present(env["CAMADA_KEY"]) || "#{ingest_token}.#{snap_token}",
|
|
40
|
+
ingest_url: ingest_url,
|
|
41
|
+
snapshot_url: Camada.present(env["CAMADA_SNAPSHOT_URL"]) || "#{ingest_url}/snapshot",
|
|
42
|
+
serverless: env["CAMADA_SERVERLESS"] == "1",
|
|
43
|
+
trusted_proxy: Config.parse_trusted_proxy_env(env["CAMADA_TRUSTED_PROXY"])
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../redact"
|
|
4
|
+
|
|
5
|
+
module Camada
|
|
6
|
+
# Wire-event builder: reproduces the collector's record() (edge-analyst
|
|
7
|
+
# workers/collector/edge-collector.js) from a normalized request, so events are comparable
|
|
8
|
+
# across taps. HDRS bit order is pinned by the shared fixture (hdrs.json) — never reorder.
|
|
9
|
+
module Events
|
|
10
|
+
HDRS = %w[
|
|
11
|
+
accept accept-language accept-encoding sec-fetch-site sec-fetch-mode sec-fetch-dest
|
|
12
|
+
sec-fetch-user sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform upgrade-insecure-requests dnt
|
|
13
|
+
cache-control pragma referer origin cookie authorization x-requested-with content-type
|
|
14
|
+
via x-forwarded-for priority sec-purpose save-data te if-modified-since if-none-match
|
|
15
|
+
].freeze
|
|
16
|
+
HDR_BIT = HDRS.each_with_index.to_h { |name, i| [name, 1 << i] }.freeze
|
|
17
|
+
|
|
18
|
+
# A schemeless header (`Authorization: <raw token>`) has no safe prefix: the first "word" IS
|
|
19
|
+
# the credential. Only a real auth-scheme token followed by a space ever ships.
|
|
20
|
+
SCHEME_RE = /\A[A-Za-z0-9!#$%&'*+.^_`|~-]{1,16}\z/
|
|
21
|
+
|
|
22
|
+
RequestInfo = Struct.new(
|
|
23
|
+
:method, :host, :path,
|
|
24
|
+
:query, # includes the leading '?', or empty
|
|
25
|
+
:headers, # [[name, value], ...] in the order the host gives them (the env's order under Rack)
|
|
26
|
+
:ip, # already resolved via trusted-proxy config
|
|
27
|
+
:http_version, # e.g. '1.1'
|
|
28
|
+
keyword_init: true
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def self.auth_scheme(value)
|
|
32
|
+
return nil if value.nil? || value.empty?
|
|
33
|
+
|
|
34
|
+
sp = value.index(" ")
|
|
35
|
+
return nil if sp.nil? || sp <= 0
|
|
36
|
+
|
|
37
|
+
scheme = value[0, sp]
|
|
38
|
+
SCHEME_RE.match?(scheme) ? scheme : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.now_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
42
|
+
|
|
43
|
+
# The mutable wire event (string keys, ready for JSON); the caller fills st/dur on
|
|
44
|
+
# response-finish before enqueueing.
|
|
45
|
+
def self.build_wire_event(r, tap:, rid:, sid: nil, new_session: false, ja4: nil)
|
|
46
|
+
mask = hn = hb = 0
|
|
47
|
+
cookie = +""
|
|
48
|
+
names = []
|
|
49
|
+
first = {}
|
|
50
|
+
(r.headers || []).each do |name, value|
|
|
51
|
+
k = name.downcase
|
|
52
|
+
hn += 1
|
|
53
|
+
hb += name.bytesize + value.bytesize # bytes on the wire, as the collector and the WSGI (latin-1) port count them
|
|
54
|
+
names << k
|
|
55
|
+
first[k] = value unless first.key?(k)
|
|
56
|
+
mask |= HDR_BIT.fetch(k, 0)
|
|
57
|
+
cookie << (cookie.empty? ? value : "; #{value}") if k == "cookie"
|
|
58
|
+
end
|
|
59
|
+
query = r.query || ""
|
|
60
|
+
qn = query.length > 1 ? query[1..].split("&").count { |p| !p.empty? } : 0
|
|
61
|
+
ev = {
|
|
62
|
+
"tap" => tap, "rid" => rid, "sid" => sid, "ns" => new_session ? 1 : 0, "ts" => now_ms,
|
|
63
|
+
"ip" => r.ip,
|
|
64
|
+
"proto" => r.http_version ? "HTTP/#{r.http_version}" : nil,
|
|
65
|
+
"m" => r.method, "h" => r.host, "p" => r.path, "q" => Redact.scrub_query(query)[0, 512], "qn" => qn,
|
|
66
|
+
"ct" => first["content-type"], "cl" => first["content-length"],
|
|
67
|
+
"ua" => first["user-agent"], "chua" => first["sec-ch-ua"],
|
|
68
|
+
"chmob" => first["sec-ch-ua-mobile"], "chplat" => first["sec-ch-ua-platform"],
|
|
69
|
+
"acc" => first["accept"], "lang" => first["accept-language"],
|
|
70
|
+
"fs" => first["sec-fetch-site"], "fm" => first["sec-fetch-mode"],
|
|
71
|
+
"fd" => first["sec-fetch-dest"], "fu" => first["sec-fetch-user"], "ref" => first["referer"], "org" => first["origin"],
|
|
72
|
+
"xrw" => first["x-requested-with"], "auth" => auth_scheme(first["authorization"]), # scheme only, never the credential
|
|
73
|
+
"hm" => mask, "hn" => hn, "hb" => hb, "ck" => cookie.empty? ? 0 : cookie.split(";", -1).length,
|
|
74
|
+
"hord" => names.join(",")[0, 2048] # header order as this host reports it (the env's order under Rack)
|
|
75
|
+
}
|
|
76
|
+
ev["ja4"] = ja4 if ja4
|
|
77
|
+
ev["st"] = nil
|
|
78
|
+
ev["dur"] = nil # 'dur': the collector wire already claims 'lat' for latitude
|
|
79
|
+
ev
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../guarded"
|
|
5
|
+
require_relative "../transport"
|
|
6
|
+
|
|
7
|
+
module Camada
|
|
8
|
+
module Events
|
|
9
|
+
# Queue: fire-and-forget batched shipping to POST /e (ported from @camada/core
|
|
10
|
+
# src/events/queue.ts). The collector ships one event per request; an in-process SDK batches,
|
|
11
|
+
# flushes on size or interval, and drains at exit — but the same law holds: NOTHING here may
|
|
12
|
+
# ever raise into the customer's request path, and a dead ingest must cost nothing but dropped
|
|
13
|
+
# telemetry. Defaults (15 s / 500): every flush is one request and one R2 put at the analyst,
|
|
14
|
+
# so the bill scales with instance count x flush cadence — not with traffic.
|
|
15
|
+
class Queue
|
|
16
|
+
attr_accessor :transport
|
|
17
|
+
attr_reader :url, :token, :max_batch, :max_queue, :flush_s, :timeout_s, :sdk, :dropped, :lock, :flush_thread
|
|
18
|
+
|
|
19
|
+
def initialize(url, token, max_batch: 500, max_queue: 2000, flush_s: 15.0, timeout_s: 2.0, transport: nil, sdk: nil)
|
|
20
|
+
# url: ingest base, e.g. https://analyst.example.com; token: ingest token (x-tenant header);
|
|
21
|
+
# max_batch: flush when the queue reaches this many (server caps at 1000); max_queue: drop-oldest beyond this;
|
|
22
|
+
# sdk: '<package>/<version>', sent as x-camada-sdk on every batch (SDK-03).
|
|
23
|
+
@url = url.sub(%r{/+\z}, "")
|
|
24
|
+
@token = token
|
|
25
|
+
@max_batch = max_batch
|
|
26
|
+
@max_queue = max_queue
|
|
27
|
+
@flush_s = flush_s
|
|
28
|
+
@timeout_s = timeout_s
|
|
29
|
+
@sdk = sdk
|
|
30
|
+
@transport = transport || Transport::DEFAULT
|
|
31
|
+
@dropped = 0 # debug counter, not an API promise
|
|
32
|
+
@exit_installed = false
|
|
33
|
+
fresh_state!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A forked worker inherits the queue but not its thread: start fresh on the next push. The
|
|
37
|
+
# parent keeps its pending events (and may have held the lock mid-flush), so the child starts empty.
|
|
38
|
+
def after_fork!
|
|
39
|
+
fresh_state!
|
|
40
|
+
@exit_installed = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def size = @q.length
|
|
44
|
+
def inflight? = @inflight.locked?
|
|
45
|
+
|
|
46
|
+
# Synchronous, never raises. Starts the flush thread lazily on first push; a stopped queue
|
|
47
|
+
# stays stopped (configure replaces the engine rather than reviving one).
|
|
48
|
+
def push(event)
|
|
49
|
+
check_fork!
|
|
50
|
+
n = 0
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
if @q.length >= @max_queue
|
|
53
|
+
@q.shift
|
|
54
|
+
@dropped += 1
|
|
55
|
+
end
|
|
56
|
+
@q << event
|
|
57
|
+
n = @q.length
|
|
58
|
+
if @flush_thread.nil? && !@stopped
|
|
59
|
+
@flush_thread = Thread.new { run }
|
|
60
|
+
@flush_thread.name = "camada-events"
|
|
61
|
+
@flush_thread.report_on_exception = false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
wake if n >= @max_batch
|
|
65
|
+
rescue StandardError => e # never into the request path
|
|
66
|
+
Guarded.log_rate_limited(e)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Drains the queue, <=1000 events per POST (the server slices there); single-in-flight;
|
|
70
|
+
# never raises. `wait` queues behind a flush already in flight instead of yielding to it —
|
|
71
|
+
# the exit drain needs the full queue gone, not just the batch someone else is posting.
|
|
72
|
+
def flush(wait: false)
|
|
73
|
+
check_fork!
|
|
74
|
+
inflight = @inflight # bound once: after_fork! swaps the attribute
|
|
75
|
+
return unless wait ? inflight.lock : inflight.try_lock
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
headers = { "x-tenant" => @token, "content-type" => "application/json" }
|
|
79
|
+
headers["x-camada-sdk"] = @sdk if @sdk
|
|
80
|
+
loop do
|
|
81
|
+
batch = @lock.synchronize { @q.shift([1000, @q.length].min) }
|
|
82
|
+
return if batch.empty?
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
body = encode(batch)
|
|
86
|
+
next if body.nil?
|
|
87
|
+
|
|
88
|
+
res = @transport.call(HttpRequest.new(method: "POST", url: "#{@url}/e", headers: headers, body: body, timeout_s: @timeout_s))
|
|
89
|
+
raise "ingest unreachable" if res.status == 0
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
@dropped += batch.length
|
|
92
|
+
# Dropping telemetry is by design, doing it silently is not: a mount that can never
|
|
93
|
+
# reach ingest looks identical to a healthy one otherwise.
|
|
94
|
+
Guarded.log_rate_limited(e)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
ensure
|
|
98
|
+
inflight.unlock
|
|
99
|
+
end
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Guarded.log_rate_limited(e)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def stop
|
|
105
|
+
@wake_m.synchronize do
|
|
106
|
+
@stopped = true
|
|
107
|
+
@woken = true
|
|
108
|
+
@wake_cv.broadcast
|
|
109
|
+
end
|
|
110
|
+
@flush_thread = nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Opt-in: drain at interpreter exit within a small budget. No signal handlers — an app owns
|
|
114
|
+
# its own shutdown; SIGTERM without a handler skips at_exit, which the README says out loud.
|
|
115
|
+
def install_exit_flush(budget_s = 0.5)
|
|
116
|
+
return if @exit_installed
|
|
117
|
+
|
|
118
|
+
@exit_installed = true
|
|
119
|
+
at_exit { drain(budget_s) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A full drain (behind any flush in flight) on its own thread, abandoned once the budget is spent.
|
|
123
|
+
def drain(budget_s = 0.5)
|
|
124
|
+
t = Thread.new { flush(wait: true) }
|
|
125
|
+
t.name = "camada-exit-flush"
|
|
126
|
+
t.report_on_exception = false
|
|
127
|
+
t.join(budget_s)
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def fresh_state!
|
|
134
|
+
@pid = Process.pid
|
|
135
|
+
@q = []
|
|
136
|
+
@lock = Mutex.new
|
|
137
|
+
@inflight = Mutex.new
|
|
138
|
+
@wake_m = Mutex.new
|
|
139
|
+
@wake_cv = ConditionVariable.new
|
|
140
|
+
@woken = false
|
|
141
|
+
@stopped = false
|
|
142
|
+
@flush_thread = nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def check_fork!
|
|
146
|
+
after_fork! if Process.pid != @pid
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The batch as JSON, or nil when nothing in it can be serialised. The adapters scrub what
|
|
150
|
+
# they ship, but a row that still carries an invalid byte must cost that row alone, never
|
|
151
|
+
# the batch: blocked rows always ship, or blocks oscillate.
|
|
152
|
+
def encode(batch)
|
|
153
|
+
JSON.generate(batch)
|
|
154
|
+
rescue JSON::GeneratorError, EncodingError
|
|
155
|
+
rows = batch.filter_map do |ev|
|
|
156
|
+
JSON.generate(ev)
|
|
157
|
+
rescue JSON::GeneratorError, EncodingError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
@dropped += batch.length - rows.length
|
|
161
|
+
rows.empty? ? nil : "[#{rows.join(",")}]"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def wake
|
|
165
|
+
@wake_m.synchronize do
|
|
166
|
+
@woken = true
|
|
167
|
+
@wake_cv.signal
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def run
|
|
172
|
+
until @stopped
|
|
173
|
+
@wake_m.synchronize do
|
|
174
|
+
@wake_cv.wait(@wake_m, @flush_s) unless @woken
|
|
175
|
+
@woken = false
|
|
176
|
+
end
|
|
177
|
+
return if @stopped
|
|
178
|
+
|
|
179
|
+
flush
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|