Fix + Docker

This commit is contained in:
Lev 2026-04-16 21:07:58 +03:00
parent 8aa4828239
commit 0e71fe7abe
10 changed files with 92 additions and 35 deletions

3
.gitignore vendored
View file

@ -3,4 +3,5 @@
API/__pycache__ API/__pycache__
API/models/__pycache__ API/models/__pycache__
API/routers/__pycache__ API/routers/__pycache__
API/schemas/__pycache__ API/schemas/__pycache__
API/utils/__pycache__

7
API/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM python:3.14.4-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -9,10 +9,11 @@ class Server(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
code: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) code: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
host: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) host: Mapped[str] = mapped_column(String(128), nullable=False)
port: Mapped[int] = mapped_column(nullable=False) port: Mapped[int] = mapped_column(nullable=False)
user: Mapped[str] = mapped_column(String(64), nullable=False) user: Mapped[str] = mapped_column(String(64), nullable=False)
password: Mapped[str] = mapped_column(String(64), nullable=False) password: Mapped[str] = mapped_column(String(64), nullable=False)
inbound_id: Mapped[int] = mapped_column(nullable=False) inbound_id: Mapped[int] = mapped_column(nullable=False)
version: Mapped[str] = mapped_column(String(16), nullable=False, default="stable")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -4,4 +4,4 @@ psycopg2-binary
asyncpg asyncpg
passlib passlib
bcrypt==4.3.0 bcrypt==4.3.0
httpx httpx

View file

