mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue