mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 08:10:47 +08:00
Polish HeroUI chrome, topology toolbar, and session client IP.
Checkpoint before workbench facade redesign: list defaults, API key quota, toast portal, topology canvas editor toolbar with More menu, and trusted-proxy client IP for sessions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5096117ad4
commit
c4526e36d8
64 changed files with 6316 additions and 4521 deletions
106
netx_api/client_ip.py
Normal file
106
netx_api/client_ip.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Resolve client IP behind reverse proxies (Vite / nginx / Caddy).
|
||||
|
||||
Only honors X-Forwarded-For / X-Real-IP / CF-Connecting-IP when the direct peer
|
||||
is in NETX_TRUSTED_PROXY_IPS (default loopback). Otherwise the peer address is
|
||||
used — prevents header spoofing from the public internet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from typing import Mapping
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from .config import settings
|
||||
|
||||
_IP_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"(?:\d{1,3}\.){3}\d{1,3}" # IPv4
|
||||
r"|"
|
||||
r"\[[0-9a-fA-F:]+\]" # [IPv6]
|
||||
r"|"
|
||||
r"[0-9a-fA-F:]+" # IPv6 bare
|
||||
r")$"
|
||||
)
|
||||
|
||||
|
||||
def _trusted_peers() -> set[str]:
|
||||
raw = str(getattr(settings, "trusted_proxy_ips", "") or "")
|
||||
out: set[str] = set()
|
||||
for part in raw.split(","):
|
||||
p = part.strip()
|
||||
if p:
|
||||
out.add(p)
|
||||
# Always treat classic loopback as trusted for local Vite/nginx.
|
||||
out.update({"127.0.0.1", "::1", "localhost"})
|
||||
return out
|
||||
|
||||
|
||||
def _looks_like_ip(value: str) -> bool:
|
||||
cand = str(value or "").strip()
|
||||
if not cand or not _IP_RE.match(cand):
|
||||
return False
|
||||
if cand.startswith("[") and cand.endswith("]"):
|
||||
cand = cand[1:-1]
|
||||
try:
|
||||
ipaddress.ip_address(cand)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_ip(value: str) -> str:
|
||||
cand = str(value or "").strip()
|
||||
if cand.startswith("[") and cand.endswith("]"):
|
||||
cand = cand[1:-1]
|
||||
return cand[:128]
|
||||
|
||||
|
||||
def _header_map(headers: Mapping[str, str] | None) -> dict[str, str]:
|
||||
if headers is None:
|
||||
return {}
|
||||
# Starlette Headers is case-insensitive; normalize keys for .get
|
||||
return {str(k).lower(): str(v) for k, v in headers.items()}
|
||||
|
||||
|
||||
def resolve_client_ip_from(peer: str, headers: Mapping[str, str] | None = None) -> str:
|
||||
"""Pick the best client IP given the TCP peer and request headers."""
|
||||
peer_ip = _normalize_ip(peer)
|
||||
hdrs = _header_map(headers)
|
||||
trusted = _trusted_peers()
|
||||
peer_trusted = peer_ip in trusted or peer_ip.lower() in {x.lower() for x in trusted}
|
||||
|
||||
if not peer_trusted:
|
||||
return peer_ip or ""
|
||||
|
||||
# Cloudflare (single value)
|
||||
cf = (hdrs.get("cf-connecting-ip") or "").strip()
|
||||
if _looks_like_ip(cf):
|
||||
return _normalize_ip(cf)
|
||||
|
||||
# nginx often sets this to the original client
|
||||
real = (hdrs.get("x-real-ip") or "").strip()
|
||||
if _looks_like_ip(real):
|
||||
return _normalize_ip(real)
|
||||
|
||||
# X-Forwarded-For: client, proxy1, proxy2 — take first public-looking hop
|
||||
xff = hdrs.get("x-forwarded-for") or ""
|
||||
for part in xff.split(","):
|
||||
cand = part.strip()
|
||||
if _looks_like_ip(cand):
|
||||
return _normalize_ip(cand)
|
||||
|
||||
return peer_ip or ""
|
||||
|
||||
|
||||
def resolve_client_ip(request: Request) -> str:
|
||||
peer = str(request.client.host if request.client else "") or ""
|
||||
return resolve_client_ip_from(peer, request.headers)
|
||||
|
||||
|
||||
def resolve_websocket_client_ip(websocket: WebSocket) -> str:
|
||||
peer = str(websocket.client.host if websocket.client else "") or ""
|
||||
return resolve_client_ip_from(peer, websocket.headers)
|
||||
Loading…
Add table
Add a link
Reference in a new issue