@ -42,7 +42,6 @@ async def create_config(body: ConfigCreate, current_user: User = Depends(get_cur
client_email_xui = f"{current_user.email}-{body.name}" client_email_xui = f"{current_user.email}-{body.name}"
display_name = f"SpectralVPN-{body.name}" display_name = f"SpectralVPN-{body.name}"
config_url = await xui.add_client( config_url = await xui.add_client(
inbound_id=server.inbound_id,
client_email=client_email_xui, client_email=client_email_xui,
display_name=display_name display_name=display_name
) )
@ -119,10 +118,10 @@ async def delete_config(body: ConfigDelete, current_user: User = Depends(get_cur
.options(selectinload(User.server)) .options(selectinload(User.server))
) )
server = server_result.scalar_one().server server = server_result.scalar_one().server
if server and server.inbound_id: if server:
xui = await XUIClient.from_server(server) xui = await XUIClient.from_server(server)
client_email_xui = f"{current_user.email}-{body.name}" client_email_xui = f"{current_user.email}-{body.name}"
await xui.delete_client(server.inbound_id, client_email_xui) await xui.delete_client(client_email_xui)
except: except:
HTTPException( HTTPException(
status_code=500, status_code=500,

View file

@ -10,27 +10,26 @@ router = APIRouter(prefix="/server")
@router.post("/add", status_code=201) @router.post("/add", status_code=201)
async def add_server(body: ServerAdd, request: Request, db: AsyncSession = Depends(get_db)): async def add_server(body: ServerAdd, request: Request, db: AsyncSession = Depends(get_db)):
ip = request.client.host
existing = await db.execute( existing = await db.execute(
select(Server).where( select(Server).where(
(Server.name == body.name) | (Server.name == body.name) |
(Server.code == body.code) | (Server.code == body.code)
(Server.host == ip)
) )
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="Name, ip or code is already exists" detail="Nameor code is already exists"
) )
new_server = Server( new_server = Server(
name=body.name, name=body.name,
code=body.code, code=body.code,
host=ip, host=body.host,
port=body.port, port=body.port,
user=body.user, user=body.user,
password=body.password, password=body.password,
inbound_id=body.inbound_id inbound_id=body.inbound_id,
version=body.version
) )
db.add(new_server) db.add(new_server)
await db.commit() await db.commit()

View file

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ServerBase(BaseModel): class ServerBase(BaseModel):
@ -7,9 +8,11 @@ class ServerBase(BaseModel):
password: str = Field(max_length=64) password: str = Field(max_length=64)
class ServerAdd(ServerBase): class ServerAdd(ServerBase):
host: str = Field(max_length=128)
port: int = Field(ge=1, le=65536) port: int = Field(ge=1, le=65536)
code: str = Field(max_length=128) code: str = Field(max_length=128)
inbound_id: int inbound_id: int
version: Literal["legacy", "stable"]
class ServerDel(ServerBase): class ServerDel(ServerBase):
pass pass
@ -19,3 +22,4 @@ class ServerInfo(BaseModel):
name: str = Field(max_length=64) name: str = Field(max_length=64)
code: str code: str
created_at: datetime created_at: datetime
version: str

View file

@ -3,9 +3,7 @@ from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker,AsyncEngine from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker,AsyncEngine
engine: AsyncEngine = create_async_engine( engine: AsyncEngine = create_async_engine(
#Todo Изменить на env, плдключение к db f"postgresql+asyncpg://{getenv("DB_USER")}:{getenv("DB_PASSWORD")}@db:5432/{getenv("DB_NAME")}",
f"postgresql+asyncpg://spectral:spectral@localhost:5432/spectral",
echo=True, #Todo удалить
pool_pre_ping=True pool_pre_ping=True
) )

View file

@ -1,22 +1,26 @@
import json import json
import uuid import uuid
import random import random
from urllib.parse import quote
from httpx import AsyncClient from httpx import AsyncClient
from fastapi import HTTPException from fastapi import HTTPException
class XUIClient: class XUIClient:
def __init__(self, base_url: str, username: str, password: str): def __init__(self, host: str, base_url: str, username: str, password: str, inbound_id: int, version: str):
self.host = host
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.username = username self.username = username
self.password = password self.password = password
self.inbound_id = inbound_id
self.session = AsyncClient(timeout=20.0) self.session = AsyncClient(timeout=20.0)
self.logged_in = False self.logged_in = False
self.version = version
@classmethod @classmethod
async def from_server(cls, server): async def from_server(cls, server):
#TODO заменить на https #TODO заменить на https
base_url = f"http://{server.host}:{server.port}" base_url = f"http://{server.host}:{server.port}"
return cls(base_url, server.user, server.password) return cls(server.host, base_url, server.user, server.password, server.inbound_id, server.version)
async def _login(self): async def _login(self):
if self.logged_in: if self.logged_in:
@ -34,28 +38,28 @@ class XUIClient:
detail="Server error" detail="Server error"
) )
async def add_client(self, inbound_id: int, client_email: str, display_name: str) -> str: async def add_client(self, client_email: str, display_name: str) -> str:
await self._login() await self._login()
resp = await self.session.post(f"{self.base_url}/panel/inbound/list") resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
inbounds = data.get("obj", []) inbounds = data.get("obj")
inbound = next((i for i in inbounds if i.get("id") == inbound_id), None) inbound = next((i for i in inbounds if i.get("id") == self.inbound_id), None)
if not inbound: if not inbound:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"Server not found" detail=f"Server not found"
) )
stream_settings = json.loads(inbound.get("streamSettings", "{}")) stream_settings = json.loads(inbound.get("streamSettings"))
reality = stream_settings.get("realitySettings", {}) reality = stream_settings.get("realitySettings")
short_ids = reality.get("shortIds", [""]) short_ids = reality.get("shortIds")
server_names = reality.get("serverNames", {}) server_names = reality.get("serverNames")
suid = random.choice(short_ids) suid = random.choice(short_ids)
sni = random.choice(server_names) sni = random.choice(server_names)
pbk = reality.get("settings", {}).get("publicKey", "") pbk = reality.get("settings").get("publicKey")
client_uuid = str(uuid.uuid4()) client_uuid = str(uuid.uuid4())
add_payload = { add_payload = {
"id": inbound_id, "id": self.inbound_id,
"settings": json.dumps({ "settings": json.dumps({
"clients": [{ "clients": [{
"id": client_uuid, "id": client_uuid,
@ -74,15 +78,43 @@ class XUIClient:
} }
resp = await self.session.post(f"{self.base_url}/panel/inbound/addClient", json=add_payload) resp = await self.session.post(f"{self.base_url}/panel/inbound/addClient", json=add_payload)
resp.raise_for_status() resp.raise_for_status()
host = inbound.get("remark") resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
resp.raise_for_status()
data = resp.json()
inbounds = data.get("obj")
inbound = next((i for i in inbounds if i.get("id") == self.inbound_id), None)
if not inbound:
raise HTTPException(
status_code=500,
detail="Server error"
)
settings = json.loads(inbound.get("settings"))
clients = settings.get("clients")
current_client = next((i for i in clients if i.get("email") == client_email), None)
if not current_client:
raise HTTPException(
status_code=500,
detail="Server error"
)
client_uuid = current_client.get("id")
sub_id = current_client.get("subId")
stream_settings = json.loads(inbound.get("streamSettings"))
reality = stream_settings.get("realitySettings")
server_names = reality.get("serverNames")
setting = reality.get("settings")
pbk = setting.get("publicKey")
fp = setting.get("fingerprint")
spx = quote(setting.get("spiderX"), safe='')
sni = random.choice(server_names)
port = inbound.get("port")
config_url = ( config_url = (
f"vless://{client_uuid}@{host}?" f"vless://{client_uuid}@{self.host}:{port}?"
f"security=reality&" f"security=reality&"
f"pbk={pbk}&" f"pbk={pbk}&"
f"fp=random&" f"fp={fp}&"
f"sni={sni}&" f"sni={sni}&"
f"sid={suid}&" f"sid={sub_id}&"
f"spx=%2F&" f"spx={spx}&"
f"flow=xtls-rprx-vision#" f"flow=xtls-rprx-vision#"
f"{display_name}" f"{display_name}"
) )
@ -101,11 +133,11 @@ class XUIClient:
except: except:
return 0 return 0
async def delete_client(self, inbound_id: int, client_email: str): async def delete_client(self, client_email: str):
await self._login() await self._login()
try: try:
resp = await self.session.post( resp = await self.session.post(
f"{self.base_url}/panel/inbound/{inbound_id}/delClientByEmail/{client_email}" f"{self.base_url}/panel/inbound/{self.inbound_id}/delClientByEmail/{client_email}"
) )
resp.raise_for_status() resp.raise_for_status()
return return

View file

@ -1,6 +1,22 @@
services: services:
#api: api:
# Todo build:
context: ./API
dockerfile: Dockerfile
container_name: spectralvpn-api
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
extra_hosts:
- host.docker.internal:172.17.0.1
ports:
- 8000:8000
#web: #web:
# Todo # Todo
db: db: