feat(6866): the proxy hop count is an admin setting, and the server shows what each count reads
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m47s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 20s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m47s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 20s
An admin can save how many reverse proxies stand in front of Grimoire. A saved count wins over TRUSTED_PROXY_HOPS, which stays the starting value. Each worker loads it at startup (under a deadline) and re-reads it every 30s; the worker that saves applies it at once. GET/PUT /api/admin/proxy returns the count, where it came from, and a readout of the admin's own request: the socket peer, the forwarding chains, what counts 0-3 would read, and the count that reads Cloudflare's visitor when Cloudflare's headers are present. Saves are audit-logged. A "too many attempts" reply now says where to look when everyone is being blocked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""The reverse-proxy hop count an admin saves in Settings → Config.
|
||||
|
||||
How many proxies stand in front of the app decides which address every rate
|
||||
limit and audit line sees, and whether a request counts as HTTPS
|
||||
(grimoire/proxy.py). Behind Cloudflare's proxied DNS the right count is 2; at
|
||||
the default of 1 every visitor reads as a Cloudflare edge address and they
|
||||
share one sign-in rate limit. So the count is set where an admin can find it
|
||||
and check it against their own request, not only in the environment.
|
||||
|
||||
Precedence: the saved count, then `TRUSTED_PROXY_HOPS` from the environment,
|
||||
then 1. Saving None removes the saved count, so the environment's applies
|
||||
again.
|
||||
|
||||
It is one instance-wide value, kept on the first admin account like the other
|
||||
instance settings. Every worker holds it in proxy.py, loads it at startup and
|
||||
re-reads it every REFRESH_SECONDS, so a save in one worker reaches the others
|
||||
within that interval; the worker that saved applies it at once.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from grimoire import proxy
|
||||
from grimoire.config import Config
|
||||
from grimoire.models import async_session
|
||||
from grimoire.models.setting import Setting
|
||||
from grimoire.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SETTING_KEY = "trusted_proxy_hops"
|
||||
REFRESH_SECONDS = 30
|
||||
# The load at startup runs inside the lifespan hook, and a hook that does not
|
||||
# return is a worker that never serves (db_maintenance_scheduler explains the
|
||||
# outage that taught this). One indexed row answers in milliseconds, so the
|
||||
# deadline only fires when the database is already in trouble.
|
||||
STARTUP_READ_TIMEOUT = 3.0
|
||||
|
||||
|
||||
def parse_hops(raw) -> int | None:
|
||||
"""A hop count from 0 to proxy.MAX_HOPS, or None for anything else. A
|
||||
bool is refused, though Python counts it as an int."""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
raw = raw.strip()
|
||||
if not raw.isdigit():
|
||||
return None
|
||||
raw = int(raw)
|
||||
if not isinstance(raw, int) or not 0 <= raw <= proxy.MAX_HOPS:
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _admin_rows():
|
||||
return (
|
||||
select(Setting)
|
||||
.join(User, Setting.user_id == User.id)
|
||||
.where(User.role == "admin", Setting.key == SETTING_KEY)
|
||||
)
|
||||
|
||||
|
||||
async def saved_hops() -> int | None:
|
||||
"""The count saved in Settings, or None when there is none (or what is
|
||||
stored can't be read as one)."""
|
||||
async with async_session() as session:
|
||||
row = (await session.execute(_admin_rows().order_by(Setting.user_id))).scalars().first()
|
||||
return parse_hops(row.value) if row else None
|
||||
|
||||
|
||||
async def save_hops(hops: int | None) -> None:
|
||||
"""Save the count, or remove it with None. Any copy on another admin's
|
||||
account goes too, so exactly one value is ever read back."""
|
||||
async with async_session() as session:
|
||||
admin_ids = select(User.id).where(User.role == "admin")
|
||||
await session.execute(
|
||||
delete(Setting).where(Setting.key == SETTING_KEY, Setting.user_id.in_(admin_ids))
|
||||
)
|
||||
if hops is not None:
|
||||
first_admin = (
|
||||
await session.execute(select(User.id).where(User.role == "admin").order_by(User.id))
|
||||
).scalars().first()
|
||||
if first_admin is None:
|
||||
raise ValueError("there is no admin account to keep the setting on")
|
||||
session.add(Setting(user_id=first_admin, key=SETTING_KEY, value=str(hops)))
|
||||
await session.commit()
|
||||
proxy.set_saved_hops(hops)
|
||||
|
||||
|
||||
async def refresh() -> None:
|
||||
"""Load the saved count into this worker. A failed read keeps the value
|
||||
it already had: an unreachable database must not switch the trust rule."""
|
||||
try:
|
||||
proxy.set_saved_hops(await saved_hops())
|
||||
except Exception:
|
||||
logger.debug("could not re-read the saved proxy hop count", exc_info=True)
|
||||
|
||||
|
||||
async def load_at_startup() -> None:
|
||||
"""`refresh`, bounded for the lifespan hook. On a timeout the environment's
|
||||
count stays in force until the next refresh reaches the database."""
|
||||
try:
|
||||
await asyncio.wait_for(refresh(), timeout=STARTUP_READ_TIMEOUT)
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
logger.warning(
|
||||
"reading the saved proxy hop count exceeded %.1fs; starting with %d "
|
||||
"from the environment until the database answers",
|
||||
STARTUP_READ_TIMEOUT, Config.TRUSTED_PROXY_HOPS,
|
||||
)
|
||||
|
||||
|
||||
_refresh_task = None
|
||||
|
||||
|
||||
def start_refresh_loop() -> None:
|
||||
global _refresh_task
|
||||
if _refresh_task is None or _refresh_task.done():
|
||||
from grimoire.services.background import start_periodic
|
||||
_refresh_task = start_periodic(REFRESH_SECONDS, refresh, label="proxy_hops_refresh")
|
||||
|
||||
|
||||
def source() -> str:
|
||||
"""Where the count in force came from: "setting", "env" or "default"."""
|
||||
if proxy.saved_hops_in_force() is not None:
|
||||
return "setting"
|
||||
return "env" if Config.TRUSTED_PROXY_HOPS_FROM_ENV else "default"
|
||||
Reference in New Issue
Block a user