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:
Your Name
2026-05-23 18:11:23 +08:00
parent 37ceaef6d9
commit 5fcc1e22a6
5 changed files with 433 additions and 0 deletions
+103
View File
@@ -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"])