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:
oliver 2026-09-12 15:10:57 +08:00
parent 5096117ad4
commit c4526e36d8
64 changed files with 6316 additions and 4521 deletions

View file

@ -64,7 +64,9 @@ def _is_public(path: str) -> bool:
def _client_ip(request: Request) -> str:
return str(request.client.host if request.client else "")
from .client_ip import resolve_client_ip
return resolve_client_ip(request)
def _action_for(method: str, path: str) -> str:

View file

@ -23,6 +23,7 @@ from .auth_service import (
change_password,
create_api_token,
create_user,
api_token_quota,
list_api_tokens,
list_audit_logs,
list_auth_sessions,
@ -42,13 +43,14 @@ from .auth_rate_limit import (
login_lock_remaining,
register_login_failure,
)
from .client_ip import resolve_client_ip
from .db import get_db
router = APIRouter(tags=["auth"])
def _client_meta(request: Request) -> tuple[str, str]:
ip = str(request.client.host if request.client else "")
ip = resolve_client_ip(request)
ua = str(request.headers.get("user-agent") or "")[:512]
return ip, ua
@ -401,7 +403,8 @@ def api_list_tokens(
db: Session = Depends(get_db),
) -> dict[str, Any]:
user_id = None if ctx.user.role == "admin" else ctx.user.id
return {"items": list_api_tokens(db, user_id=user_id)}
quota = api_token_quota(db)
return {"items": list_api_tokens(db, user_id=user_id), **quota}
@router.post("/v1/api-tokens")

View file

@ -235,6 +235,7 @@ def ensure_default_mcp_token(db: Session, user: AppUser | None = None) -> str |
name="mcp-default",
expires_in_days=0,
scopes=list(MCP_DEFAULT_SCOPES),
enforce_limit=False,
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(plaintext + "\n", encoding="utf-8")
@ -571,6 +572,17 @@ def change_password(
revoke_auth_sessions(db, user_id=str(row.id), except_jti=keep_jti)
def count_active_api_tokens(db: Session) -> int:
"""Non-revoked API keys (expired-but-not-revoked still occupy a slot)."""
return int(db.query(ApiToken).filter(ApiToken.revoked_at.is_(None)).count())
def api_token_quota(db: Session) -> dict[str, int]:
max_count = max(0, int(getattr(settings, "auth_api_token_max_count", 20) or 0))
active = count_active_api_tokens(db)
return {"active_count": active, "max_count": max_count}
def create_api_token(
db: Session,
*,
@ -578,10 +590,20 @@ def create_api_token(
name: str,
expires_in_days: int | None = None,
scopes: list[str] | None = None,
enforce_limit: bool = True,
) -> tuple[ApiToken, str]:
label = str(name or "").strip() or "default"
if len(label) > 128:
raise HTTPException(status_code=400, detail="token_name_too_long")
if enforce_limit:
max_count = max(0, int(getattr(settings, "auth_api_token_max_count", 20) or 0))
if max_count > 0:
active = count_active_api_tokens(db)
if active >= max_count:
raise HTTPException(
status_code=400,
detail=f"api_token_limit_reached:{active}/{max_count}",
)
expires_at: datetime | None = None
if expires_in_days is not None and int(expires_in_days) > 0:
expires_at = utcnow_naive() + timedelta(days=int(expires_in_days))

106
netx_api/client_ip.py Normal file
View 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)

View file

@ -163,6 +163,11 @@ class Settings(BaseSettings):
bootstrap_admin_password: str = "admin123"
# Written on first boot for MCP; path relative to cwd / absolute
auth_mcp_token_file: str = "data/auth/mcp_token"
# Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited.
auth_api_token_max_count: int = 20
# Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP
# (Vite dev proxy, local nginx). Add your reverse-proxy LAN IP in production.
trusted_proxy_ips: str = "127.0.0.1,::1"
# Expose /docs /redoc /openapi.json without auth when true (lab only).
docs_enabled: bool = False
# Refuse start when bind host is non-loopback and insecure defaults remain.

View file

@ -126,12 +126,13 @@ class WebcrtSftpChmodBody(BaseModel):
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
host = ""
from .client_ip import resolve_client_ip, resolve_websocket_client_ip
if request is not None:
host = request.client.host if request.client else ""
elif websocket is not None:
host = websocket.client.host if websocket.client else ""
return str(host or "")
return resolve_client_ip(request)
if websocket is not None:
return resolve_websocket_client_ip(websocket)
return ""
@router.get("/sessions")