"""Client IP extraction behind a reverse proxy. Prod runs behind IIS ARR, which sets X-Forwarded-For to "clientip:port" (and appends a hop per proxy). The trailing source port is ephemeral - it changes every connection - so it must be stripped before the value is stored (audit), matched (dashboard IP fallback), or used as a rate-limit key (login). Left in, it makes every request look like a different client. Port stripping is IPv6-aware: a bracketed [2001:db8::1]:443 loses its port; a bare 2001:db8::1 (many colons, no brackets) is returned untouched. """ def strip_port(ipaddress): """Return ipaddress without a trailing :port. IPv6-safe. None/empty-safe.""" if not ipaddress: return ipaddress ipaddress = ipaddress.strip() if ipaddress.startswith('['): # [IPv6] or [IPv6]:port -> the address inside the brackets return ipaddress[1:].split(']', 1)[0] if ipaddress.count(':') == 1: # exactly one colon = IPv4:port (a bare IPv6 has several colons) return ipaddress.split(':', 1)[0] return ipaddress def client_ip(request_obj): """First-hop client IP behind one proxy, port stripped. Honors the first X-Forwarded-For hop, else remote_addr. Returns None only when neither is present (callers that need a non-null key coerce it). """ forwarded = request_obj.headers.get('X-Forwarded-For') if forwarded: raw = forwarded.split(',')[0].strip() else: raw = request_obj.remote_addr return strip_port(raw)