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.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../config"
5
+ require_relative "../constants"
6
+ require_relative "../guarded"
7
+ require_relative "../transport"
8
+ require_relative "match"
9
+ require_relative "parse"
10
+
11
+ module Camada
12
+ module Snapshot
13
+ # Client: the single-tenant port of the edge collector's snapshot lifecycle over the
14
+ # GET /snapshot contract (ported from @camada/core src/snapshot/client.ts):
15
+ # 200 [u32 LE meta-length][meta JSON][BLK container] + etag + x-camada-config
16
+ # 304 nothing changed; config header repeated (config refreshes every poll for free)
17
+ # 204 authenticated, no snapshot published -> enforce nothing, fail open
18
+ # Semantics ported exactly: single-in-flight load; loaded_at stamped even on 204 (retry per
19
+ # poll cadence, not per request); any error keeps the previous snapshot; cold = fail open.
20
+ # Timers are threads here: timer mode runs one thread per client sleeping on a condition
21
+ # variable; lazy mode kicks a one-shot thread from ensure_fresh so the request path never
22
+ # waits on the network. Ruby has no register_at_fork, so every entry point compares
23
+ # Process.pid with the pid that built the client and starts over in a forked worker.
24
+ COLD = MatchResult.new(reason: "cold") # never loaded yet: fail open, mirrors the collector
25
+ NONE = MatchResult.new
26
+
27
+ class Client
28
+ attr_accessor :transport
29
+ attr_reader :url, :token, :matcher, :config, :refresh_s, :mode, :sdk, :snapshot_version, :timeout_s, :poll_thread
30
+
31
+ def initialize(url, token, refresh_s: nil, timeout_s: 3.0, mode: :timer, transport: nil, sdk: nil,
32
+ snapshot_version: DEFAULT_SNAPSHOT_VERSION)
33
+ # refresh_s: leave unset and the server's poll_seconds steers it; set it and it is pinned.
34
+ # sdk: '<package>/<version>', sent as x-camada-sdk on every poll (SDK-03).
35
+ # snapshot_version: 5 asks for the custom rules too; 4 the sides only; 3 opts out of both.
36
+ @url = url
37
+ @token = token
38
+ @timeout_s = timeout_s
39
+ @mode = mode
40
+ @sdk = sdk
41
+ @snapshot_version = snapshot_version
42
+ @transport = transport || Transport::DEFAULT
43
+ @matcher = nil
44
+ @config = nil
45
+ @refresh_s = refresh_s.nil? ? DEFAULT_REFRESH_S : refresh_s.to_f
46
+ @pinned = !refresh_s.nil?
47
+ @etag = nil
48
+ @loaded_at = nil
49
+ fresh_state!
50
+ end
51
+
52
+ # Threads do not survive fork (Puma cluster preload_app!, Unicorn, Passenger): forget the
53
+ # parent's, then re-arm the timer so the child polls on its own (lazy mode refreshes from
54
+ # the request path anyway).
55
+ def after_fork!
56
+ fresh_state!
57
+ start if @mode == :timer
58
+ end
59
+
60
+ def start
61
+ ensure_fresh
62
+ return if @mode != :timer || !@poll_thread.nil?
63
+
64
+ @stopped = false
65
+ @poll_thread = Thread.new { run }
66
+ @poll_thread.name = "camada-snapshot"
67
+ @poll_thread.report_on_exception = false
68
+ end
69
+
70
+ def stop
71
+ @stop_m.synchronize do
72
+ @stopped = true
73
+ @stop_cv.broadcast
74
+ end
75
+ @poll_thread = nil
76
+ end
77
+
78
+ # 0.9 x refresh so a timer tick arriving at ~refresh-ε still refreshes; a full-interval
79
+ # comparison makes every other tick a no-op (effective cadence 2x).
80
+ def stale?
81
+ @loaded_at.nil? || monotonic - @loaded_at > @refresh_s * 0.9
82
+ end
83
+
84
+ # Kicks a refresh when stale; never blocks the request path, never raises.
85
+ def ensure_fresh
86
+ check_fork!
87
+ return if !stale? || @loading.locked?
88
+
89
+ t = Thread.new { refresh }
90
+ t.name = "camada-snapshot-load"
91
+ t.report_on_exception = false
92
+ nil
93
+ end
94
+
95
+ # One synchronous poll (single in-flight): what the threads call, and what tests and warm-ups call directly.
96
+ def refresh
97
+ check_fork!
98
+ lock = @loading # bound once: after_fork! swaps the attribute
99
+ return unless lock.try_lock
100
+
101
+ begin
102
+ load_once
103
+ rescue StandardError => e # a poll that can never succeed must not be silent, nor fatal
104
+ Guarded.log_rate_limited(e)
105
+ ensure
106
+ lock.unlock
107
+ end
108
+ end
109
+
110
+ # Cold (never loaded) and no-snapshot both fail open, mirroring the edge collector.
111
+ def verdict(i)
112
+ return COLD if @loaded_at.nil?
113
+
114
+ m = @matcher
115
+ m ? m.match(i) : NONE
116
+ end
117
+
118
+ private
119
+
120
+ def fresh_state!
121
+ @pid = Process.pid
122
+ @loading = Mutex.new
123
+ @stop_m = Mutex.new
124
+ @stop_cv = ConditionVariable.new
125
+ @stopped = false
126
+ @poll_thread = nil
127
+ end
128
+
129
+ def check_fork!
130
+ after_fork! if Process.pid != @pid
131
+ end
132
+
133
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+
135
+ # Sleeps one cadence; true when stop was called meanwhile.
136
+ def wait_stop(seconds)
137
+ @stop_m.synchronize do
138
+ @stop_cv.wait(@stop_m, seconds) unless @stopped
139
+ @stopped
140
+ end
141
+ end
142
+
143
+ def run
144
+ ensure_fresh until wait_stop(@refresh_s)
145
+ end
146
+
147
+ def load_once
148
+ headers = { "authorization" => "Bearer #{@token}", "accept-encoding" => "gzip" }
149
+ headers["if-none-match"] = @etag if @etag
150
+ headers["x-camada-sdk"] = @sdk if @sdk
151
+ # a tenant without that container is answered with the next one down
152
+ headers["x-camada-snapshot"] = @snapshot_version.to_s if @snapshot_version > 3
153
+ res = @transport.call(HttpRequest.new(method: "GET", url: @url, headers: headers, body: nil, timeout_s: @timeout_s))
154
+ return unless [200, 204, 304].include?(res.status) # 401/5xx/network: keep what we have
155
+
156
+ @loaded_at = monotonic
157
+ read_config(res.headers["x-camada-config"])
158
+ return if res.status == 304
159
+
160
+ if res.status == 204 # no snapshot published: enforce nothing
161
+ @matcher = nil
162
+ @etag = nil
163
+ return
164
+ end
165
+ body = res.body
166
+ raise ArgumentError, "camada: truncated snapshot frame" if body.bytesize < 4
167
+
168
+ meta_len = body.unpack1("V")
169
+ raise ArgumentError, "camada: truncated snapshot frame" if 4 + meta_len > body.bytesize
170
+
171
+ meta = JSON.parse(body.byteslice(4, meta_len))
172
+ raise ArgumentError, "camada: snapshot meta is not an object" unless meta.is_a?(Hash)
173
+
174
+ # The server ships the v3, v4 and v5 bodies of one publish under the SAME meta.version and
175
+ # different etags, so version alone cannot say "nothing changed".
176
+ etag = res.headers["etag"]
177
+ return if @matcher && meta["version"].to_s == @matcher.snap.version && !etag.nil? && etag == @etag
178
+
179
+ # parse_snapshot raises on corrupt data -> caught by refresh, previous kept
180
+ @matcher = Matcher.new(Snapshot.parse_snapshot(Words.new(body, 4 + meta_len), meta))
181
+ @etag = etag
182
+ end
183
+
184
+ def read_config(raw)
185
+ return if raw.nil? || raw.empty?
186
+
187
+ cfg = Camada.parse_json(raw)
188
+ return unless cfg.is_a?(Hash) # not an object, or not JSON: keep the previous config
189
+
190
+ @config = cfg
191
+ # the server steers the poll cadence per tenant (its cost lever) unless the client pinned one
192
+ secs = Float(cfg["poll_seconds"] || 0, exception: false)
193
+ return if @pinned || secs.nil? || !secs.finite? || secs < 5 || secs == @refresh_s # JSON admits 1e999
194
+
195
+ @refresh_s = secs
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parse"
4
+
5
+ module Camada
6
+ module Snapshot
7
+ # Matcher: sub-millisecond checks over a parsed Snap, ported from @camada/core
8
+ # src/snapshot/match.ts (itself from edge-analyst src/blocklist.js). Matching is fully
9
+ # synchronous and allocation-light. The original's per-instance scratch request is not ported:
10
+ # a JS isolate runs one match() at a time, but here one Matcher serves every request thread,
11
+ # so the rule loop reads a RuleRequest built per call.
12
+ #
13
+ # Outcome order is contract (contracts §D3, fixtures pin it): the tenant's ordered custom rules
14
+ # first (first match wins, the order IS the precedence), then allow -> block -> challenge.
15
+ # Within each side the axis order is ip4 -> ip6 -> asn -> country -> tls -> path.
16
+ # At the SDK position only ip, path, ua and the request headers are usually known;
17
+ # asn/country/tlsx entries and conditions then simply never match — that is the documented,
18
+ # honest enforcement scope (fail open, never guess).
19
+ MatchInput = Struct.new(
20
+ :ip, :asn, :country, :tlsx, :path,
21
+ :ua, # v5 rules read it; the three sides never do
22
+ :header, # v5 header conditions read it, always with a lower-cased name
23
+ keyword_init: true
24
+ )
25
+
26
+ class MatchResult
27
+ attr_reader :block, :challenge, :allowed, :warn, :action, :rule, :reason, :version
28
+
29
+ # allowed: true for skip (which absorbed the old allow) and for the allow side;
30
+ # action: the action of the rule that decided, nil when a side did;
31
+ # rule: the rule id, present only when reason is 'rule';
32
+ # reason: ip4 | ip6 | asn | country | tls | path | rule | cold.
33
+ def initialize(block: false, challenge: false, allowed: false, warn: false, action: nil, rule: nil, reason: nil, version: nil)
34
+ @block = block
35
+ @challenge = challenge
36
+ @allowed = allowed
37
+ @warn = warn
38
+ @action = action
39
+ @rule = rule
40
+ @reason = reason
41
+ @version = version
42
+ freeze
43
+ end
44
+ end
45
+
46
+ def self.clean_path(raw)
47
+ p = raw.nil? || raw.empty? ? "/" : raw
48
+ q = p.index("?")
49
+ q.nil? ? p : p[0, q]
50
+ end
51
+
52
+ # Walks every '/'-terminated ancestor of `path`, the way the block side does.
53
+ def self.prefix_hit?(prefixes, path)
54
+ i = path.index("/", 1)
55
+ until i.nil?
56
+ return true if prefixes.include?(path[0, i + 1])
57
+
58
+ i = path.index("/", i + 1)
59
+ end
60
+ false
61
+ end
62
+
63
+ # A rule decided this request (§D3): at most one of allowed / block / challenge / warn is
64
+ # true, `reason` is 'rule', and `rule` names the id the adapters stamp on the event.
65
+ def self.rule_result(rule, version)
66
+ a = rule.action
67
+ MatchResult.new(
68
+ block: a == "block", challenge: a == "challenge", allowed: a == "skip", warn: a == "warn",
69
+ action: a, rule: rule.id, reason: "rule", version: version
70
+ )
71
+ end
72
+
73
+ class Matcher
74
+ attr_reader :snap
75
+
76
+ def initialize(snap)
77
+ @snap = snap
78
+ end
79
+
80
+ def match(i)
81
+ s = @snap
82
+ ip = i.ip || ""
83
+ n4 = -1
84
+ w = nil
85
+ unless ip.empty?
86
+ if ip.include?(":")
87
+ w = IpParse.parse_ip6(ip)
88
+ else
89
+ n4 = IpParse.parse_ip4(ip)
90
+ end
91
+ end
92
+ unless s.rules.empty?
93
+ r = RuleRequest.new(n4: n4, ip6: w, asn: i.asn, country: i.country, tlsx: i.tlsx,
94
+ path: Snapshot.clean_path(i.path), ua: i.ua, header: i.header)
95
+ s.rules.each do |rule| # the order IS the precedence (§A4): first match wins
96
+ return Snapshot.rule_result(rule, s.version) if rule.conds.all? { |cond| cond.call(r) }
97
+ end
98
+ end
99
+ reason = side(s.allow, i, n4, w)
100
+ return MatchResult.new(allowed: true, reason: reason, version: s.version) if reason
101
+
102
+ reason = block_side(i, n4, w)
103
+ return MatchResult.new(block: true, reason: reason, version: s.version) if reason
104
+
105
+ reason = side(s.challenge, i, n4, w)
106
+ return MatchResult.new(challenge: true, reason: reason, version: s.version) if reason
107
+
108
+ MatchResult.new(version: s.version)
109
+ end
110
+
111
+ private
112
+
113
+ def blocked4?(n)
114
+ s = @snap
115
+ b = n >> 8
116
+ return false if (s.bm4[b >> 5] >> (b & 31)) & 1 == 0
117
+
118
+ hi = n >> 16
119
+ left = s.idx4[hi]
120
+ right = s.idx4[hi + 1] - 1
121
+ left -= 1 if left > 0
122
+ return false if right < left
123
+
124
+ s4 = s.s4
125
+ while left < right
126
+ m = (left + right + 1) >> 1
127
+ if s4[m] <= n
128
+ left = m
129
+ else
130
+ right = m - 1
131
+ end
132
+ end
133
+ n.between?(s4[left], s.e4[left])
134
+ end
135
+
136
+ def words_at(a, o) = [a[o], a[o + 1], a[o + 2], a[o + 3]]
137
+
138
+ def blocked6?(w)
139
+ s = @snap
140
+ b = w[0] >> 8
141
+ return false if (s.bm6[b >> 5] >> (b & 31)) & 1 == 0
142
+
143
+ left = 0
144
+ right = s.n6 - 1
145
+ return false if right < 0
146
+
147
+ s6 = s.s6
148
+ while left < right
149
+ m = (left + right + 1) >> 1
150
+ if (words_at(s6, m * 4) <=> w) <= 0
151
+ left = m
152
+ else
153
+ right = m - 1
154
+ end
155
+ end
156
+ o = left * 4
157
+ (words_at(s6, o) <=> w) <= 0 && (w <=> words_at(s.e6, o)) <= 0
158
+ end
159
+
160
+ def blocked_asn?(asn)
161
+ s = @snap
162
+ return (s.asn_bm[asn >> 5] >> (asn & 31)) & 1 != 0 if asn < 4_194_304
163
+
164
+ extra = s.asn_extra
165
+ left = 0
166
+ right = extra.length - 1
167
+ while left <= right
168
+ m = (left + right) >> 1
169
+ v = extra[m]
170
+ return true if v == asn
171
+
172
+ if v < asn
173
+ left = m + 1
174
+ else
175
+ right = m - 1
176
+ end
177
+ end
178
+ false
179
+ end
180
+
181
+ def blocked_path?(path)
182
+ s = @snap
183
+ return true if s.paths_exact.include?(path)
184
+ return true if !s.paths_prefix.empty? && Snapshot.prefix_hit?(s.paths_prefix, path)
185
+
186
+ s.paths_regex.any? { |rx| Snapshot.regex_hit?(rx, path) }
187
+ end
188
+
189
+ # The block side: v3 sections plus the top-level meta.
190
+ def block_side(i, n4, w)
191
+ s = @snap
192
+ return "ip4" if n4 >= 0 && blocked4?(n4)
193
+ return "ip6" if !w.nil? && blocked6?(w)
194
+ return "asn" if !i.asn.nil? && blocked_asn?(i.asn)
195
+ return "country" if Camada.present(i.country) && !s.country.empty? && s.country.include?(i.country)
196
+ return "tls" if Camada.present(i.tlsx) && s.tls.include?(i.tlsx)
197
+ if (!s.paths_exact.empty? || !s.paths_prefix.empty? || !s.paths_regex.empty?) && blocked_path?(Snapshot.clean_path(i.path))
198
+ return "path"
199
+ end
200
+
201
+ nil
202
+ end
203
+
204
+ # A v4 side list (allow or challenge). No tls axis: §A3's side meta has no tls key.
205
+ def side(st, i, n4, w)
206
+ return nil if st.empty # the common v3 snapshot
207
+ return "ip4" if n4 >= 0 && Snapshot.in_range4?(st.r4, n4)
208
+ return "ip6" if !w.nil? && Snapshot.in_range6?(st.r6, st.n6, w)
209
+ return "asn" if !i.asn.nil? && st.asn.include?(i.asn)
210
+ return "country" if Camada.present(i.country) && st.country.include?(i.country)
211
+
212
+ if !st.paths_exact.empty? || !st.paths_prefix.empty?
213
+ p = Snapshot.clean_path(i.path)
214
+ return "path" if st.paths_exact.include?(p)
215
+ return "path" if !st.paths_prefix.empty? && Snapshot.prefix_hit?(st.paths_prefix, p)
216
+ end
217
+ nil
218
+ end
219
+ end
220
+ end
221
+ end