feat: login IP allowlist with proxy subscription parser
server/infrastructure/subscription_parser.py (new): - parse_subscription_content(): fetch and decode subscription, extract host/IP - Supports SS, VMess, VLESS, Trojan, Hysteria2 formats - Base64 decode with padding fix; IPv4/CIDR/hostname detection server/config.py: - LOGIN_ALLOWED_IPS setting (empty = allow all, comma-separated) server/api/settings.py: - POST /settings/ip-allowlist/parse-subscription: fetch URL, decode, return hosts - POST /settings/ip-allowlist: save allowlist to settings table + reload - GET /settings/ip-allowlist: return current list server/application/services/auth_service.py: - _ip_in_allowlist(): supports exact IP, CIDR (ipaddress module), hostname match - login(): check allowlist before brute-force check; if IP blocked return reason='ip_blocked' with clear message; audit log 'login_ip_blocked' web/app/settings.html: - New '登录 IP 白名单' card with: - Subscription URL input + parse button (calls backend parser) - Parsed IPs with checkboxes (select/deselect before adding) - Manual IP/CIDR/hostname textarea input - Current allowlist with per-IP remove buttons - Clear all button (removes restriction) - Status badge: 启用 N条 / 未启用(不限制) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -280,6 +280,109 @@ async def delete_preset(
|
||||
))
|
||||
|
||||
|
||||
# ── IP Allowlist ──
|
||||
|
||||
class IpAllowlistRequest(BaseModel):
|
||||
ips: list[str] = Field(..., description="IP or hostname list to set as allowlist")
|
||||
|
||||
|
||||
class SubscriptionParseRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="Proxy subscription URL to fetch and parse")
|
||||
|
||||
|
||||
@router.post("/ip-allowlist/parse-subscription", response_model=dict)
|
||||
async def parse_subscription(
|
||||
payload: SubscriptionParseRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fetch a proxy subscription URL, decode it, and extract node host/IP list.
|
||||
|
||||
Supports SS, VMess, VLESS, Trojan, Hysteria2 formats.
|
||||
Returns the parsed host list for the user to review before saving.
|
||||
"""
|
||||
import httpx
|
||||
from server.infrastructure.subscription_parser import parse_subscription_content
|
||||
|
||||
url = payload.url.strip()
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail=f"订阅链接返回 HTTP {resp.status_code}")
|
||||
raw = resp.text
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=400, detail="请求订阅链接超时(15s)")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"获取订阅失败: {e}")
|
||||
|
||||
hosts = parse_subscription_content(raw)
|
||||
if not hosts:
|
||||
raise HTTPException(status_code=400, detail="未能从订阅内容中解析出任何节点 IP / 域名")
|
||||
|
||||
# Audit
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="parse_subscription",
|
||||
target_type="setting", detail=f"解析订阅: {url[:80]} → {len(hosts)} 个节点",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {"hosts": hosts, "count": len(hosts)}
|
||||
|
||||
|
||||
@router.post("/ip-allowlist", response_model=dict)
|
||||
async def set_ip_allowlist(
|
||||
payload: IpAllowlistRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Save login IP allowlist. Empty list = allow all IPs (no restriction)."""
|
||||
# Deduplicate and strip
|
||||
cleaned = [ip.strip() for ip in payload.ips if ip.strip()]
|
||||
value = ",".join(cleaned)
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
await repo.set("login_allowed_ips", value)
|
||||
|
||||
# Reload into running settings
|
||||
from server.config import settings as _settings
|
||||
_settings.LOGIN_ALLOWED_IPS = value
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
ip_address = request.client.host if request.client else ""
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="update_ip_allowlist",
|
||||
target_type="setting",
|
||||
detail=f"IP白名单更新: {len(cleaned)} 条" + (" (清空=不限制)" if not cleaned else ""),
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {"success": True, "count": len(cleaned), "ips": cleaned}
|
||||
|
||||
|
||||
@router.get("/ip-allowlist", response_model=dict)
|
||||
async def get_ip_allowlist(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return current login IP allowlist."""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("login_allowed_ips") or ""
|
||||
ips = [ip.strip() for ip in value.split(",") if ip.strip()]
|
||||
return {"ips": ips, "count": len(ips), "enabled": bool(ips)}
|
||||
|
||||
|
||||
# ── Audit Logs ──
|
||||
|
||||
audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||
|
||||
Reference in New Issue
Block a user