camada 0.2.0__tar.gz
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.
- camada-0.2.0/.gitignore +8 -0
- camada-0.2.0/LICENSE +21 -0
- camada-0.2.0/PKG-INFO +215 -0
- camada-0.2.0/README.md +193 -0
- camada-0.2.0/pyproject.toml +88 -0
- camada-0.2.0/src/camada/__init__.py +47 -0
- camada-0.2.0/src/camada/asgi.py +149 -0
- camada-0.2.0/src/camada/beacon_js.py +6 -0
- camada-0.2.0/src/camada/challenge/__init__.py +0 -0
- camada-0.2.0/src/camada/challenge/format.py +113 -0
- camada-0.2.0/src/camada/challenge/page.py +104 -0
- camada-0.2.0/src/camada/challenge/verify.py +75 -0
- camada-0.2.0/src/camada/config.py +59 -0
- camada-0.2.0/src/camada/constants.py +19 -0
- camada-0.2.0/src/camada/django.py +153 -0
- camada-0.2.0/src/camada/engine.py +383 -0
- camada-0.2.0/src/camada/env.py +46 -0
- camada-0.2.0/src/camada/events/__init__.py +0 -0
- camada-0.2.0/src/camada/events/build.py +89 -0
- camada-0.2.0/src/camada/events/queue.py +136 -0
- camada-0.2.0/src/camada/fastapi.py +38 -0
- camada-0.2.0/src/camada/flask.py +54 -0
- camada-0.2.0/src/camada/guarded.py +21 -0
- camada-0.2.0/src/camada/ip.py +86 -0
- camada-0.2.0/src/camada/ipparse.py +88 -0
- camada-0.2.0/src/camada/redact.py +62 -0
- camada-0.2.0/src/camada/snapshot/__init__.py +0 -0
- camada-0.2.0/src/camada/snapshot/client.py +167 -0
- camada-0.2.0/src/camada/snapshot/match.py +206 -0
- camada-0.2.0/src/camada/snapshot/parse.py +352 -0
- camada-0.2.0/src/camada/transport.py +57 -0
- camada-0.2.0/src/camada/version.py +6 -0
- camada-0.2.0/src/camada/wsgi.py +158 -0
camada-0.2.0/.gitignore
ADDED
camada-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 camada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
camada-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: camada
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: camada SDK for Python: inline enforcement of your snapshot (ordered custom rules, then allow, block, challenge), first-party beacon and proof-of-work challenge, batched event shipping. ASGI and WSGI middleware; Django, Flask and FastAPI integrations.
|
|
5
|
+
Project-URL: Homepage, https://camada.app
|
|
6
|
+
Project-URL: Repository, https://github.com/camada-app/camada-python
|
|
7
|
+
Project-URL: Issues, https://github.com/camada-app/camada-python/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Framework :: Flask
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# camada
|
|
24
|
+
|
|
25
|
+
camada for Python: enforces the tenant snapshot inline (your ordered custom rules, then allow,
|
|
26
|
+
block, challenge), serves a first-party proof-of-work challenge page and beacon, records the
|
|
27
|
+
outcomes your handlers know (`track()`), and ships wire events in batches off the request path.
|
|
28
|
+
One package on PyPI with an ASGI middleware (the first-class tier: it keeps the wire's header
|
|
29
|
+
order), a WSGI middleware, and integrations for FastAPI, Django and Flask — the `sentry-sdk`
|
|
30
|
+
model. Fails open by design: a camada outage or bug never 5xxes your app.
|
|
31
|
+
|
|
32
|
+
Not yet on PyPI — install it from a sibling checkout: `pip install -e ../camada-python` (or a uv
|
|
33
|
+
path dependency, as [`camada-python-example`](../camada-python-example) does); publishing is one
|
|
34
|
+
decision with the npm packages (SDK-G01). Python 3.10 or newer, no runtime dependencies.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# FastAPI / Starlette
|
|
40
|
+
from camada.fastapi import CamadaMiddleware
|
|
41
|
+
app.add_middleware(CamadaMiddleware) # last, so it runs outermost
|
|
42
|
+
|
|
43
|
+
# Django — settings.py
|
|
44
|
+
MIDDLEWARE = ["camada.django.CamadaMiddleware", *MIDDLEWARE] # first
|
|
45
|
+
|
|
46
|
+
# Flask
|
|
47
|
+
from camada.flask import init_app
|
|
48
|
+
init_app(app) # wraps app.wsgi_app, so camada answers before routing
|
|
49
|
+
|
|
50
|
+
# any ASGI 3 or WSGI app
|
|
51
|
+
from camada.asgi import CamadaASGI # application = CamadaASGI(application)
|
|
52
|
+
from camada.wsgi import CamadaWSGI # application = CamadaWSGI(application)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Env (printed by camada onboarding / `npm run seed` in dev):
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
CAMADA_KEY=<ingest_token>.<snap_token>
|
|
59
|
+
CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The integrations share one lazy engine built from the environment on the first request. That
|
|
63
|
+
build starts the snapshot poll on a thread and never blocks, so the request that triggered it is
|
|
64
|
+
answered cold: it passes (fail open), and so does anything else that arrives before that first
|
|
65
|
+
poll lands (a few hundred milliseconds against a local analyst; snapshot-size and network bound).
|
|
66
|
+
To enforce from request 1, warm the engine in a startup hook by waiting for the boot poll —
|
|
67
|
+
`snap.refresh()` alone is not it, the boot poll already holds the single-in-flight lock:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import time
|
|
71
|
+
import camada
|
|
72
|
+
from camada.snapshot.match import MatchInput
|
|
73
|
+
|
|
74
|
+
engine = camada.get_default() # builds the engine; the boot poll is already running on its thread
|
|
75
|
+
if engine.snap: # None when CAMADA_KEY is unset or CAMADA_DISABLED=1
|
|
76
|
+
deadline = time.monotonic() + 5
|
|
77
|
+
while engine.snap.verdict(MatchInput(ip="0.0.0.0")).reason == "cold" and time.monotonic() < deadline:
|
|
78
|
+
time.sleep(0.01) # bounded: an unreachable analyst leaves it cold, and the app still fails open
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`snap.refresh()` is not the warm-up: the boot poll holds the single-in-flight lock, so a
|
|
82
|
+
synchronous `refresh()` called right after `get_default()` returns at once and the engine is
|
|
83
|
+
still cold.
|
|
84
|
+
|
|
85
|
+
Without `CAMADA_KEY` the engine is inert (one log line, no requests, no enforcement). An app that
|
|
86
|
+
reads its own config builds the engine itself and hands it in:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from camada import Camada
|
|
90
|
+
engine = Camada(env={"CAMADA_KEY": MY_KEY, "CAMADA_INGEST_URL": MY_INGEST})
|
|
91
|
+
app.add_middleware(CamadaMiddleware, engine=engine) # init_app(app, engine=engine) for Flask
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## What it does per request
|
|
95
|
+
|
|
96
|
+
1. Keeps the snapshot fresh. A Python server is a long-lived process, so the default is a daemon
|
|
97
|
+
poll thread at the cadence your tenant config sets (`poll_seconds`), with ETag/304 and gzip on
|
|
98
|
+
the wire. `CAMADA_SERVERLESS=1` switches to a per-request staleness check with no thread. Every
|
|
99
|
+
poll and event batch carries `x-camada-sdk: @camada/python/<version>`, and polls ask for
|
|
100
|
+
snapshot v5 (`x-camada-snapshot: 5`) — the container that carries your ordered custom rules.
|
|
101
|
+
2. Resolves the client from the socket peer (`REMOTE_ADDR` / `scope["client"]`), combined with
|
|
102
|
+
`X-Forwarded-For` only under your tenant's trusted-proxy config (or `CAMADA_TRUSTED_PROXY`
|
|
103
|
+
locally). A forwarded header on its own is never the ip: any caller can set it.
|
|
104
|
+
3. Enforces before anything else, beacon endpoints included: your ordered custom rules first (first
|
|
105
|
+
match wins; they read ip, path, user-agent and request headers), then allow → block → challenge.
|
|
106
|
+
A block answers `403 Forbidden` with `x-block-reason`, `x-block-version` and, when a rule
|
|
107
|
+
decided, `x-block-rule`; its event ships with `blk` (and `rl`). A `warn` rule passes and stamps
|
|
108
|
+
`wrn`; a `skip` rule passes with nothing stamped. Cold (no snapshot yet) passes: fail open.
|
|
109
|
+
4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page (or 403 JSON for a
|
|
110
|
+
non-HTML request); `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
|
|
111
|
+
ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged.
|
|
112
|
+
5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
|
|
113
|
+
(≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
|
|
114
|
+
fall through to your app when the tenant switched the beacon off.
|
|
115
|
+
6. Runs your app with `x-rid` and the `_sfp` session cookie on its response, and when the response
|
|
116
|
+
is done ships one redacted event: method, host, path, scrubbed query, status, latency, header
|
|
117
|
+
names/sizes/order, the auth scheme (never the credential), cookie count (never values). An
|
|
118
|
+
exception in your app ships as `st: 500` and propagates unchanged.
|
|
119
|
+
|
|
120
|
+
## Options
|
|
121
|
+
|
|
122
|
+
`Camada(...)` keyword arguments; everything credential-shaped comes from the environment.
|
|
123
|
+
|
|
124
|
+
| option | default | meaning |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `env` | `os.environ` | where `CAMADA_*` are read from |
|
|
127
|
+
| `transport` | urllib | the HTTP callable that reaches the analyst (tests inject a fake) |
|
|
128
|
+
| `refresh_s` | server-steered | poll cadence; set, it is pinned |
|
|
129
|
+
| `challenge` | `True` | serve the proof-of-work page for challenge verdicts (`CAMADA_CHALLENGE=0` too) |
|
|
130
|
+
| `challenge_path` | `/__camada/challenge` | where the page posts its solution |
|
|
131
|
+
| `snapshot_version` | `5` | 4 drops your custom rules; 3 the allow/challenge sides too |
|
|
132
|
+
| `script_path` / `fp_path` | `/_cam/b.js` / `/_cam/fp` | the beacon endpoints; keep them in one directory |
|
|
133
|
+
|
|
134
|
+
Env: `CAMADA_KEY` (or `CAMADA_TOKEN` + `CAMADA_SNAPSHOT_TOKEN`), `CAMADA_INGEST_URL`,
|
|
135
|
+
`CAMADA_SNAPSHOT_URL`, `CAMADA_TRUSTED_PROXY` (`none | vercel | hops:N | cidrs:a,b`),
|
|
136
|
+
`CAMADA_SERVERLESS=1`, `CAMADA_CHALLENGE=0`, and the kill switch `CAMADA_DISABLED=1` (checked per
|
|
137
|
+
request; set at boot, no threads start at all).
|
|
138
|
+
|
|
139
|
+
## The first-party beacon
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from camada.fastapi import script_tag # camada.django: script_tag(request); camada.flask: script_tag()
|
|
143
|
+
return HTMLResponse(f"<html><head>{script_tag(request)}</head>…")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The tag is `<script src="/_cam/b.js?r=<rid>" async>`, so the beacon joins the page view that
|
|
147
|
+
served it. Move both paths with `script_path` / `fp_path` when `/_cam/` is not yours; the script
|
|
148
|
+
derives the post path from its own URL, so the two must share a directory.
|
|
149
|
+
|
|
150
|
+
## App-context events
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from camada.fastapi import track # camada.django: track(request, ...); camada.flask: track(event, user=...)
|
|
154
|
+
track(request, "login_failed", user=email)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The identifier is HMAC-hashed in-process with your ingest token; the raw value never reaches the
|
|
158
|
+
queue. `track()` never raises and is a no-op on a request the middleware did not run for. The
|
|
159
|
+
event name is free-form; the analyst's app-context rules read this vocabulary:
|
|
160
|
+
|
|
161
|
+
| event | when |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `login_failed` / `login_succeeded` | a login attempt settled; pass `user=` so attempts per account can be counted |
|
|
164
|
+
| `signup` | an account was created |
|
|
165
|
+
| `password_reset` | a reset was requested |
|
|
166
|
+
| `mfa_failed` | a second factor was rejected |
|
|
167
|
+
| `payment_failed` / `payment_succeeded` | a payment authorisation settled |
|
|
168
|
+
| `coupon_failed` | a promo/voucher code was rejected |
|
|
169
|
+
|
|
170
|
+
A route you gate yourself: `serve_challenge(request)` (Flask: `serve_challenge()`) returns the
|
|
171
|
+
page as a framework response to return from the handler until the browser holds a valid `_cch`,
|
|
172
|
+
then `None`.
|
|
173
|
+
|
|
174
|
+
## What this tap can see
|
|
175
|
+
|
|
176
|
+
`sdk-python` is an in-app tap: status, latency, session, the beacon's browser signals and your
|
|
177
|
+
outcomes. Under ASGI the event also carries the wire's header order (`hord`); WSGI's `environ`
|
|
178
|
+
loses it, which is why ASGI is the first-class tier. The analyst knows what this tap can see and
|
|
179
|
+
never scores the absence of header order, ASN, country or a TLS fingerprint against a request;
|
|
180
|
+
ASN and country it resolves itself. Enforcement at this position covers ip, path, user-agent and
|
|
181
|
+
header conditions — ASN, country and TLS entries fail open in-app. `matches` patterns are JS
|
|
182
|
+
regexes read by Python's `re` (named groups, `[^]` and `\cX` are translated, `\d`/`\w`/`\b` stay
|
|
183
|
+
ASCII); a spelling `re` still rejects never matches here, while it does at the edge.
|
|
184
|
+
|
|
185
|
+
## Deploying it
|
|
186
|
+
|
|
187
|
+
- Every worker process polls its own snapshot (about 5 MB resident) and flushes its own batches;
|
|
188
|
+
the tenant's `poll_seconds` keeps the cadence honest across a fleet.
|
|
189
|
+
- Threads do not survive a fork. Under gunicorn `--preload` or uWSGI the SDK notices the fork
|
|
190
|
+
(`os.register_at_fork`) and restarts its poll and flush threads in each worker.
|
|
191
|
+
- Pending events drain at interpreter exit within half a second. No signal handlers are
|
|
192
|
+
installed — an app owns its own shutdown — so a worker killed by SIGTERM without one may drop
|
|
193
|
+
its last batch.
|
|
194
|
+
- Serverless: `CAMADA_SERVERLESS=1`. A cold invocation fails open and catches up on the next one.
|
|
195
|
+
|
|
196
|
+
## Fail open
|
|
197
|
+
|
|
198
|
+
Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
|
|
199
|
+
most once a minute), a corrupt snapshot keeps the previous one, a bug in the package costs the
|
|
200
|
+
request its join, never its response. `CAMADA_DISABLED=1` bypasses everything.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
uv sync && uv run ruff check && uv run mypy && uv run pytest
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The suite reads the golden snapshot fixtures from the `camada-core` sibling checkout and pins the
|
|
209
|
+
vendored beacon to `camada-browser/dist/auto.global.js` (`npm run build` there first, then
|
|
210
|
+
`python scripts/sync_beacon.py` after a beacon release). Both fail by name when the checkout is
|
|
211
|
+
missing rather than skipping.
|
|
212
|
+
|
|
213
|
+
[`camada-python-example`](../camada-python-example) is the hand-test bench (FastAPI under uvicorn on
|
|
214
|
+
:3002), and `node scripts/e2e-sdk-python.mjs` in `camada/edge-analyst` drives it against a seeded
|
|
215
|
+
local analyst over real HTTP, cold first request included.
|
camada-0.2.0/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# camada
|
|
2
|
+
|
|
3
|
+
camada for Python: enforces the tenant snapshot inline (your ordered custom rules, then allow,
|
|
4
|
+
block, challenge), serves a first-party proof-of-work challenge page and beacon, records the
|
|
5
|
+
outcomes your handlers know (`track()`), and ships wire events in batches off the request path.
|
|
6
|
+
One package on PyPI with an ASGI middleware (the first-class tier: it keeps the wire's header
|
|
7
|
+
order), a WSGI middleware, and integrations for FastAPI, Django and Flask — the `sentry-sdk`
|
|
8
|
+
model. Fails open by design: a camada outage or bug never 5xxes your app.
|
|
9
|
+
|
|
10
|
+
Not yet on PyPI — install it from a sibling checkout: `pip install -e ../camada-python` (or a uv
|
|
11
|
+
path dependency, as [`camada-python-example`](../camada-python-example) does); publishing is one
|
|
12
|
+
decision with the npm packages (SDK-G01). Python 3.10 or newer, no runtime dependencies.
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
# FastAPI / Starlette
|
|
18
|
+
from camada.fastapi import CamadaMiddleware
|
|
19
|
+
app.add_middleware(CamadaMiddleware) # last, so it runs outermost
|
|
20
|
+
|
|
21
|
+
# Django — settings.py
|
|
22
|
+
MIDDLEWARE = ["camada.django.CamadaMiddleware", *MIDDLEWARE] # first
|
|
23
|
+
|
|
24
|
+
# Flask
|
|
25
|
+
from camada.flask import init_app
|
|
26
|
+
init_app(app) # wraps app.wsgi_app, so camada answers before routing
|
|
27
|
+
|
|
28
|
+
# any ASGI 3 or WSGI app
|
|
29
|
+
from camada.asgi import CamadaASGI # application = CamadaASGI(application)
|
|
30
|
+
from camada.wsgi import CamadaWSGI # application = CamadaWSGI(application)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Env (printed by camada onboarding / `npm run seed` in dev):
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
CAMADA_KEY=<ingest_token>.<snap_token>
|
|
37
|
+
CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The integrations share one lazy engine built from the environment on the first request. That
|
|
41
|
+
build starts the snapshot poll on a thread and never blocks, so the request that triggered it is
|
|
42
|
+
answered cold: it passes (fail open), and so does anything else that arrives before that first
|
|
43
|
+
poll lands (a few hundred milliseconds against a local analyst; snapshot-size and network bound).
|
|
44
|
+
To enforce from request 1, warm the engine in a startup hook by waiting for the boot poll —
|
|
45
|
+
`snap.refresh()` alone is not it, the boot poll already holds the single-in-flight lock:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import time
|
|
49
|
+
import camada
|
|
50
|
+
from camada.snapshot.match import MatchInput
|
|
51
|
+
|
|
52
|
+
engine = camada.get_default() # builds the engine; the boot poll is already running on its thread
|
|
53
|
+
if engine.snap: # None when CAMADA_KEY is unset or CAMADA_DISABLED=1
|
|
54
|
+
deadline = time.monotonic() + 5
|
|
55
|
+
while engine.snap.verdict(MatchInput(ip="0.0.0.0")).reason == "cold" and time.monotonic() < deadline:
|
|
56
|
+
time.sleep(0.01) # bounded: an unreachable analyst leaves it cold, and the app still fails open
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`snap.refresh()` is not the warm-up: the boot poll holds the single-in-flight lock, so a
|
|
60
|
+
synchronous `refresh()` called right after `get_default()` returns at once and the engine is
|
|
61
|
+
still cold.
|
|
62
|
+
|
|
63
|
+
Without `CAMADA_KEY` the engine is inert (one log line, no requests, no enforcement). An app that
|
|
64
|
+
reads its own config builds the engine itself and hands it in:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from camada import Camada
|
|
68
|
+
engine = Camada(env={"CAMADA_KEY": MY_KEY, "CAMADA_INGEST_URL": MY_INGEST})
|
|
69
|
+
app.add_middleware(CamadaMiddleware, engine=engine) # init_app(app, engine=engine) for Flask
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## What it does per request
|
|
73
|
+
|
|
74
|
+
1. Keeps the snapshot fresh. A Python server is a long-lived process, so the default is a daemon
|
|
75
|
+
poll thread at the cadence your tenant config sets (`poll_seconds`), with ETag/304 and gzip on
|
|
76
|
+
the wire. `CAMADA_SERVERLESS=1` switches to a per-request staleness check with no thread. Every
|
|
77
|
+
poll and event batch carries `x-camada-sdk: @camada/python/<version>`, and polls ask for
|
|
78
|
+
snapshot v5 (`x-camada-snapshot: 5`) — the container that carries your ordered custom rules.
|
|
79
|
+
2. Resolves the client from the socket peer (`REMOTE_ADDR` / `scope["client"]`), combined with
|
|
80
|
+
`X-Forwarded-For` only under your tenant's trusted-proxy config (or `CAMADA_TRUSTED_PROXY`
|
|
81
|
+
locally). A forwarded header on its own is never the ip: any caller can set it.
|
|
82
|
+
3. Enforces before anything else, beacon endpoints included: your ordered custom rules first (first
|
|
83
|
+
match wins; they read ip, path, user-agent and request headers), then allow → block → challenge.
|
|
84
|
+
A block answers `403 Forbidden` with `x-block-reason`, `x-block-version` and, when a rule
|
|
85
|
+
decided, `x-block-rule`; its event ships with `blk` (and `rl`). A `warn` rule passes and stamps
|
|
86
|
+
`wrn`; a `skip` rule passes with nothing stamped. Cold (no snapshot yet) passes: fail open.
|
|
87
|
+
4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page (or 403 JSON for a
|
|
88
|
+
non-HTML request); `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
|
|
89
|
+
ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged.
|
|
90
|
+
5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
|
|
91
|
+
(≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
|
|
92
|
+
fall through to your app when the tenant switched the beacon off.
|
|
93
|
+
6. Runs your app with `x-rid` and the `_sfp` session cookie on its response, and when the response
|
|
94
|
+
is done ships one redacted event: method, host, path, scrubbed query, status, latency, header
|
|
95
|
+
names/sizes/order, the auth scheme (never the credential), cookie count (never values). An
|
|
96
|
+
exception in your app ships as `st: 500` and propagates unchanged.
|
|
97
|
+
|
|
98
|
+
## Options
|
|
99
|
+
|
|
100
|
+
`Camada(...)` keyword arguments; everything credential-shaped comes from the environment.
|
|
101
|
+
|
|
102
|
+
| option | default | meaning |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `env` | `os.environ` | where `CAMADA_*` are read from |
|
|
105
|
+
| `transport` | urllib | the HTTP callable that reaches the analyst (tests inject a fake) |
|
|
106
|
+
| `refresh_s` | server-steered | poll cadence; set, it is pinned |
|
|
107
|
+
| `challenge` | `True` | serve the proof-of-work page for challenge verdicts (`CAMADA_CHALLENGE=0` too) |
|
|
108
|
+
| `challenge_path` | `/__camada/challenge` | where the page posts its solution |
|
|
109
|
+
| `snapshot_version` | `5` | 4 drops your custom rules; 3 the allow/challenge sides too |
|
|
110
|
+
| `script_path` / `fp_path` | `/_cam/b.js` / `/_cam/fp` | the beacon endpoints; keep them in one directory |
|
|
111
|
+
|
|
112
|
+
Env: `CAMADA_KEY` (or `CAMADA_TOKEN` + `CAMADA_SNAPSHOT_TOKEN`), `CAMADA_INGEST_URL`,
|
|
113
|
+
`CAMADA_SNAPSHOT_URL`, `CAMADA_TRUSTED_PROXY` (`none | vercel | hops:N | cidrs:a,b`),
|
|
114
|
+
`CAMADA_SERVERLESS=1`, `CAMADA_CHALLENGE=0`, and the kill switch `CAMADA_DISABLED=1` (checked per
|
|
115
|
+
request; set at boot, no threads start at all).
|
|
116
|
+
|
|
117
|
+
## The first-party beacon
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from camada.fastapi import script_tag # camada.django: script_tag(request); camada.flask: script_tag()
|
|
121
|
+
return HTMLResponse(f"<html><head>{script_tag(request)}</head>…")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The tag is `<script src="/_cam/b.js?r=<rid>" async>`, so the beacon joins the page view that
|
|
125
|
+
served it. Move both paths with `script_path` / `fp_path` when `/_cam/` is not yours; the script
|
|
126
|
+
derives the post path from its own URL, so the two must share a directory.
|
|
127
|
+
|
|
128
|
+
## App-context events
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from camada.fastapi import track # camada.django: track(request, ...); camada.flask: track(event, user=...)
|
|
132
|
+
track(request, "login_failed", user=email)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The identifier is HMAC-hashed in-process with your ingest token; the raw value never reaches the
|
|
136
|
+
queue. `track()` never raises and is a no-op on a request the middleware did not run for. The
|
|
137
|
+
event name is free-form; the analyst's app-context rules read this vocabulary:
|
|
138
|
+
|
|
139
|
+
| event | when |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `login_failed` / `login_succeeded` | a login attempt settled; pass `user=` so attempts per account can be counted |
|
|
142
|
+
| `signup` | an account was created |
|
|
143
|
+
| `password_reset` | a reset was requested |
|
|
144
|
+
| `mfa_failed` | a second factor was rejected |
|
|
145
|
+
| `payment_failed` / `payment_succeeded` | a payment authorisation settled |
|
|
146
|
+
| `coupon_failed` | a promo/voucher code was rejected |
|
|
147
|
+
|
|
148
|
+
A route you gate yourself: `serve_challenge(request)` (Flask: `serve_challenge()`) returns the
|
|
149
|
+
page as a framework response to return from the handler until the browser holds a valid `_cch`,
|
|
150
|
+
then `None`.
|
|
151
|
+
|
|
152
|
+
## What this tap can see
|
|
153
|
+
|
|
154
|
+
`sdk-python` is an in-app tap: status, latency, session, the beacon's browser signals and your
|
|
155
|
+
outcomes. Under ASGI the event also carries the wire's header order (`hord`); WSGI's `environ`
|
|
156
|
+
loses it, which is why ASGI is the first-class tier. The analyst knows what this tap can see and
|
|
157
|
+
never scores the absence of header order, ASN, country or a TLS fingerprint against a request;
|
|
158
|
+
ASN and country it resolves itself. Enforcement at this position covers ip, path, user-agent and
|
|
159
|
+
header conditions — ASN, country and TLS entries fail open in-app. `matches` patterns are JS
|
|
160
|
+
regexes read by Python's `re` (named groups, `[^]` and `\cX` are translated, `\d`/`\w`/`\b` stay
|
|
161
|
+
ASCII); a spelling `re` still rejects never matches here, while it does at the edge.
|
|
162
|
+
|
|
163
|
+
## Deploying it
|
|
164
|
+
|
|
165
|
+
- Every worker process polls its own snapshot (about 5 MB resident) and flushes its own batches;
|
|
166
|
+
the tenant's `poll_seconds` keeps the cadence honest across a fleet.
|
|
167
|
+
- Threads do not survive a fork. Under gunicorn `--preload` or uWSGI the SDK notices the fork
|
|
168
|
+
(`os.register_at_fork`) and restarts its poll and flush threads in each worker.
|
|
169
|
+
- Pending events drain at interpreter exit within half a second. No signal handlers are
|
|
170
|
+
installed — an app owns its own shutdown — so a worker killed by SIGTERM without one may drop
|
|
171
|
+
its last batch.
|
|
172
|
+
- Serverless: `CAMADA_SERVERLESS=1`. A cold invocation fails open and catches up on the next one.
|
|
173
|
+
|
|
174
|
+
## Fail open
|
|
175
|
+
|
|
176
|
+
Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
|
|
177
|
+
most once a minute), a corrupt snapshot keeps the previous one, a bug in the package costs the
|
|
178
|
+
request its join, never its response. `CAMADA_DISABLED=1` bypasses everything.
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
uv sync && uv run ruff check && uv run mypy && uv run pytest
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The suite reads the golden snapshot fixtures from the `camada-core` sibling checkout and pins the
|
|
187
|
+
vendored beacon to `camada-browser/dist/auto.global.js` (`npm run build` there first, then
|
|
188
|
+
`python scripts/sync_beacon.py` after a beacon release). Both fail by name when the checkout is
|
|
189
|
+
missing rather than skipping.
|
|
190
|
+
|
|
191
|
+
[`camada-python-example`](../camada-python-example) is the hand-test bench (FastAPI under uvicorn on
|
|
192
|
+
:3002), and `node scripts/e2e-sdk-python.mjs` in `camada/edge-analyst` drives it against a seeded
|
|
193
|
+
local analyst over real HTTP, cold first request included.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "camada"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "camada SDK for Python: inline enforcement of your snapshot (ordered custom rules, then allow, block, challenge), first-party beacon and proof-of-work challenge, batched event shipping. ASGI and WSGI middleware; Django, Flask and FastAPI integrations."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Framework :: Django",
|
|
22
|
+
"Framework :: Flask",
|
|
23
|
+
"Framework :: FastAPI",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://camada.app"
|
|
28
|
+
Repository = "https://github.com/camada-app/camada-python"
|
|
29
|
+
Issues = "https://github.com/camada-app/camada-python/issues"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8",
|
|
34
|
+
"ruff>=0.6",
|
|
35
|
+
"mypy>=1.11",
|
|
36
|
+
"django>=4.2",
|
|
37
|
+
"flask>=3",
|
|
38
|
+
"fastapi>=0.110",
|
|
39
|
+
"httpx>=0.27",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
path = "src/camada/version.py"
|
|
44
|
+
|
|
45
|
+
[tool.uv]
|
|
46
|
+
# uv fingerprints a path dependency by its pyproject alone; the version lives in version.py (hatch dynamic),
|
|
47
|
+
# so a consumer's `uv lock` would keep the old number without this
|
|
48
|
+
cache-keys = [{ file = "pyproject.toml" }, { file = "src/camada/version.py" }]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/camada"]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
include = ["src/camada", "README.md", "LICENSE"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 140
|
|
58
|
+
target-version = "py310"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "B", "UP", "W"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.isort]
|
|
64
|
+
known-first-party = ["camada"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.per-file-ignores]
|
|
67
|
+
"src/camada/beacon_js.py" = ["E501"] # generated: one vendored JS string
|
|
68
|
+
"tests/*" = ["E501"]
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
strict = true
|
|
72
|
+
files = ["src"]
|
|
73
|
+
python_version = "3.10"
|
|
74
|
+
|
|
75
|
+
[[tool.mypy.overrides]]
|
|
76
|
+
module = ["django.*", "flask.*", "starlette.*", "fastapi.*"]
|
|
77
|
+
ignore_missing_imports = true
|
|
78
|
+
|
|
79
|
+
[tool.pytest.ini_options]
|
|
80
|
+
testpaths = ["tests"]
|
|
81
|
+
addopts = "-q"
|
|
82
|
+
filterwarnings = [
|
|
83
|
+
"error",
|
|
84
|
+
# third-party test-client deprecations (starlette's httpx2 nudge, anyio alias), not ours
|
|
85
|
+
"ignore:.*httpx2.*",
|
|
86
|
+
"ignore::DeprecationWarning:starlette.*",
|
|
87
|
+
"ignore::DeprecationWarning:anyio.*",
|
|
88
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""camada for Python: inline enforcement of your tenant snapshot (ordered custom rules, then
|
|
2
|
+
allow, block, challenge), a first-party proof-of-work challenge and beacon, app-context events,
|
|
3
|
+
and batched wire events shipped off the request path. Fails open by design.
|
|
4
|
+
|
|
5
|
+
Quickstart (env: CAMADA_KEY, plus CAMADA_INGEST_URL in dev):
|
|
6
|
+
|
|
7
|
+
# FastAPI / Starlette
|
|
8
|
+
from camada.fastapi import CamadaMiddleware
|
|
9
|
+
app.add_middleware(CamadaMiddleware)
|
|
10
|
+
|
|
11
|
+
# Django: MIDDLEWARE = ["camada.django.CamadaMiddleware", ...]
|
|
12
|
+
# Flask: camada.flask.init_app(app)
|
|
13
|
+
# Any ASGI / WSGI app: camada.asgi.CamadaASGI(app) / camada.wsgi.CamadaWSGI(app)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .engine import Answer, Camada, Passed, Req, create_camada, engine_of
|
|
21
|
+
from .version import SDK_ID, __version__
|
|
22
|
+
|
|
23
|
+
__all__ = ["Answer", "Camada", "Passed", "Req", "SDK_ID", "__version__", "configure", "create_camada", "engine_for", "get_default"]
|
|
24
|
+
|
|
25
|
+
_default: Camada | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_default(**opts: Any) -> Camada:
|
|
29
|
+
"""The lazy singleton wired from the environment on first use (what the integrations share)."""
|
|
30
|
+
global _default
|
|
31
|
+
if _default is None:
|
|
32
|
+
_default = create_camada(**opts)
|
|
33
|
+
return _default
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def engine_for(ctx: Mapping[str, Any] | None) -> Camada:
|
|
37
|
+
"""The engine that produced a request context, else the default: what the integrations resolve through."""
|
|
38
|
+
return engine_of(ctx) or get_default()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure(**opts: Any) -> Camada:
|
|
42
|
+
"""Replaces the default engine (stopping the old one) — for tests and explicit wiring."""
|
|
43
|
+
global _default
|
|
44
|
+
if _default is not None:
|
|
45
|
+
_default.stop()
|
|
46
|
+
_default = create_camada(**opts)
|
|
47
|
+
return _default
|