feat: implement API versioning /api/v1/

- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-02 21:29:30 +02:00
parent 0d5882b32f
commit cc6dbcf3f0
51 changed files with 1886 additions and 671 deletions

View File

@@ -104,3 +104,52 @@ BLOCKLIST_PREVIEW_MAX_LINES: Final[int] = 100
HEALTH_CHECK_INTERVAL_SECONDS: Final[int] = 30
"""How often the background health-check task polls fail2ban."""
# ---------------------------------------------------------------------------
# Rate limits (per IP)
# ---------------------------------------------------------------------------
RATE_LIMIT_BANS_BAN_REQUESTS: Final[int] = 100
"""Max ban requests per IP per minute."""
RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100
"""Max unban requests per IP per minute."""
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 10
"""Max blocklist import requests per IP per hour."""
RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50
"""Max config update requests per IP per minute."""
RATE_LIMIT_FILTER_UPDATE_REQUESTS: Final[int] = 50
"""Max filter config update requests per IP per minute."""
RATE_LIMIT_FILTER_CREATE_REQUESTS: Final[int] = 50
"""Max filter config create requests per IP per minute."""
RATE_LIMIT_FILTER_DELETE_REQUESTS: Final[int] = 50
"""Max filter config delete requests per IP per minute."""
RATE_LIMIT_ACTION_UPDATE_REQUESTS: Final[int] = 50
"""Max action config update requests per IP per minute."""
RATE_LIMIT_ACTION_CREATE_REQUESTS: Final[int] = 50
"""Max action config create requests per IP per minute."""
RATE_LIMIT_ACTION_DELETE_REQUESTS: Final[int] = 50
"""Max action config delete requests per IP per minute."""
RATE_LIMIT_JAIL_UPDATE_REQUESTS: Final[int] = 100
"""Max jail config update requests per IP per minute."""
RATE_LIMIT_JAIL_CREATE_REQUESTS: Final[int] = 100
"""Max jail config create requests per IP per minute."""
RATE_LIMIT_JAIL_DELETE_REQUESTS: Final[int] = 100
"""Max jail config delete requests per IP per minute."""
RATE_LIMIT_JAIL_ACTIVATE_REQUESTS: Final[int] = 100
"""Max jail activation requests per IP per minute."""
RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS: Final[int] = 100
"""Max jail deactivation requests per IP per minute."""

View File

@@ -228,12 +228,16 @@ class GlobalRateLimiter:
blocked until the oldest request expires.
4. A background cleanup task removes dormant IPs from memory periodically.
**Per-Endpoint Configuration:**
**Per-Bucket Configuration:**
Different endpoints can have different limits. For example:
- Login endpoint: 5 requests per 60 seconds
- Dashboard read: 100 requests per 60 seconds
- Config write: 20 requests per 60 seconds
Different endpoints can have different limits via named buckets:
- `bans:ban` — 100/minute per IP (ban operations)
- `bans:unban` — 100/minute per IP (unban operations)
- `blocklist:import` — 10/hour per IP (import operations)
- `config:update` — 50/minute per IP (config write operations)
Each bucket tracks its own requests independently, so hitting the
blocklist:import limit does not affect the bans:ban limit.
"""
def __init__(
@@ -250,6 +254,32 @@ class GlobalRateLimiter:
self.max_requests: int = max_requests
self.window_seconds: int = window_seconds
self._requests: dict[str, deque[float]] = {}
self._buckets: dict[str, dict[str, deque[float]]] = {}
def _get_bucket_deque(
self,
bucket: str,
ip_address: str,
max_requests: int,
window_seconds: int,
) -> deque[float]:
"""Get or create the deque for a specific bucket and IP.
Args:
bucket: Bucket name (e.g., "bans:ban").
ip_address: Client IP address.
max_requests: Maximum requests for this bucket (unused, for future).
window_seconds: Window in seconds (unused, for future).
Returns:
The deque of timestamps for this bucket+IP.
"""
if bucket not in self._buckets:
self._buckets[bucket] = {}
bucket_dict = self._buckets[bucket]
if ip_address not in bucket_dict:
bucket_dict[ip_address] = deque()
return bucket_dict[ip_address]
def check_allowed(self, ip_address: str) -> tuple[bool, float]:
"""Check if a request from *ip_address* is allowed.
@@ -292,6 +322,53 @@ class GlobalRateLimiter:
return False, retry_after
def check_allowed_for_bucket(
self,
bucket: str,
ip_address: str,
max_requests: int,
window_seconds: int,
) -> tuple[bool, float]:
"""Check if a request for a specific bucket is allowed.
Each bucket has independent rate limiting. This allows different
endpoints to have different limits (e.g., blocklist import is more
restrictive than ban operations).
Args:
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
ip_address: The client IP address to rate-limit.
max_requests: Maximum requests allowed within the window.
window_seconds: Time window (seconds) for this bucket.
Returns:
A tuple of (is_allowed, retry_after_seconds). If is_allowed is True,
retry_after_seconds is 0. If False, it's the estimated time to wait.
"""
now = time()
requests = self._get_bucket_deque(bucket, ip_address, max_requests, window_seconds)
cutoff = now - window_seconds
# Remove old requests outside the window
while requests and requests[0] < cutoff:
requests.popleft()
# If under the limit, allow the request
if len(requests) < max_requests:
requests.append(now)
return True, 0.0
# Over the limit: calculate how long to wait
oldest_request = requests[0]
age = now - oldest_request
retry_after = window_seconds - age
# Ensure retry_after is at least 1 second
retry_after = max(retry_after, 1.0)
return False, retry_after
def cleanup_expired(self) -> None:
"""Remove all IPs with no recent requests (cleanup task).
@@ -316,6 +393,21 @@ class GlobalRateLimiter:
if ips_to_remove:
log.debug("global_rate_limiter_cleanup", removed_ips=len(ips_to_remove))
# Cleanup per-bucket dictionaries
for bucket, bucket_dict in list(self._buckets.items()):
bucket_ips_to_remove = []
bucket_window = 60 # Use a reasonable window for bucket cleanup
bucket_cutoff = now - bucket_window
for ip_address, requests in bucket_dict.items():
while requests and requests[0] < bucket_cutoff:
requests.popleft()
if not requests:
bucket_ips_to_remove.append(ip_address)
for ip_address in bucket_ips_to_remove:
del bucket_dict[ip_address]
if not bucket_dict:
del self._buckets[bucket]
def get_state(self) -> Mapping[str, int]:
"""Return a read-only view of current request counts per IP.
@@ -334,6 +426,30 @@ class GlobalRateLimiter:
result[ip_address] = count
return result
def get_bucket_state(self, bucket: str) -> Mapping[str, int]:
"""Return a read-only view of current request counts per IP for a bucket.
For debugging and monitoring.
Args:
bucket: Bucket name to get state for.
Returns:
A mapping of IP addresses to their request counts in this bucket.
"""
if bucket not in self._buckets:
return {}
now = time()
result = {}
for ip_address, requests in self._buckets[bucket].items():
# Count non-expired requests (use max window of 3600s for hourly buckets)
cutoff = now - 3600
count = sum(1 for ts in requests if ts >= cutoff)
if count > 0:
result[ip_address] = count
return result
def reset(self) -> None:
"""Clear all tracked requests (for testing)."""
self._requests.clear()
self._buckets.clear()