"""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"