Files
Random-image-api/backend/app/api/admin/proxies.py
T
2026-02-22 18:58:55 +08:00

1096 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlparse, urlunparse
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Request
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from app.api.admin.deps import get_admin_claims
from app.core.bindings_recompute import recompute_token_proxy_bindings
from app.core.crypto import FieldEncryptor
from app.core.errors import ApiError, ErrorCode
from app.core.proxy_uri import parse_proxy_uri
from app.core.request_id import get_or_create_request_id
from app.core.time import iso_utc_ms
from app.db.models.jobs import JobRow
from app.db.models.proxy_endpoints import ProxyEndpoint
from app.db.models.proxy_pool_endpoints import ProxyPoolEndpoint
from app.db.models.proxy_pools import ProxyPool
from app.db.models.token_proxy_bindings import TokenProxyBinding
from app.db.session import create_sessionmaker, with_sqlite_busy_retry
from app.easy_proxies.client import EasyProxiesError, easy_proxies_auth, easy_proxies_export
from app.easy_proxies.normalize import normalize_exported_proxy_host, resolve_export_host
router = APIRouter()
def _mask_proxy_uri(*, scheme: str, host: str, port: int, username: str, password_set: bool) -> str:
scheme = (scheme or "").strip().lower()
host = (host or "").strip()
username = (username or "").strip()
if ":" in host and not host.startswith("["):
host = f"[{host}]"
auth = ""
if username:
auth = f"{username}@"
if password_set:
auth = f"{username}:***@"
return f"{scheme}://{auth}{host}:{int(port)}"
def _sanitize_source_ref(value: str | None) -> str | None:
raw = str(value or "").strip()
if not raw:
return None
try:
parsed = urlparse(raw)
except Exception:
return raw[:200]
if not parsed.scheme or not parsed.netloc:
return raw[:200]
host = parsed.hostname
if not host:
return raw[:200]
port = parsed.port
netloc = f"{host}:{int(port)}" if port else host
path = parsed.path or ""
return urlunparse((parsed.scheme, netloc, path, "", "", ""))
@router.get("/proxies/endpoints")
async def list_proxy_endpoints(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
endpoints = (
(
await session.execute(sa.select(ProxyEndpoint).order_by(ProxyEndpoint.id.desc()))
)
.scalars()
.all()
)
endpoint_ids = [int(p.id) for p in endpoints]
pools_by_eid: dict[int, list[dict[str, Any]]] = {}
if endpoint_ids:
pool_rows = (
(
await session.execute(
sa.select(
ProxyPoolEndpoint.endpoint_id,
ProxyPoolEndpoint.pool_id,
ProxyPoolEndpoint.enabled,
ProxyPoolEndpoint.weight,
ProxyPool.name,
ProxyPool.enabled,
)
.join(ProxyPool, ProxyPool.id == ProxyPoolEndpoint.pool_id)
.where(ProxyPoolEndpoint.endpoint_id.in_(endpoint_ids))
.order_by(ProxyPoolEndpoint.pool_id.asc())
)
)
.all()
)
for endpoint_id, pool_id, member_enabled, weight, pool_name, pool_enabled in pool_rows:
pools_by_eid.setdefault(int(endpoint_id), []).append(
{
"id": str(pool_id),
"name": str(pool_name),
"pool_enabled": bool(pool_enabled),
"member_enabled": bool(member_enabled),
"weight": int(weight or 0),
}
)
primary_counts: dict[int, int] = {}
override_counts: dict[int, int] = {}
if endpoint_ids:
rows = (
(
await session.execute(
sa.select(TokenProxyBinding.primary_proxy_id, sa.func.count())
.where(TokenProxyBinding.primary_proxy_id.in_(endpoint_ids))
.group_by(TokenProxyBinding.primary_proxy_id)
)
)
.all()
)
for pid, c in rows:
primary_counts[int(pid)] = int(c)
rows2 = (
(
await session.execute(
sa.select(TokenProxyBinding.override_proxy_id, sa.func.count())
.where(TokenProxyBinding.override_proxy_id.is_not(None))
.where(TokenProxyBinding.override_proxy_id.in_(endpoint_ids))
.group_by(TokenProxyBinding.override_proxy_id)
)
)
.all()
)
for pid, c in rows2:
if pid is None:
continue
override_counts[int(pid)] = int(c)
now_iso = iso_utc_ms()
invalid_hosts = {"0.0.0.0", "127.0.0.1", "localhost", "::", "::1", "[::]", "[::1]"}
items = [
{
"id": str(p.id),
"enabled": bool(p.enabled),
"source": str(p.source or "manual"),
"source_ref": _sanitize_source_ref(p.source_ref),
"scheme": str(p.scheme),
"host": str(p.host),
"port": int(p.port),
"invalid_host": str(p.host or "").strip().lower() in invalid_hosts,
"uri_masked": _mask_proxy_uri(
scheme=str(p.scheme),
host=str(p.host),
port=int(p.port),
username=str(p.username or ""),
password_set=bool(str(p.password_enc or "").strip()),
),
"latency_ms": float(p.last_latency_ms) if p.last_latency_ms is not None else None,
"status": (
"blacklisted"
if (p.blacklisted_until and str(p.blacklisted_until) > str(now_iso))
else "ok"
if p.last_ok_at and (not p.last_fail_at or str(p.last_ok_at) >= str(p.last_fail_at))
else "fail"
if p.last_fail_at
else "unknown"
),
"blacklisted_until": p.blacklisted_until,
"last_error": p.last_error,
"success_count": int(p.success_count or 0),
"failure_count": int(p.failure_count or 0),
"last_ok_at": p.last_ok_at,
"last_fail_at": p.last_fail_at,
"pools": pools_by_eid.get(int(p.id), []),
"bindings": {
"primary_count": int(primary_counts.get(int(p.id), 0)),
"override_count": int(override_counts.get(int(p.id), 0)),
},
}
for p in endpoints
]
return {"ok": True, "items": items, "request_id": rid}
def _parse_conflict_policy(value: Any) -> str:
v = str(value or "").strip().lower() or "skip"
if v not in {"skip", "overwrite"}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported conflict_policy", status_code=400)
return v
def _parse_easy_conflict_policy(value: Any) -> str:
v = str(value or "").strip().lower() or "skip_non_easy_proxies"
if v not in {"skip_non_easy_proxies", "skip", "overwrite"}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported conflict_policy", status_code=400)
return v
def _parse_bool_strict(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
v = value.strip().lower()
if v in {"1", "true", "yes", "y", "on"}:
return True
if v in {"0", "false", "no", "n", "off"}:
return False
return None
async def _load_import_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400) from exc
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
text = str(data.get("text") or "")
if not text.strip():
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Missing text", status_code=400)
source = str(data.get("source") or "manual").strip() or "manual"
conflict_policy = _parse_conflict_policy(data.get("conflict_policy"))
return {"text": text, "source": source, "conflict_policy": conflict_policy}
async def _load_update_endpoint_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400) from exc
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
if "enabled" not in data:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Missing enabled", status_code=400)
enabled = _parse_bool_strict(data.get("enabled"))
if enabled is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported enabled", status_code=400)
return {"enabled": bool(enabled)}
async def _load_easy_import_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400) from exc
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
base_url = str(data.get("base_url") or "").strip()
password = str(data.get("password") or "").strip()
if not base_url:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Missing base_url", status_code=400)
host_override_raw = data.get("host_override")
host_override = str(host_override_raw).strip() if host_override_raw is not None else ""
host_override = host_override if host_override else None
if host_override is not None and len(host_override) > 200:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid host_override", status_code=400)
attach_pool_id: int | None = None
if "attach_pool_id" in data:
raw = data.get("attach_pool_id")
if raw is None or (isinstance(raw, str) and not raw.strip()):
attach_pool_id = None
else:
try:
attach_pool_id = int(raw)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid attach_pool_id", status_code=400) from exc
if attach_pool_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid attach_pool_id", status_code=400)
attach_weight = 1
if "attach_weight" in data:
try:
attach_weight = int(data.get("attach_weight"))
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid attach_weight", status_code=400) from exc
if attach_weight < 0 or attach_weight > 1000:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid attach_weight", status_code=400)
recompute_bindings = False
if "recompute_bindings" in data:
v = _parse_bool_strict(data.get("recompute_bindings"))
if v is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid recompute_bindings", status_code=400)
recompute_bindings = bool(v)
max_tokens_per_proxy = 2
if "max_tokens_per_proxy" in data:
try:
max_tokens_per_proxy = int(data.get("max_tokens_per_proxy"))
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid max_tokens_per_proxy", status_code=400) from exc
if max_tokens_per_proxy <= 0 or max_tokens_per_proxy > 1000:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid max_tokens_per_proxy", status_code=400)
strict = True
if "strict" in data:
v = _parse_bool_strict(data.get("strict"))
if v is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid strict", status_code=400)
strict = bool(v)
conflict_policy = _parse_easy_conflict_policy(data.get("conflict_policy"))
if recompute_bindings and attach_pool_id is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="recompute_bindings requires attach_pool_id", status_code=400)
return {
"base_url": base_url,
"password": password,
"conflict_policy": conflict_policy,
"host_override": host_override,
"attach_pool_id": attach_pool_id,
"attach_weight": int(attach_weight),
"recompute_bindings": bool(recompute_bindings),
"max_tokens_per_proxy": int(max_tokens_per_proxy),
"strict": bool(strict),
}
async def _load_probe_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception:
return {}
if data is None:
return {}
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
out: dict[str, Any] = {}
if "probe_url" in data:
probe_url = str(data.get("probe_url") or "").strip()
if probe_url:
if len(probe_url) > 2000:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid probe_url", status_code=400)
if "://" not in probe_url:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid probe_url", status_code=400)
out["probe_url"] = probe_url
if "timeout_ms" in data:
raw = data.get("timeout_ms")
try:
timeout_ms = int(raw)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid timeout_ms", status_code=400) from exc
if timeout_ms <= 0 or timeout_ms > 600_000:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid timeout_ms", status_code=400)
out["timeout_ms"] = int(timeout_ms)
if "concurrency" in data:
raw = data.get("concurrency")
try:
concurrency = int(raw)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid concurrency", status_code=400) from exc
if concurrency < 1 or concurrency > 200:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid concurrency", status_code=400)
out["concurrency"] = int(concurrency)
return out
async def _load_cleanup_invalid_hosts_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception:
return {}
if data is None:
return {}
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
out: dict[str, Any] = {}
for key in ("dry_run", "delete_orphans", "recompute_bindings", "strict"):
if key not in data:
continue
v = _parse_bool_strict(data.get(key))
if v is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message=f"Invalid {key}", status_code=400)
out[key] = bool(v)
if "max_tokens_per_proxy" in data:
try:
n = int(data.get("max_tokens_per_proxy"))
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid max_tokens_per_proxy", status_code=400) from exc
if n <= 0 or n > 1000:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid max_tokens_per_proxy", status_code=400)
out["max_tokens_per_proxy"] = int(n)
return out
@router.post("/proxies/endpoints/import")
async def import_proxy_endpoints(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
body = await _load_import_json(request)
text = str(body["text"])
source = str(body["source"])
conflict_policy = str(body["conflict_policy"])
created = 0
updated = 0
skipped = 0
errors: list[dict[str, Any]] = []
now = iso_utc_ms()
engine = request.app.state.engine
Session = create_sessionmaker(engine)
settings = request.app.state.settings
encryptor: FieldEncryptor | None = None
async def _op() -> None:
nonlocal created, updated, skipped, errors, encryptor
async with Session() as session:
for line_no, raw in enumerate(text.splitlines(), start=1):
uri = raw.strip()
if not uri:
continue
try:
parsed = parse_proxy_uri(uri)
except Exception:
errors.append({"line": line_no, "code": "invalid_proxy_uri", "message": "invalid_proxy_uri"})
continue
username = (parsed.username or "").strip()
password = parsed.password
password_enc = ""
if password:
if encryptor is None:
try:
encryptor = FieldEncryptor.from_key(settings.field_encryption_key)
except Exception:
errors.append(
{
"line": line_no,
"code": "encryption_not_configured",
"message": "代理包含密码,但未配置加密密钥(FIELD_ENCRYPTION_KEY)",
}
)
continue
password_enc = encryptor.encrypt_text(password)
existing = (
(
await session.execute(
sa.select(ProxyEndpoint).where(
ProxyEndpoint.scheme == parsed.scheme,
ProxyEndpoint.host == parsed.host,
ProxyEndpoint.port == int(parsed.port),
ProxyEndpoint.username == username,
)
)
)
.scalars()
.first()
)
if existing is None:
session.add(
ProxyEndpoint(
scheme=parsed.scheme,
host=parsed.host,
port=int(parsed.port),
username=username,
password_enc=password_enc,
enabled=1,
source=source,
source_ref=None,
updated_at=now,
)
)
created += 1
continue
if conflict_policy == "overwrite":
existing.password_enc = password_enc
existing.enabled = 1
existing.source = source
existing.updated_at = now
updated += 1
else:
skipped += 1
await session.commit()
await with_sqlite_busy_retry(_op)
return {
"ok": True,
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors[:200],
"request_id": rid,
}
@router.put("/proxies/endpoints/{endpoint_id}")
async def update_proxy_endpoint(
endpoint_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if endpoint_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid endpoint id", status_code=400)
rid = get_or_create_request_id(request)
body = await _load_update_endpoint_json(request)
enabled = bool(body["enabled"])
now = iso_utc_ms()
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
row = await session.get(ProxyEndpoint, endpoint_id)
if row is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Proxy endpoint not found", status_code=404)
row.enabled = 1 if enabled else 0
row.updated_at = now
await session.commit()
return {"ok": True, "endpoint_id": str(endpoint_id), "enabled": enabled, "request_id": rid}
@router.post("/proxies/endpoints/{endpoint_id}/reset-failures")
async def reset_proxy_failures(
endpoint_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if endpoint_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid endpoint id", status_code=400)
rid = get_or_create_request_id(request)
now = iso_utc_ms()
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
row = await session.get(ProxyEndpoint, endpoint_id)
if row is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Proxy endpoint not found", status_code=404)
row.failure_count = 0
row.blacklisted_until = None
row.last_fail_at = None
row.last_error = None
row.updated_at = now
await session.commit()
return {"ok": True, "endpoint_id": str(endpoint_id), "request_id": rid}
@router.post("/proxies/endpoints/cleanup-invalid-hosts")
async def cleanup_invalid_hosts(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
body = await _load_cleanup_invalid_hosts_json(request)
dry_run = bool(body.get("dry_run", False))
delete_orphans = bool(body.get("delete_orphans", False))
recompute_bindings = bool(body.get("recompute_bindings", True))
max_tokens_per_proxy = int(body.get("max_tokens_per_proxy", 2))
strict = bool(body.get("strict", False))
invalid_hosts = {"0.0.0.0", "127.0.0.1", "localhost", "::", "::1", "[::]", "[::1]"}
engine = request.app.state.engine
Session = create_sessionmaker(engine)
now = iso_utc_ms()
async with Session() as session:
endpoint_rows = (
(
await session.execute(
sa.select(ProxyEndpoint.id, ProxyEndpoint.host)
.where(sa.func.lower(sa.func.trim(ProxyEndpoint.host)).in_(sorted(invalid_hosts)))
.order_by(ProxyEndpoint.id.asc())
)
)
.all()
)
endpoint_ids = [int(r[0]) for r in endpoint_rows]
affected_pool_ids: list[int] = []
if endpoint_ids:
pool_rows = (
(
await session.execute(
sa.select(sa.distinct(ProxyPoolEndpoint.pool_id))
.where(ProxyPoolEndpoint.endpoint_id.in_(endpoint_ids))
.order_by(ProxyPoolEndpoint.pool_id.asc())
)
)
.all()
)
affected_pool_ids = [int(r[0]) for r in pool_rows]
if dry_run:
return {
"ok": True,
"dry_run": True,
"invalid_hosts": sorted(invalid_hosts),
"matched": len(endpoint_ids),
"endpoint_ids": [str(x) for x in endpoint_ids[:200]],
"affected_pool_ids": [str(x) for x in affected_pool_ids],
"request_id": rid,
}
disabled = 0
memberships_removed = 0
overrides_cleared = 0
deleted = 0
binding_results: list[dict[str, Any]] = []
warnings: list[str] = []
if endpoint_ids:
disabled = int(
(
await session.execute(
sa.update(ProxyEndpoint)
.where(ProxyEndpoint.id.in_(endpoint_ids))
.values(enabled=0, updated_at=now)
)
).rowcount
or 0
)
memberships_removed = int(
(
await session.execute(
sa.delete(ProxyPoolEndpoint).where(ProxyPoolEndpoint.endpoint_id.in_(endpoint_ids))
)
).rowcount
or 0
)
overrides_cleared = int(
(
await session.execute(
sa.update(TokenProxyBinding)
.where(TokenProxyBinding.override_proxy_id.is_not(None))
.where(TokenProxyBinding.override_proxy_id.in_(endpoint_ids))
.values(override_proxy_id=None, override_expires_at=None, updated_at=now)
)
).rowcount
or 0
)
if recompute_bindings and affected_pool_ids:
for pid in affected_pool_ids:
try:
result = await recompute_token_proxy_bindings(
session,
pool_id=int(pid),
now=now,
max_tokens_per_proxy=int(max_tokens_per_proxy),
strict=bool(strict),
)
except ApiError as exc:
warnings.append(f"pool#{pid} 重算绑定失败: {exc.message}")
continue
binding_results.append({"pool_id": str(pid), **result})
if delete_orphans and endpoint_ids:
refs_primary = (
(
await session.execute(
sa.select(sa.distinct(TokenProxyBinding.primary_proxy_id)).where(
TokenProxyBinding.primary_proxy_id.in_(endpoint_ids)
)
)
)
.scalars()
.all()
)
refs_override = (
(
await session.execute(
sa.select(sa.distinct(TokenProxyBinding.override_proxy_id))
.where(TokenProxyBinding.override_proxy_id.is_not(None))
.where(TokenProxyBinding.override_proxy_id.in_(endpoint_ids))
)
)
.scalars()
.all()
)
referenced: set[int] = {int(x) for x in refs_primary if x is not None} | {int(x) for x in refs_override if x is not None}
deletable = [int(eid) for eid in endpoint_ids if int(eid) not in referenced]
if deletable:
deleted = int(
(await session.execute(sa.delete(ProxyEndpoint).where(ProxyEndpoint.id.in_(deletable)))).rowcount or 0
)
await session.commit()
return {
"ok": True,
"dry_run": False,
"invalid_hosts": sorted(invalid_hosts),
"matched": len(endpoint_ids),
"disabled": int(disabled),
"memberships_removed": int(memberships_removed),
"overrides_cleared": int(overrides_cleared),
"deleted": int(deleted),
"affected_pool_ids": [str(x) for x in affected_pool_ids],
"bindings": binding_results,
"warnings": warnings,
"request_id": rid,
}
@router.post("/proxies/easy-proxies/import")
async def import_easy_proxies(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
body = await _load_easy_import_json(request)
base_url_raw = str(body["base_url"])
base_url = _sanitize_source_ref(base_url_raw) or base_url_raw
password = str(body["password"])
conflict_policy = str(body["conflict_policy"])
host_override = body.get("host_override")
attach_pool_id = body.get("attach_pool_id")
attach_weight = int(body.get("attach_weight", 1))
recompute_bindings = bool(body.get("recompute_bindings", False))
max_tokens_per_proxy = int(body.get("max_tokens_per_proxy", 2))
strict = bool(body.get("strict", True))
export_host = resolve_export_host(base_url=base_url_raw, host_override=host_override)
transport = getattr(request.app.state, "httpx_transport", None)
try:
bearer_token: str | None = None
if password.strip():
auth = await easy_proxies_auth(base_url=base_url_raw, password=password, transport=transport)
bearer_token = auth.token
uris = await easy_proxies_export(base_url=base_url_raw, bearer_token=bearer_token, transport=transport)
except EasyProxiesError as exc:
raise ApiError(code=ErrorCode.INTERNAL_ERROR, message="easy_proxies import failed", status_code=502) from exc
except Exception as exc:
raise ApiError(code=ErrorCode.INTERNAL_ERROR, message="easy_proxies import failed", status_code=502) from exc
warnings: list[str] = []
raw_count = len(uris)
seen_keys: set[tuple[str, str, int, str]] = set()
deduped: list[str] = []
invalid_uris: list[str] = []
duplicates = 0
invalid = 0
placeholder_hosts = 0
rewritten_hosts = 0
for raw in uris:
uri = (raw or "").strip()
if not uri:
continue
try:
parsed = parse_proxy_uri(uri)
except Exception:
invalid += 1
invalid_uris.append(uri)
continue
desired_host, rewrote = normalize_exported_proxy_host(exported_host=str(parsed.host), export_host=export_host)
if str(parsed.host or "").strip().lower() in {"0.0.0.0", "127.0.0.1", "localhost", "::", "::1"}:
placeholder_hosts += 1
if rewrote:
rewritten_hosts += 1
key = (str(parsed.scheme), str(desired_host), int(parsed.port), str((parsed.username or "").strip()))
if key in seen_keys:
duplicates += 1
continue
seen_keys.add(key)
deduped.append(uri)
uris = deduped
if duplicates > 0:
warnings.append(f"检测到导出结果包含重复入口(已去重 {duplicates} 条)。")
if placeholder_hosts > 0 and rewritten_hosts <= 0:
warnings.append("检测到 easy_proxies 导出 host 为 0.0.0.0/127.0.0.1/localhost 等占位符,但无法确定可用的替换 host;请检查 base_url 或使用 host_override。")
if rewritten_hosts > 0 and export_host:
warnings.append(f"检测到 easy_proxies 导出 host 为占位符,已自动替换为 {export_host}。")
if raw_count > 1 and len(deduped) <= 1:
warnings.append("当前 easy_proxies 可能处于 pool 模式(所有节点共享同一入口端口),导入后只会得到一个入口。若要每节点独立端口,请在 easy_proxies 启用 multi-port 或 hybrid 模式后再导入。")
if invalid > 0:
warnings.append(f"有 {invalid} 条导出内容不是合法代理 URI,已跳过。")
created = 0
updated = 0
skipped = 0
errors: list[dict[str, Any]] = []
attach_created = 0
attach_updated = 0
attach_total = 0
binding_result: dict[str, Any] | None = None
now = iso_utc_ms()
engine = request.app.state.engine
Session = create_sessionmaker(engine)
settings = request.app.state.settings
encryptor: FieldEncryptor | None = None
async def _op() -> None:
nonlocal created, updated, skipped, errors, encryptor, attach_created, attach_updated, attach_total, binding_result
async with Session() as session:
for uri in deduped + invalid_uris:
try:
parsed = parse_proxy_uri(uri)
except Exception:
errors.append({"code": "invalid_proxy_uri", "message": "invalid_proxy_uri"})
continue
username = (parsed.username or "").strip()
password_v = parsed.password
password_enc = ""
if password_v:
if encryptor is None:
try:
encryptor = FieldEncryptor.from_key(settings.field_encryption_key)
except Exception:
errors.append(
{
"code": "encryption_not_configured",
"message": "代理包含密码,但未配置加密密钥(FIELD_ENCRYPTION_KEY)",
}
)
continue
password_enc = encryptor.encrypt_text(password_v)
desired_host, rewrote = normalize_exported_proxy_host(
exported_host=str(parsed.host),
export_host=export_host,
)
original_host = str(parsed.host)
existing = (
(
await session.execute(
sa.select(ProxyEndpoint).where(
ProxyEndpoint.scheme == parsed.scheme,
ProxyEndpoint.host == desired_host,
ProxyEndpoint.port == int(parsed.port),
ProxyEndpoint.username == username,
)
)
)
.scalars()
.first()
)
moved_from_placeholder = False
if existing is None and rewrote and desired_host and desired_host != original_host:
placeholder = (
(
await session.execute(
sa.select(ProxyEndpoint).where(
ProxyEndpoint.scheme == parsed.scheme,
ProxyEndpoint.host == original_host,
ProxyEndpoint.port == int(parsed.port),
ProxyEndpoint.username == username,
)
)
)
.scalars()
.first()
)
if placeholder is not None:
can_update_placeholder = conflict_policy == "overwrite" or (
conflict_policy == "skip_non_easy_proxies" and (placeholder.source or "") == "easy_proxies"
)
if can_update_placeholder:
existing = placeholder
existing.host = desired_host
moved_from_placeholder = True
if existing is None:
session.add(
ProxyEndpoint(
scheme=parsed.scheme,
host=desired_host or original_host,
port=int(parsed.port),
username=username,
password_enc=password_enc,
enabled=1,
source="easy_proxies",
source_ref=base_url,
updated_at=now,
)
)
created += 1
continue
if conflict_policy == "skip_non_easy_proxies" and (existing.source or "") != "easy_proxies":
if moved_from_placeholder:
# can't update, but the desired host identity is new; create a new easy_proxies entry
session.add(
ProxyEndpoint(
scheme=parsed.scheme,
host=desired_host or original_host,
port=int(parsed.port),
username=username,
password_enc=password_enc,
enabled=1,
source="easy_proxies",
source_ref=base_url,
updated_at=now,
)
)
created += 1
else:
skipped += 1
continue
if conflict_policy == "skip":
skipped += 1
continue
existing.password_enc = password_enc
existing.enabled = 1
existing.source = "easy_proxies"
existing.source_ref = base_url
# easy-proxies export only contains currently-healthy endpoints; clear local blacklist so they can be used immediately.
existing.blacklisted_until = None
existing.last_error = None
existing.updated_at = now
updated += 1
if attach_pool_id is not None:
pool = await session.get(ProxyPool, int(attach_pool_id))
if pool is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Proxy pool not found", status_code=404)
endpoint_ids = (
(
await session.execute(
sa.select(ProxyEndpoint.id)
.where(ProxyEndpoint.source == "easy_proxies")
.where(ProxyEndpoint.source_ref == base_url)
.where(ProxyEndpoint.enabled == 1)
.order_by(ProxyEndpoint.id.asc())
)
)
.scalars()
.all()
)
attach_total = len(endpoint_ids)
if endpoint_ids:
existing_members = (
(
await session.execute(
sa.select(ProxyPoolEndpoint.endpoint_id)
.where(ProxyPoolEndpoint.pool_id == int(attach_pool_id))
.where(ProxyPoolEndpoint.endpoint_id.in_([int(x) for x in endpoint_ids]))
)
)
.scalars()
.all()
)
existing_set = {int(x) for x in existing_members}
attach_created = len([x for x in endpoint_ids if int(x) not in existing_set])
attach_updated = len([x for x in endpoint_ids if int(x) in existing_set])
for endpoint_id in endpoint_ids:
stmt = sqlite_insert(ProxyPoolEndpoint).values(
pool_id=int(attach_pool_id),
endpoint_id=int(endpoint_id),
enabled=1,
weight=int(attach_weight),
updated_at=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=[ProxyPoolEndpoint.pool_id, ProxyPoolEndpoint.endpoint_id],
set_={"enabled": 1, "weight": int(attach_weight), "updated_at": now},
)
await session.execute(stmt)
if recompute_bindings:
binding_result = await recompute_token_proxy_bindings(
session,
pool_id=int(attach_pool_id),
now=str(now),
max_tokens_per_proxy=int(max_tokens_per_proxy),
strict=bool(strict),
)
await session.commit()
await with_sqlite_busy_retry(_op)
resp: dict[str, Any] = {
"ok": True,
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors[:200],
"warnings": warnings[:50],
"request_id": rid,
}
if attach_pool_id is not None:
resp["attach"] = {
"pool_id": str(attach_pool_id),
"endpoints_total": int(attach_total),
"created": int(attach_created),
"updated": int(attach_updated),
}
if binding_result is not None:
resp["bindings"] = {"pool_id": str(attach_pool_id), **binding_result}
return resp
@router.post("/proxies/probe")
async def probe_proxies(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
opts = await _load_probe_json(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> int:
async with Session() as session:
job = JobRow(
type="proxy_probe",
status="pending",
payload_json=json.dumps({"scope": "all", **opts}, ensure_ascii=False),
ref_type="proxy_probe",
ref_id="all",
)
session.add(job)
await session.flush()
await session.commit()
return int(job.id)
job_id = await with_sqlite_busy_retry(_op)
return {"ok": True, "job_id": str(job_id), "request_id": rid}