Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
53 lines
2.1 KiB
Python
53 lines
2.1 KiB
Python
"""Fixed-window per-IP rate limiting for the agent-facing routes.
|
|
|
|
One implementation, several budgets. The bootstrap installer routes and the
|
|
enrollment route are both unauthenticated by design - a PC with no agent has
|
|
neither a credential nor a user session - so the only thing standing between
|
|
them and a loop is a limiter. Each caller gets its own budget name so the two
|
|
surfaces cannot spend each other's allowance.
|
|
|
|
Same shape and same cache extension as the login limiter in
|
|
shopdb.core.api.auth. Under the default SimpleCache the counter is per-process,
|
|
so with N gunicorn workers the effective budget is N times the configured
|
|
maximum; that is defence in depth rather than a hard quota, and a shared cache
|
|
backend (Redis/memcached) tightens it to a true global budget.
|
|
"""
|
|
|
|
import time
|
|
|
|
from flask import current_app, request
|
|
|
|
from shopdb.api import cache, client_ip, error_response
|
|
|
|
|
|
def caller_ip():
|
|
return client_ip(request) or 'unknown'
|
|
|
|
|
|
def ratelimited(budget, configprefix, defaultmax, defaultwindow):
|
|
"""True when this caller is over budget for the current window.
|
|
|
|
budget cache key namespace, e.g. 'endpointenrollratelimit'
|
|
configprefix config key stem, e.g. 'ENDPOINTS_ENROLL_RATELIMIT'; reads
|
|
<prefix>_ENABLED, <prefix>_MAX and <prefix>_WINDOW_SECONDS
|
|
"""
|
|
if not current_app.config.get(configprefix + '_ENABLED', True):
|
|
return False
|
|
window = current_app.config.get(configprefix + '_WINDOW_SECONDS',
|
|
defaultwindow)
|
|
maxhits = current_app.config.get(configprefix + '_MAX', defaultmax)
|
|
# Time bucket makes this a fixed window: the key rolls over at each window
|
|
# boundary, so a per-hit set() cannot turn it into a sliding window.
|
|
bucket = int(time.time() // window) if window > 0 else 0
|
|
key = '{0}:{1}:{2}'.format(budget, caller_ip(), bucket)
|
|
count = cache.get(key) or 0
|
|
if count >= maxhits:
|
|
return True
|
|
cache.set(key, count + 1, timeout=window)
|
|
return False
|
|
|
|
|
|
def rate_limit_response(message):
|
|
"""The standard 429 shape every limiter in the product answers with."""
|
|
return error_response('RATE_LIMITED', message, http_code=429)
|