"""单 IP 只允许挂载一台普通节点。白名单单 IP、白名单网段和白名单节点跳过这条限制。""" from __future__ import annotations import ipaddress from datetime import datetime from fastapi import Request from sqlalchemy import select from sqlalchemy.orm import Session from app.clock import utcnow from app.errors import AppError from app.models import ComputeNode, IPWhitelist IP_LIMIT_MESSAGE = "该 IP 已绑定过设备,非白名单 IP 仅限挂载一台" DATACENTER_BATCH_MESSAGE = "机房/批量接入需满足最低 5 台且全系 30/40 显卡门槛,请前往申请机房白名单" def client_ip(request: Request) -> str: """优先取反向代理写下的真实地址,否则用连接对端。""" real = (request.headers.get("x-real-ip") or "").split(",")[0].strip() if real: return _loose_ip(real) forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() if forwarded: return _loose_ip(forwarded) host = request.client.host if request.client is not None else "" return _loose_ip(host) def normalize_admin_ip(value: str) -> str: """单 IP 保持地址本身,网段规范成网络地址,例如 192.168.1.10/24 变成 192.168.1.0/24。""" text = value.strip() if text.lower().startswith("::ffff:"): text = text[7:] if "/" in text: try: return str(ipaddress.ip_network(text, strict=False)) except ValueError as exc: raise AppError("IP 或网段格式不正确") from exc try: return str(ipaddress.ip_address(text)) except ValueError as exc: raise AppError("IP 或网段格式不正确") from exc def assert_ip_available(session: Session, ip: str, node: ComputeNode | None = None, now: datetime | None = None) -> None: """白名单 IP,或节点已标记 is_whitelist 时,不限制同一地址上的节点数量。""" address = _loose_ip(ip) if not address: return if node is not None and node.is_whitelist: return if matching_whitelist(session, address) is not None: return moment = now or utcnow() query = select(ComputeNode.id).where( ComputeNode.client_ip == address, ComputeNode.is_whitelist.is_(False), ComputeNode.license_expires_at > moment, ) if node is not None: query = query.where(ComputeNode.id != node.id) if session.scalar(query) is not None: raise AppError(DATACENTER_BATCH_MESSAGE, 409) def list_whitelist(session: Session) -> list[IPWhitelist]: return list(session.scalars(select(IPWhitelist).order_by(IPWhitelist.id.desc())).all()) def add_whitelist_ip(session: Session, ip: str, note: str | None = None) -> IPWhitelist: address = normalize_admin_ip(ip) if _ip_listed(session, address): raise AppError("这个 IP 已在白名单中", 409) row = IPWhitelist(ip_address=address, note=(note or "").strip() or None, created_at=utcnow()) session.add(row) session.flush() return row def remove_whitelist_ip(session: Session, entry_id: int) -> IPWhitelist: row = session.get(IPWhitelist, entry_id) if row is None: raise AppError("白名单 IP 不存在", 404) session.delete(row) session.flush() return row def matching_whitelist(session: Session, address: str) -> str | None: """请求 IP 落在某条单 IP 或网段里时,返回那条白名单原文。""" entries = list(session.scalars(select(IPWhitelist.ip_address)).all()) try: ip = ipaddress.ip_address(address) except ValueError: return address if address in entries else None subnet_hit = None exact_hit = None for entry in entries: if "/" in entry: try: if ip in ipaddress.ip_network(entry, strict=False): subnet_hit = entry except ValueError: continue elif entry == str(ip): exact_hit = entry return subnet_hit or exact_hit def _ip_listed(session: Session, address: str) -> bool: return session.scalar(select(IPWhitelist.id).where(IPWhitelist.ip_address == address)) is not None def _loose_ip(value: str) -> str: text = (value or "").strip() if text.lower().startswith("::ffff:"): text = text[7:] if not text: return "" try: return str(ipaddress.ip_address(text)) except ValueError: return text[:64]