Compare commits

...

10 commits

Author SHA1 Message Date
Lev
821e5a65ab fix 2026-04-20 23:36:13 +03:00
Lev
ecc16a198d Release 2026-04-20 16:31:12 +03:00
Lev
bc0838213e Release 1/2 2026-04-19 22:32:36 +03:00
Lev
4c4d325245 Unstable 2026-04-19 17:46:56 +03:00
Lev
11a84f4926 comment 2026-04-17 23:11:41 +03:00
Lev
89651a8e81 fix + vershions configs 2026-04-17 23:07:47 +03:00
Lev
9987500ac0 fix 2026-04-16 22:27:18 +03:00
Lev
0e71fe7abe Fix + Docker 2026-04-16 21:07:58 +03:00
Lev
8aa4828239 Refactor API 2026-04-16 00:07:48 +03:00
Lev
9c813cdfbe fix 2026-03-25 20:24:35 +03:00
43 changed files with 1438 additions and 936 deletions

7
.gitignore vendored
View file

@ -1,2 +1,7 @@
.venv
configs/config/params_spectralvpn.conf
.env
API/__pycache__
API/models/__pycache__
API/routers/__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

@ -1,109 +1,24 @@
from fastapi import FastAPI, HTTPException, Query, status
from pydantic import BaseModel, EmailStr
import configparser
import utils
from datetime import datetime
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from utils.database import engine
import models
from routers import server, user, config
app = FastAPI()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(models.Base.metadata.create_all)
yield
await engine.dispose()
config = configparser.ConfigParser()
config.read('params.conf', encoding='utf-8')
args = [
config["api"]["path"],
config["api"]["host"],
config["api"]["username"],
config["api"]["password"],
config.getint("api", "inbaund_id"),
config["api"]["inbaund_url"]
]
app = FastAPI(lifespan=lifespan)
class RegisterBody(BaseModel) :
email : EmailStr
password : str
app.include_router(server.router)
app.include_router(user.router)
app.include_router(config.router)
class LoginBody(BaseModel) :
email : EmailStr
password : str
class UrlsBody(BaseModel) :
urls_name : str
email : EmailStr
password : str
@app.post("/registration", status_code=status.HTTP_201_CREATED)
def registration(body: RegisterBody) :
methods = utils.API(*args)
result = methods.registration(body.email, body.password)
methods.close()
if result["code"] == 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is busy."
)
return result["data"]
@app.post("/login", status_code=status.HTTP_200_OK)
def login(body: LoginBody) :
methods = utils.API(*args)
answer = methods.login(body.email, body.password)
methods.close()
if answer["code"] == 1 :
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect email or password."
)
else :
return answer["data"]
@app.post("/add_url", status_code=status.HTTP_200_OK)
def add_url(body: UrlsBody) :
methods = utils.API(*args)
answer = methods.add_url(body.email, body.password, body.urls_name)
methods.close()
if answer["code"] == 1 :
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect email or password."
)
elif answer["code"] == 2 :
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The url already exists."
)
else :
return answer["data"]
@app.get("/get_url", status_code=status.HTTP_200_OK)
def get_url(email: EmailStr = Query(...), password: str = Query(...), urls_name: str = Query(...)) :
methods = utils.API(*args)
answer = methods.get_url(email, password, urls_name)
methods.close()
if answer["code"] == 1 :
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect email or password."
)
elif answer["code"] == 2 :
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The url does not exist"
)
else :
return answer["data"]
@app.delete("/del_url", status_code=status.HTTP_200_OK)
def del_url(email: EmailStr = Query(...), password: str = Query(...), urls_name: str = Query(...)) :
methods = utils.API(*args)
answer = methods.del_url(email, password, urls_name)
methods.close()
if answer["code"] == 1 :
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect email or password."
)
elif answer["code"] == 2 :
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The url does not exist"
)
else :
return answer["data"]
@app.get("/ping")
async def ping():
return {"message": "pong", "time": datetime.now().timestamp()}

7
API/models/__init__.py Normal file
View file

@ -0,0 +1,7 @@
from .base import Base
from .user import User
from .server import Server
from .token import AuthToken
from .config import Config
__all__ = ["Base", "User", "Server", "AuthToken", "Config"]

4
API/models/base.py Normal file
View file

@ -0,0 +1,4 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

14
API/models/config.py Normal file
View file

@ -0,0 +1,14 @@
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .base import Base
class Config(Base):
__tablename__ = "configs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
config: Mapped[str] = mapped_column(String(1024), 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)

19
API/models/server.py Normal file
View file

@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .base import Base
class Server(Base):
__tablename__ = "servers"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=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)
host: Mapped[str] = mapped_column(String(128), nullable=False)
port: Mapped[int] = mapped_column(nullable=False)
user: 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)
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)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

17
API/models/token.py Normal file
View file

@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .base import Base
class AuthToken(Base):
__tablename__ = "tokens"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token_hash: Mapped[str] = mapped_column(String(512), index=True, nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked: Mapped[bool] = mapped_column(default=False, nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
def is_valid(self) -> bool:
return not self.revoked and self.expires_at > datetime.utcnow()

16
API/models/user.py Normal file
View file

@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .base import Base
from .server import Server
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(256), index=True, nullable=False)
pass_hash: Mapped[str] = mapped_column(String(512), nullable=False)
server_id: Mapped[int] = mapped_column(ForeignKey("servers.id"))
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)
server: Mapped[Server] = relationship("Server", lazy="selectin")

7
API/requirements.txt Normal file
View file

@ -0,0 +1,7 @@
fastapi[all]
sqlalchemy
psycopg2-binary
asyncpg
passlib
bcrypt==4.3.0
httpx

132
API/routers/config.py Normal file
View file

@ -0,0 +1,132 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.user import User
from models.config import Config
from schemas.config import ConfigCreate, ConfigResponse, ConfigListResponse, ConfigDelete
from utils.auth import get_current_user
from utils.database import get_db
from utils.xui import XUIClient
router = APIRouter(prefix="/config")
@router.post("/add", status_code=201)
async def create_config(body: ConfigCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
existing = await db.execute(
select(Config).where(
Config.user_id == current_user.id,
Config.name == body.name,
Config.deleted_at.is_(None)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail="Config name is invalid"
)
result = await db.execute(
select(User)
.where(User.id == current_user.id)
.options(selectinload(User.server))
)
user_with_server = result.scalar_one()
server = user_with_server.server
if not server or server.deleted_at is not None:
raise HTTPException(
status_code=404,
detail="The server not found"
)
xui = await XUIClient.from_server(server)
client_email_xui = f"{current_user.email}-{body.name}"
display_name = f"SpectralVPN-{body.name}"
config_url = await xui.add_client(
client_email=client_email_xui,
display_name=display_name
)
new_config = Config(
user_id=current_user.id,
name=body.name,
config=config_url
)
db.add(new_config)
await db.commit()
await db.refresh(new_config)
return ConfigResponse(
name=new_config.name,
config=new_config.config,
created_at=new_config.created_at,
bytes_used=0
)
@router.get("/get_info")
async def get_configs(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Config).where(
Config.user_id == current_user.id,
Config.deleted_at.is_(None)
).order_by(Config.created_at.desc())
)
configs_db = result.scalars().all()
configs = []
xui = None
for cfg in configs_db:
bytes_used = 0
try:
if xui is None:
server_result = await db.execute(
select(User)
.where(User.id == current_user.id)
.options(selectinload(User.server))
)
server = server_result.scalar_one().server
xui = await XUIClient.from_server(server)
client_email = f"{current_user.email}-{cfg.name}"
bytes_used = await xui.get_client_traffic(client_email)
except:
pass
configs.append(
ConfigResponse(
name=cfg.name,
config=cfg.config,
created_at=cfg.created_at,
bytes_used=bytes_used
)
)
return ConfigListResponse(configs=configs)
@router.delete("/delete", status_code=204)
async def delete_config(body: ConfigDelete, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Config).where(
Config.user_id == current_user.id,
Config.name == body.name,
Config.deleted_at.is_(None)
)
)
config_record = result.scalar_one_or_none()
if not config_record:
raise HTTPException(
status_code=404,
detail="Config not found"
)
try:
server_result = await db.execute(
select(User)
.where(User.id == current_user.id)
.options(selectinload(User.server))
)
server = server_result.scalar_one().server
if server:
xui = await XUIClient.from_server(server)
client_email_xui = f"{current_user.email}-{body.name}"
await xui.delete_client(client_email_xui)
except:
HTTPException(
status_code=500,
detail="Server error"
)
config_record.deleted_at = datetime.utcnow()
await db.commit()
return None

57
API/routers/server.py Normal file
View file

@ -0,0 +1,57 @@
from datetime import datetime
from fastapi import APIRouter, Request, Depends, HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.server import Server
from schemas.server import ServerAdd, ServerDel, ServerInfo
from utils.database import get_db
router = APIRouter(prefix="/server")
@router.post("/add", status_code=201)
async def add_server(body: ServerAdd, request: Request, db: AsyncSession = Depends(get_db)):
existing = await db.execute(
select(Server).where(
(Server.name == body.name) |
(Server.code == body.code)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail="Nameor code is already exists"
)
new_server = Server(
name=body.name,
code=body.code,
host=body.host,
port=body.port,
user=body.user,
password=body.password,
inbound_id=body.inbound_id,
version=body.version
)
db.add(new_server)
await db.commit()
await db.refresh(new_server)
return ServerInfo.model_validate(new_server, from_attributes=True)
@router.delete("/delete", status_code=204)
async def del_server(body: ServerDel, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Server).where(
(Server.name == body.name) &
(Server.user == body.user) &
(Server.password == body.password) &
(Server.deleted_at.is_(None))
)
)
server = result.scalar_one_or_none()
if not server:
raise HTTPException(
status_code=404,
detail="Server not found, or invalid secure_key"
)
server.deleted_at = datetime.utcnow()
await db.commit()
return None

144
API/routers/user.py Normal file
View file

@ -0,0 +1,144 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from schemas.user import UserCreate, UserResponse, UserLogin, UserTokenRevoke
from models.server import Server
from models.user import User
from models.config import Config
from models.token import AuthToken
from utils.database import get_db
from utils.auth import get_password_hash, create_token, verify_password, get_current_user, hash_token
from utils.xui import XUIClient
router = APIRouter(prefix="/user")
@router.post("/signup", status_code=201)
async def signup(body: UserCreate, db: AsyncSession = Depends(get_db)):
promo_code = (body.promo_code or "").strip()
result = await db.execute(
select(Server).where(
Server.deleted_at.is_(None),
Server.code == promo_code
)
)
server = result.scalar_one_or_none()
if not server:
raise HTTPException(
status_code=404,
detail="Promo-code is not valid"
)
existing = await db.execute(
select(User).where(
User.email == body.email,
User.deleted_at.is_(None)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail="Email is busy"
)
new_user = User(
email=body.email,
pass_hash= get_password_hash(body.password),
server_id=server.id
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
access_token = await create_token(user_id=new_user.id, db=db)
return UserResponse(
id=new_user.id,
email=new_user.email,
created_at=new_user.created_at,
access_token=access_token
)
@router.post("/login", status_code=200)
async def login(body: UserLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).where(User.email == body.email)
)
user = result.scalar_one_or_none()
if not user or user.deleted_at is not None:
raise HTTPException(
status_code=401,
detail="Incorrect email or password"
)
if not await verify_password(body.password, user.pass_hash, user.id, db):
raise HTTPException(
status_code=401,
detail="Incorrect email or password"
)
access_token = await create_token(user_id=user.id, db=db)
return UserResponse(
id=user.id,
email=user.email,
created_at=user.created_at,
access_token=access_token
)
@router.post("/logout", status_code=200)
async def revoke_token(body: UserTokenRevoke, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
token_to_revoke = body.token_to_revoke.strip()
token_hash = hash_token(token_to_revoke)
result = await db.execute(
select(AuthToken).where(
AuthToken.token_hash == token_hash,
AuthToken.user_id == current_user.id
)
)
token_record = result.scalar_one_or_none()
if not token_record:
raise HTTPException(
status_code=404,
detail="Token not found"
)
if token_record.revoked:
return {"message": "Token already revoked"}
token_record.revoked = True
token_record.revoked_at = datetime.utcnow()
await db.commit()
return {"message": "Token successfully revoked"}
@router.delete("/delete", status_code=200)
async def delete_user(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
if current_user.deleted_at is not None:
raise HTTPException(
status_code=400,
detail="User is already deleted"
)
try:
await db.refresh(current_user, ["server"])
server = current_user.server
if server and server.inbound_id:
xui = await XUIClient.from_server(server)
result = await db.execute(
select(Config).where(
Config.user_id == current_user.id,
Config.deleted_at.is_(None)
)
)
user_configs = result.scalars().all()
for cfg in user_configs:
client_email_xui = f"{current_user.email}-{cfg.name}"
try:
await xui.delete_client(server.inbound_id, client_email_xui)
except:
pass
except:
pass
current_user.deleted_at = datetime.utcnow()
await db.execute(
update(AuthToken)
.where(AuthToken.user_id == current_user.id)
.values(revoked=True, revoked_at=datetime.utcnow())
)
await db.execute(
update(Config)
.where(Config.user_id == current_user.id, Config.deleted_at.is_(None))
.values(deleted_at=datetime.utcnow())
)
await db.commit()
return {"message": "User has been successfully deleted"}

20
API/schemas/config.py Normal file
View file

@ -0,0 +1,20 @@
from datetime import datetime
from pydantic import BaseModel, Field
class ConfigCreate(BaseModel):
name: str = Field(..., max_length=64)
class ConfigResponse(BaseModel):
name: str
config: str
created_at: datetime
bytes_used: int = 0
class Config:
from_attributes = True
class ConfigDelete(BaseModel):
name: str
class ConfigListResponse(BaseModel):
configs: list[ConfigResponse]

25
API/schemas/server.py Normal file
View file

@ -0,0 +1,25 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class ServerBase(BaseModel):
name: str = Field(max_length=64)
user: str = Field(max_length=64)
password: str = Field(max_length=64)
class ServerAdd(ServerBase):
host: str = Field(max_length=128)
port: int = Field(ge=1, le=65536)
code: str = Field(max_length=128)
inbound_id: int
version: Literal["legacy", "stable"]
class ServerDel(ServerBase):
pass
class ServerInfo(BaseModel):
id: int
name: str = Field(max_length=64)
code: str
created_at: datetime
version: str

21
API/schemas/user.py Normal file
View file

@ -0,0 +1,21 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr = Field(max_length=256)
password: str = Field(min_length=6, max_length=128)
promo_code: str | None = Field(default="", max_length=128)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserTokenRevoke(BaseModel):
token_to_revoke: str
class UserResponse(BaseModel):
id: int
email: EmailStr
created_at: datetime
access_token: str
token_type: str = "bearer"

View file

@ -1,253 +0,0 @@
import sqlite3
import pandas
import datetime
import hashlib
import json
import uuid
import requests
import random
class API :
def __init__(self, path: str, host: str, username: str, passwd: str, inbaund_id: int, inbaund_url: str) :
self.con = sqlite3.connect(path)
self.cur = self.con.cursor()
self.cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL,
register TEXT NOT NULL,
urls TEXT DEFAULT "[]" NOT NULL,
payment TEXT DEFAULT ""
)
""")
self.con.commit()
self.x_ui = requests.Session()
self.x_ui.verify = True
self.x_ui.headers.update({
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (compatible; py3xui-like)"
})
response = self.x_ui.post(f"{host}/login", json={
"username": username,
"password": passwd
})
response.raise_for_status()
self.host = host
self.inbaund_id = inbaund_id
self.inbaund_url = inbaund_url
def read_table(self, sql_command: str, params=None) -> dict :
if params == None :
params = ()
return json.loads(pandas.read_sql(sql_command, self.con, params=params).to_json())
@staticmethod
def get_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def response(self, method: str, endpoint: str, **kwargs) :
response = self.x_ui.request(method, f"{self.host}/panel/{endpoint}", **kwargs)
response.raise_for_status()
return response.json()
def registration(self, email: str, passwd: str) -> dict :
"""
0 - Success
1 - Email is busy
"""
date = datetime.datetime.now()
try :
self.cur.execute("""
INSERT INTO users (email, pass_hash, register)
VALUES (?, ?, ?)
""", (email, self.get_hash(passwd), f"{date.day}-{date.month}-{date.year} {date.hour}:{date.minute}:{date.second}"))
self.con.commit()
except :
return {"code": 1}
data = self.read_table("""
SELECT id FROM users
WHERE email = ?
""", (email,))
return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email}}
def close(self) :
self.con.close()
self.x_ui.close()
def login(self, email: str, passwd: str) -> dict :
"""
0 - Success
1 - Incorrect email or password
"""
data = self.read_table("""
SELECT id, urls FROM users
WHERE email = ? AND pass_hash = ?
""", (email, self.get_hash(passwd)))
if data["id"] == {} :
return {"code": 1}
return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": json.loads(data["urls"]["0"])}}
def add_url(self, email: str, passwd: str, url_name: str) -> dict :
"""
0 - Success
1 - Incorrect email or password
2 - The url already exists
"""
data = self.read_table("""
SELECT id, urls FROM users
WHERE email = ? AND pass_hash = ?
""", (email, self.get_hash(passwd)))
if data["id"] == {} :
return {"code": 1}
urls = json.loads(data["urls"]["0"])
if url_name in urls :
return {"code": 2}
response = self.response("post", "inbound/list")
inbaund = None
for i in response["obj"] :
if i["id"] == self.inbaund_id :
inbaund = i
break
if inbaund == None :
raise TypeError
stream = json.loads(inbaund["streamSettings"])
suid = random.choice(stream["realitySettings"]["shortIds"])
sni = random.choice(stream["realitySettings"]["serverNames"])
pbk = stream["realitySettings"]["settings"]["publicKey"]
uid = str(uuid.uuid4())
response = self.response("post", "inbound/addClient", json={
"id": self.inbaund_id,
"settings": json.dumps({
"clients":[{
"id": uid,
"flow": "xtls-rprx-vision",
"email": f"{email}-{url_name}",
"limitIp": 0,
"totalGB": 0,
"expiryTime": 0,
"enable": True,
"tgId": "",
"subId": suid,
"comment": "",
"reset": 0
}]
})
})
response = self.response("post", "inbound/list")
inbaund = None
for i in response["obj"] :
if i["id"] == self.inbaund_id :
inbaund = i
break
if inbaund == None :
raise TypeError
url = f"vless://{uid}@{self.inbaund_url}?security=reality&pbk={pbk}&fp=random&sni={sni}&sid={suid}&spx=%2F&flow=xtls-rprx-vision#SpectralVPN-{url_name}"
urls.append(url_name)
self.cur.execute("""
UPDATE users SET
urls = ?
WHERE id = ?
""", (json.dumps(urls), int(data["id"]["0"])))
self.con.commit()
data = self.read_table("""
SELECT id, urls FROM users
WHERE email = ? AND pass_hash = ?
""", (email, self.get_hash(passwd)))
return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": json.loads(data["urls"]["0"]), "url": url}}
def get_url(self, email: str, passwd: str, url_name: str) -> dict :
"""
0 - Success
1 - Incorrect email or password
2 - The url does not exist
"""
data = self.read_table("""
SELECT id, urls FROM users
WHERE email = ? AND pass_hash = ?
""", (email, self.get_hash(passwd)))
if data["id"] == {} :
return {"code": 1}
urls = json.loads(data["urls"]["0"])
if not url_name in urls :
return {"code": 2}
response = self.response("post", "inbound/list")
inbaund = None
for i in response["obj"] :
if i["id"] == self.inbaund_id :
inbaund = i
break
if inbaund == None :
raise TypeError
stream = json.loads(inbaund["streamSettings"])
suid = random.choice(stream["realitySettings"]["shortIds"])
sni = random.choice(stream["realitySettings"]["serverNames"])
pbk = stream["realitySettings"]["settings"]["publicKey"]
client = None
for i in json.loads(inbaund["settings"])["clients"] :
if i["email"] == f"{email}-{url_name}" :
client = i
break
if client == None :
raise TypeError
uid = client["id"]
url = f"vless://{uid}@{self.inbaund_url}?security=reality&pbk={pbk}&fp=random&sni={sni}&sid={suid}&spx=%2F&flow=xtls-rprx-vision#SpectralVPN-{url_name}"
return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": urls, "url": url}}
def del_url(self, email: str, passwd: str, url_name: str) -> dict :
"""
0 - Success
1 - Incorrect email or password
2 - The url does not exist
"""
data = self.read_table("""
SELECT id, urls FROM users
WHERE email = ? AND pass_hash = ?
""", (email, self.get_hash(passwd)))
if data["id"] == {} :
return {"code": 1}
urls = json.loads(data["urls"]["0"])
if not url_name in urls :
return {"code": 2}
response = self.response("post", "inbound/list")
inbaund = None
for i in response["obj"] :
if i["id"] == self.inbaund_id :
inbaund = i
break
if inbaund == None :
raise TypeError
client = None
for i in json.loads(inbaund["settings"])["clients"] :
if i["email"] == f"{email}-{url_name}" :
client = i
break
if client == None :
raise TypeError
uid = client["id"]
self.response("post", f"inbound/{self.inbaund_id}/delClient/{uid}")
urls.remove(url_name)
self.cur.execute("""
UPDATE users SET
urls = ?
WHERE id = ?
""", (json.dumps(urls), int(data["id"]["0"])))
self.con.commit()
data = self.read_table("""
SELECT id, urls FROM users
WHERE email = ? AND pass_hash = ?
""", (email, self.get_hash(passwd)))
return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": urls}}

74
API/utils/auth.py Normal file
View file

@ -0,0 +1,74 @@
import secrets
import hashlib
from datetime import datetime, timedelta
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from passlib.context import CryptContext
from models.token import AuthToken
from models.user import User
from utils.database import get_db
pwd_context = CryptContext(schemes=["bcrypt"])
API_KEY_HEADER = APIKeyHeader(name="X-API-KEY", auto_error=False)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
async def verify_password(plain_password: str, hashed_password: str, user_id: int, db: AsyncSession) -> bool:
if hashed_password != "Unknown":
return pwd_context.verify(plain_password, hashed_password)
new_hash = get_password_hash(plain_password)
await db.execute(
update(User)
.where(User.id == user_id)
.values(pass_hash=new_hash)
)
await db.commit()
return True
def hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
async def create_token(user_id: int, db: AsyncSession) -> str:
raw_token = secrets.token_urlsafe(48)
token_hash = hash_token(raw_token)
new_token = AuthToken(
user_id=user_id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(days=30)
)
db.add(new_token)
await db.commit()
await db.refresh(new_token)
return raw_token
async def get_current_user(api_key: Annotated[str | None, Depends(API_KEY_HEADER)], db: AsyncSession = Depends(get_db)) -> User:
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token is not valid"
)
token_hash = hash_token(api_key)
result = await db.execute(
select(AuthToken).where(
AuthToken.token_hash == token_hash,
AuthToken.revoked == False,
AuthToken.expires_at > datetime.utcnow()
)
)
token_record = result.scalar_one_or_none()
if not token_record:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token is expired"
)
user = await db.get(User, token_record.user_id)
if not user or user.deleted_at is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not found"
)
return user

24
API/utils/database.py Normal file
View file

@ -0,0 +1,24 @@
from os import getenv
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker,AsyncEngine
engine: AsyncEngine = create_async_engine(
f"postgresql+asyncpg://{getenv("DB_USER")}:{getenv("DB_PASSWORD")}@db:5432/{getenv("DB_NAME")}",
pool_pre_ping=True
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
autocommit=False,
autoflush=False,
expire_on_commit=False
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

176
API/utils/xui.py Normal file
View file

@ -0,0 +1,176 @@
import json
import uuid
import random
from urllib.parse import quote
from httpx import AsyncClient
from fastapi import HTTPException
class Configs:
def __init__(self, inbound_id: int, host: str, client_email: str, display_name: str):
self.inbound_id = inbound_id
self.host = host
self.client_email = client_email
self.display_name = display_name
def legacy_payload(self, data: dict,) -> dict:
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=404,
detail=f"Server not found"
)
stream_settings = json.loads(inbound.get("streamSettings"))
reality = stream_settings.get("realitySettings")
short_ids = reality.get("shortIds")
suid = random.choice(short_ids)
client_uuid = str(uuid.uuid4())
return {
"id": self.inbound_id,
"settings": json.dumps({
"clients": [{
"id": client_uuid,
"flow": "xtls-rprx-vision",
"email": self.client_email,
"limitIp": 0,
"totalGB": 0,
"expiryTime": 0,
"enable": True,
"tgId": "",
"subId": suid,
"comment": "",
"reset": 0
}]
})
}
def legacy_config(self, data: dict) -> str:
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") == self.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")
return (
f"vless://{client_uuid}@{self.host}:{port}?"
f"security=reality&"
f"pbk={pbk}&"
f"fp={fp}&"
f"sni={sni}&"
f"sid={sub_id}&"
f"spx={spx}&"
f"flow=xtls-rprx-vision#"
f"{self.display_name}"
)
class XUIClient:
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.username = username
self.password = password
self.inbound_id = inbound_id
self.session = AsyncClient(timeout=20.0)
self.logged_in = False
self.version = version
@classmethod
async def from_server(cls, server):
base_url = f"https://{server.host}:{server.port}"
return cls(server.host, base_url, server.user, server.password, server.inbound_id, server.version)
async def _login(self):
if self.logged_in:
return
try:
resp = await self.session.post(
f"{self.base_url}/login",
json={"username": self.username, "password": self.password}
)
resp.raise_for_status()
self.logged_in = True
except:
raise HTTPException(
status_code=500,
detail="Server error"
)
async def add_client(self, client_email: str, display_name: str) -> str:
configs = Configs(self.inbound_id, self.host, client_email, display_name)
await self._login()
resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
resp.raise_for_status()
if self.version == "legacy":
resp = await self.session.post(f"{self.base_url}/panel/inbound/addClient", json=configs.legacy_payload(resp.json()))
#New configs here
else:
raise HTTPException(
status_code=500,
detail="Server error"
)
resp.raise_for_status()
resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
resp.raise_for_status()
if self.version == "legacy":
return configs.legacy_config(resp.json())
#New configs here
else:
raise HTTPException(
status_code=500,
detail="Server error"
)
async def get_client_traffic(self, client_email: str) -> int:
await self._login()
try:
resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
resp.raise_for_status()
data = resp.json()
inbounds = data.get("obj")
for inbound in inbounds:
client_stats = inbound.get("clientStats")
for stat in client_stats:
if stat.get("email") == client_email:
up = stat.get("up")
down = stat.get("down")
return up + down
return 0
except:
return 0
async def delete_client(self, client_email: str):
await self._login()
try:
resp = await self.session.post(
f"{self.base_url}/panel/inbound/{self.inbound_id}/delClientByEmail/{client_email}"
)
resp.raise_for_status()
return
except:
HTTPException(
status_code=500,
detail="Server error"
)
async def close(self):
await self.session.aclose()

7
Frontend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM nginx:stable-alpine
COPY Web/ /usr/share/nginx/html/
COPY Nginx/spectralvpn.ru.nginx /etc/nginx/conf.d/spectralvpn.ru.conf
COPY Nginx/spectralvpn.ru_http.nginx /etc/nginx/conf.d/spectralvpn.ru_http.conf
COPY Nginx/spectralvpn_api.nginx /etc/nginx/conf.d/spectralvpn_api.conf
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,225 +0,0 @@
const API_BASE = "https://spectralvpn.ru:8500";
const getCookie = (name) => {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
};
const deleteCookie = (name) => {
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict`;
};
const sha256 = async (str) => {
const buf = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
};
const showNotification = (text, color = "cyan") => {
const notification = document.createElement("div");
notification.textContent = text;
notification.style.cssText = `
position: fixed; top: 20px; right: 20px; padding: 15px 25px; border-radius: 12px;
z-index: 9999; font-weight: 600; background: ${color === "cyan" ? "#00ffff22" : "#ff444422"};
color: ${color}; border: 1px solid ${color === "cyan" ? "cyan" : "#f66"};
`;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
};
let currentEmail = null;
let currentHash = null;
const modal = document.getElementById("loginModal");
const showModal = () => modal.classList.add("active");
const hideModal = () => modal.classList.remove("active");
const showLoginError = (msg) => {
document.getElementById("loginError").textContent = msg;
};
const renderUrls = (urls) => {
const container = document.getElementById("urlsList");
container.innerHTML = "";
if (urls.length === 0) {
container.innerHTML = "<p style='text-align:center; color:#666;'>У вас пока нет конфигураций</p>";
return;
}
urls.forEach(name => {
const card = document.createElement("div");
card.className = "url-card";
card.innerHTML = `
<div>
<div class="url-name">${name}</div>
</div>
<div class="url-actions">
<button class="btn-copy" data-name="${name}">Скопировать</button>
<button class="btn-delete" data-name="${name}">Удалить</button>
</div>
`;
card.querySelector(".btn-copy").onclick = async () => {
try {
const url = `${API_BASE}/get_url?email=${encodeURIComponent(currentEmail)}&password=${encodeURIComponent(currentHash)}&urls_name=${encodeURIComponent(name)}`;
const res = await fetch(url);
const data = await res.json();
if (res.ok && data.url) {
await navigator.clipboard.writeText(data.url);
showNotification(`Конфиг "${name}" скопирован!`);
} else {
showNotification("Ошибка получения конфига", "red");
}
} catch (e) {
showNotification("Нет связи с сервером", "red");
}
};
card.querySelector(".btn-delete").onclick = async () => {
if (!confirm(`Удалить конфиг "${name}"?`)) return;
try {
const url = `${API_BASE}/del_url?email=${encodeURIComponent(currentEmail)}&password=${encodeURIComponent(currentHash)}&urls_name=${encodeURIComponent(name)}`;
const res = await fetch(url, { method: "DELETE" });
if (res.ok) {
card.remove();
showNotification(`Конфиг "${name}" удалён`, "red");
} else {
showNotification("Не удалось удалить", "red");
}
} catch (e) {
showNotification("Ошибка сети", "red");
}
};
container.appendChild(card);
});
};
const tryAutoLogin = async () => {
currentEmail = getCookie("user_email");
currentHash = getCookie("user_hash");
if (!currentEmail || !currentHash) {
showModal();
return;
}
try {
const res = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: currentEmail, password: currentHash })
});
if (res.ok) {
const data = await res.json();
document.getElementById("userEmail").textContent = currentEmail;
document.getElementById("mainContent").classList.remove("hidden");
renderUrls(data.urls || []);
} else {
deleteCookie("user_email");
deleteCookie("user_hash");
showModal();
}
} catch (e) {
showModal();
}
};
document.getElementById("loginSubmit").onclick = async () => {
const email = document.getElementById("loginEmail").value.trim();
const pass = document.getElementById("loginPassword").value;
if (!email || !pass) {
showLoginError("Заполните все поля");
return;
}
const hash = await sha256(pass);
try {
const res = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: hash })
});
const data = await res.json();
if (res.ok) {
currentEmail = email;
currentHash = hash;
document.cookie = `user_email=${encodeURIComponent(email)}; path=/; max-age=2592000; Secure; SameSite=Strict`;
document.cookie = `user_hash=${hash}; path=/; max-age=2592000; Secure; SameSite=Strict`;
document.getElementById("userEmail").textContent = email;
hideModal();
document.getElementById("mainContent").classList.remove("hidden");
renderUrls(data.urls || []);
} else {
showLoginError("Неверный email или пароль");
}
} catch (e) {
showLoginError("Ошибка подключения");
}
};
document.getElementById("addUrlBtn").onclick = async () => {
const name = prompt("Введите название конфигурации:");
if (!name?.trim()) return;
try {
const res = await fetch(`${API_BASE}/add_url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: currentEmail,
password: currentHash,
urls_name: name.trim()
})
});
const data = await res.json();
if (res.ok) {
renderUrls(data.urls);
showNotification(`Конфиг "${name}" создан! Скопируйте через кнопку.`);
} else {
showNotification("Ошибка: " + (data.detail || "неизвестно"), "red");
}
} catch (e) {
showNotification("Нет связи с сервером", "red");
}
};
document.getElementById("logoutBtn").onclick = () => {
deleteCookie("user_email");
deleteCookie("user_hash");
location.reload();
};
document.getElementById("closeModal").onclick = hideModal;
tryAutoLogin();
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.instructions').forEach(el => el.classList.add('hidden'));
const platform = btn.dataset.platform;
const target = document.getElementById(`${platform}-instructions`);
if (target) {
target.classList.remove('hidden');
}
});
});
document.addEventListener('DOMContentLoaded', () => {});

View file

@ -10,7 +10,7 @@ server {
gzip_types text/css text/javascript text/plain application/javascript application/json;
gzip_min_length 1000;
root /var/www/html/spectralvpn.ru;
root /usr/share/nginx/html/;
index index.html;
location / {

View file

@ -3,4 +3,4 @@ server {
listen [::]:80;
server_name spectralvpn.ru;
return 301 https://$host$request_uri;
}
}

View file

@ -0,0 +1,34 @@
server {
listen 8000 ssl;
listen [::]:8000 ssl;
server_name spectralvpn.ru;
ssl_certificate /etc/letsencrypt/live/spectralvpn.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spectralvpn.ru/privkey.pem;
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://spectralvpn.ru' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'X-API-KEY, Content-Type, Authorization' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://spectralvpn.ru' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' '*' always; # если нужно читать заголовки ответа
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
}
}

View file

@ -0,0 +1,251 @@
const API_BASE = "https://spectralvpn.ru:8000";
const getToken = () => localStorage.getItem("access_token");
const showNotification = (message, type = "success") => {
const color = type === "success" ? "#00ffff" : "#ff5555";
const notif = document.createElement("div");
notif.style.cssText = `
position: fixed; top: 20px; right: 20px; padding: 14px 24px; border-radius: 12px;
background: rgba(0,0,0,0.96); border: 1px solid ${color}; color: ${color};
z-index: 10000; font-weight: 500; box-shadow: 0 4px 20px rgba(0,0,0,0.6);
`;
notif.textContent = message;
document.body.appendChild(notif);
setTimeout(() => notif.remove(), 4500);
};
async function apiRequest(endpoint, options = {}) {
const token = getToken();
if (!token) {
showLoginModal();
return null;
}
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
"X-API-KEY": token,
"Content-Type": "application/json",
...options.headers
}
});
if (res.status === 401) {
localStorage.removeItem("access_token");
showNotification("Сессия истекла. Войдите заново.", "error");
showLoginModal();
return null;
}
return res;
}
function showLoginModal() {
document.getElementById("loginModal").classList.add("active");
document.getElementById("mainContent").classList.add("hidden");
document.getElementById("userInfo").style.display = "none";
}
function hideLoginModal() {
document.getElementById("loginModal").classList.remove("active");
document.getElementById("mainContent").classList.remove("hidden");
document.getElementById("userInfo").style.display = "flex";
}
async function loadPanel() {
const token = getToken();
if (!token) {
showLoginModal();
return;
}
const res = await apiRequest("/config/get_info", { method: "GET" });
if (!res) return;
if (!res.ok) {
showNotification("Ошибка загрузки данных", "error");
return;
}
const data = await res.json();
document.getElementById("userEmail").textContent = data.email || "Аккаунт активен";
hideLoginModal();
renderConfigs(data.configs || []);
}
function renderConfigs(configs) {
const container = document.getElementById("urlsList");
container.innerHTML = "";
if (configs.length === 0) {
container.innerHTML = `<p style="text-align:center; color:#666; padding:50px 20px; font-size:15px;">
У вас пока нет конфигураций.<br>Нажмите кнопку ниже, чтобы создать первую.
</p>`;
return;
}
configs.forEach(cfg => {
const trafficGB = (cfg.bytes_used / (1024 ** 3)).toFixed(2);
const card = document.createElement("div");
card.className = "url-card";
card.innerHTML = `
<div class="url-info">
<div class="url-name">${cfg.name}</div>
<div class="traffic">Использовано: ${trafficGB} ГБ</div>
</div>
<div class="url-actions">
<button class="btn btn-copy" data-config="${cfg.config}">Скопировать</button>
<button class="btn btn-delete" data-name="${cfg.name}">Удалить</button>
</div>
`;
card.querySelector(".btn-copy").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(cfg.config);
showNotification(`Конфиг "${cfg.name}" скопирован`);
} catch (e) {
showNotification("Не удалось скопировать", "error");
}
});
card.querySelector(".btn-delete").addEventListener("click", async () => {
if (!confirm(`Удалить конфигурацию "${cfg.name}"?`)) return;
const res = await apiRequest("/config/delete", {
method: "DELETE",
body: JSON.stringify({ name: cfg.name })
});
if (res && res.ok) {
showNotification(`Конфиг "${cfg.name}" удалён`);
loadPanel();
} else {
showNotification("Не удалось удалить конфиг", "error");
}
});
container.appendChild(card);
});
}
document.getElementById("loginSubmit").addEventListener("click", async () => {
const email = document.getElementById("loginEmail").value.trim();
const password = document.getElementById("loginPassword").value;
const errorEl = document.getElementById("loginError");
errorEl.textContent = "";
if (!email || !password) {
errorEl.textContent = "Введите email и пароль";
return;
}
try {
const res = await fetch(`${API_BASE}/user/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (res.ok && data.access_token) {
localStorage.setItem("access_token", data.access_token);
showNotification("Вход выполнен успешно!");
document.getElementById("loginEmail").value = "";
document.getElementById("loginPassword").value = "";
loadPanel();
} else {
errorEl.textContent = data.detail || "Неверный email или пароль";
}
} catch (err) {
console.error(err);
errorEl.textContent = "Нет связи с сервером. Попробуйте позже.";
}
});
document.getElementById("addConfigBtn").addEventListener("click", () => {
document.getElementById("addModal").classList.add("active");
document.getElementById("configName").focus();
});
document.getElementById("addCancel").addEventListener("click", () => {
document.getElementById("addModal").classList.remove("active");
document.getElementById("addError").textContent = "";
});
document.getElementById("addSubmit").addEventListener("click", async () => {
const name = document.getElementById("configName").value.trim();
const errorEl = document.getElementById("addError");
if (!name) {
errorEl.textContent = "Введите название конфигурации";
return;
}
const res = await apiRequest("/config/add", {
method: "POST",
body: JSON.stringify({ name })
});
if (res && res.ok) {
showNotification(`Конфигурация "${name}" создана!`);
document.getElementById("addModal").classList.remove("active");
document.getElementById("configName").value = "";
loadPanel();
} else {
const errData = await res.json().catch(() => ({}));
errorEl.textContent = errData.detail || "Ошибка при создании";
}
});
document.getElementById("logoutBtn").addEventListener("click", async () => {
if (!confirm("Выйти из аккаунта?")) return;
const token = getToken();
if (token) {
await fetch(`${API_BASE}/user/logout`, {
method: "POST",
headers: { "X-API-KEY": token, "Content-Type": "application/json" },
body: JSON.stringify({ token_to_revoke: token })
}).catch(() => {});
}
localStorage.removeItem("access_token");
showLoginModal();
});
document.getElementById("deleteAccountBtn").addEventListener("click", async () => {
if (!confirm("Вы уверены? Это действие необратимо!")) return;
const res = await apiRequest("/user/delete", { method: "DELETE" });
if (res && res.ok) {
localStorage.removeItem("access_token");
alert("Аккаунт успешно удалён.");
showLoginModal();
} else {
showNotification("Не удалось удалить аккаунт", "error");
}
});
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".instructions").forEach(instr => instr.classList.add("hidden"));
document.getElementById(btn.dataset.platform + "-instructions").classList.remove("hidden");
});
});
document.getElementById("addModal").addEventListener("click", (e) => {
if (e.target === document.getElementById("addModal")) {
document.getElementById("addModal").classList.remove("active");
}
});
document.addEventListener("DOMContentLoaded", loadPanel);

View file

@ -4,26 +4,20 @@ document.addEventListener("DOMContentLoaded", () => {
const setError = (fieldId, message) => {
const errorEl = document.getElementById(fieldId);
const inputEl = errorEl.previousElementSibling;
errorEl.textContent = message;
errorEl.classList.add("active");
if (inputEl) inputEl.classList.add("invalid");
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.add("active");
}
const input = document.getElementById(fieldId.replace("Error", ""));
if (input) input.classList.add("invalid");
};
const clearErrors = () => {
form.querySelectorAll(".error").forEach(el => {
document.querySelectorAll(".error").forEach(el => {
el.textContent = "";
el.classList.remove("active");
});
form.querySelectorAll(".invalid").forEach(el => el.classList.remove("invalid"));
document.querySelector(".checkbox-container")?.classList.remove("invalid");
};
const sha256 = async (message) => {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
document.querySelectorAll(".input").forEach(el => el.classList.remove("invalid"));
};
form.addEventListener("submit", async (e) => {
@ -33,59 +27,49 @@ document.addEventListener("DOMContentLoaded", () => {
const email = document.getElementById("email").value.trim();
const password = document.getElementById("password").value;
const passwordReply = document.getElementById("password_reply").value;
const terms = document.getElementById("terms").checked;
let hasError = false;
const promo_code = document.getElementById("promo_code") ? document.getElementById("promo_code").value.trim() : "";
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError("emailError", "Введите корректный email");
hasError = true;
return;
}
if (!password || password.length < 6 || !/(?=.*[A-Za-z])(?=.*\d)/.test(password)) {
setError("passwordError", "Минимум 6 символов, буквы + цифры");
hasError = true;
if (!password || password.length < 6) {
setError("passwordError", "Пароль минимум 6 символов");
return;
}
if (password !== passwordReply) {
setError("passwordReplyError", "Пароли не совпадают");
hasError = true;
return;
}
if (!terms) {
setError("termsError", "Принять условия обязательно");
document.querySelector(".checkbox-container").classList.add("invalid");
hasError = true;
}
if (hasError) return;
submitButton.disabled = true;
submitButton.textContent = "Создаём аккаунт...";
try {
const passwordHash = await sha256(password);
const response = await fetch("https://spectralvpn.ru:8500/registration", {
const response = await fetch("https://spectralvpn.ru:8000/user/signup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email,
password: passwordHash
password: password,
promo_code: promo_code
})
});
const result = await response.json();
if (response.ok) {
Cookies.set("user_email", email, { expires: 30, sameSite: "strict" });
Cookies.set("user_hash", passwordHash, { expires: 30, sameSite: "strict" });
// Сохраняем токен
localStorage.setItem("access_token", result.access_token);
window.location.href = "control-panel.html";
} else {
if (result.detail === "Email is busy.") {
setError("emailError", "Этот email уже зарегистрирован");
if (result.detail?.includes("Promo-code")) {
setError("promoError", "Неверный промокод");
} else if (result.detail?.includes("Email") || result.detail?.includes("busy")) {
setError("emailError", "Этот email уже используется");
} else {
setError("emailError", result.detail || "Ошибка сервера");
setError("emailError", result.detail || "Ошибка регистрации");
}
}
} catch (err) {

View file

@ -1,261 +1,294 @@
:root{
:root {
--bg: #000;
--card: #111;
--text: #e0e0e0;
--accent: cyan;
--accent: #00ffff;
--border: #333;
--danger: #ff4444;
}
*{margin: 0; padding: 0; box-sizing: border-box;}
body{background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh;}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
min-height: 100vh;
line-height: 1.5;
}
.container{max-width: 800px; margin: 0 auto; padding: 20px;}
.container { max-width: 820px; margin: 0 auto; padding: 20px; }
header{
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--border);
margin-bottom: 40px;
flex-wrap: wrap;
gap: 15px;
}
header h2 {font-size: 24pt;}
header h2 b {color: var(--accent);}
header h2 { font-size: 28px; }
header h2 b { color: var(--accent); }
.user-info{
.user-info {
display: flex;
align-items: center;
gap: 15px;
font-size: 14pt;
font-size: 15px;
}
#logoutBtn{
#logoutBtn {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
padding: 8px 16px;
padding: 8px 18px;
border-radius: 8px;
cursor: pointer;
transition: 0.3s;
}
#logoutBtn:hover{
#logoutBtn:hover {
background: var(--accent);
color: #000;
}
h1{
font-size: 28pt;
margin-bottom: 30px;
h1 {
font-size: 26px;
margin-bottom: 25px;
text-align: center;
}
.urls-list{
.urls-list {
display: grid;
gap: 15px;
gap: 16px;
margin-bottom: 30px;
}
.url-card{
.url-card {
background: var(--card);
border: 1px solid #333;
border-radius: 12px;
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
transition: 0.3s;
transition: all 0.3s ease;
}
.url-card:hover{
.url-card:hover {
border-color: var(--accent);
box-shadow: 0 0 15px rgba(0, 255, 255, 0.2);
box-shadow: 0 0 20px rgba(0, 255, 255, 0.15);
}
.url-name{
.url-info {
flex: 1;
}
.url-name {
font-weight: 600;
color: var(--accent);
font-size: 17px;
}
.url-actions{
.traffic {
font-size: 13.5px;
color: #888;
margin-top: 4px;
}
.url-actions {
display: flex;
gap: 10px;
}
.btn-copy, .btn-delete{
padding: 8px 16px;
.btn {
padding: 9px 18px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: 0.3s;
border: none;
}
.btn-copy{
.btn-copy {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
}
.btn-copy:hover{
.btn-copy:hover {
background: var(--accent);
color: #000;
}
.btn-delete{
background: #330000;
.btn-delete {
background: #2a0a0a;
border: 1px solid #800;
color: #f88;
color: #ff8888;
}
.btn-delete:hover{
.btn-delete:hover {
background: #800;
color: white;
}
.add-btn{
.add-btn {
width: 100%;
padding: 16px;
padding: 18px;
background: transparent;
border: 2px dashed var(--accent);
color: var(--accent);
font-size: 16pt;
font-size: 17px;
font-weight: 600;
border-radius: 12px;
cursor: pointer;
transition:hover{
background: rgba(0, 255, 255, 0.1);
}
transition: 0.3s;
}
.modal{
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
justify-content: center;
align-items: center;
z-index: 1000;
.add-btn:hover {
background: rgba(0, 255, 255, 0.08);
}
.modal.active{display: flex;}
.modal-content{
background: #111;
padding: 30px;
border-radius: 16px;
width: 90%;
max-width: 400px;
border: 1px solid var(--accent);
.danger-zone {
margin-top: 60px;
text-align: center;
}
.modal-content input{
width: 100%;
padding: 14px;
margin: 10px 0;
background: #222;
border: 1px solid #444;
.delete-account-btn {
background: #3a0a0a;
color: var(--danger);
border: 1px solid #a00;
padding: 12px 28px;
border-radius: 10px;
cursor: pointer;
font-size: 15px;
}
.delete-account-btn:hover {
background: #600;
color: white;
}
.modal-content .error{
color: #ff6b6b;
margin: 10px 0;
min-height: 20px;
/* Модальное окно */
.modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
justify-content: center;
align-items: center;
z-index: 2000;
}
.buttons{
.modal.active { display: flex; }
.modal-content {
background: #111;
padding: 32px;
border-radius: 16px;
width: 90%;
max-width: 420px;
border: 1px solid var(--accent);
}
.modal-content h2 { margin-bottom: 20px; text-align: center; }
.modal-content input {
width: 100%;
padding: 14px;
margin-bottom: 15px;
background: #222;
border: 1px solid #555;
border-radius: 10px;
color: white;
font-size: 16px;
}
.error {
color: #ff6b6b;
min-height: 22px;
font-size: 14px;
text-align: center;
}
.buttons {
display: flex;
gap: 10px;
gap: 12px;
margin-top: 20px;
}
.buttons button{
.buttons button {
flex: 1;
padding: 12px;
padding: 14px;
border-radius: 10px;
cursor: pointer;
}
#loginSubmit{
background: var(--accent);
color: black;
border: none;
}
#closeModal{
background: transparent;
border: 1px solid #666;
color: #aaa;
}
.how-to-connect{
margin-bottom: 60px;
}
.platform-tabs{
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.tab-btn{
background: #1a1a1a;
border: 1px solid #444;
color: #aaa;
padding: 12px 24px;
border-radius: 50px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.22s ease;
}
.tab-btn:hover{
border-color: #666;
color: #ddd;
background: #222;
}
.tab-btn.active{
background: var(--accent);
color: #000;
border-color: var(--accent);
box-shadow: 0 0 20px rgba(0, 255, 255, 0.25);
font-weight: 600;
}
.instructions{
#addSubmit {
background: var(--accent);
color: #000;
border: none;
}
#addCancel {
background: transparent;
border: 1px solid #666;
color: #ccc;
}
/* Табы и инструкции (оставил твои стили, только немного подчистил) */
.platform-tabs {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.tab-btn {
background: #1a1a1a;
border: 1px solid #444;
color: #aaa;
padding: 11px 22px;
border-radius: 50px;
cursor: pointer;
transition: all 0.25s;
}
.tab-btn.active,
.tab-btn:hover {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.instructions {
background: var(--card);
border: 1px solid #333;
border-radius: 16px;
padding: 28px 32px;
transition: all 0.3s ease;
padding: 26px 30px;
}
.instructions ol{
.instructions ol {
counter-reset: step;
list-style: none;
font-size: 15.5px;
line-height: 1.65;
}
.instructions li{
.instructions li {
position: relative;
padding-left: 38px;
margin-bottom: 18px;
padding-left: 42px;
margin-bottom: 16px;
}
.instructions li::before{
.instructions li::before {
content: counter(step);
counter-increment: step;
position: absolute;
left: 0;
top: 2px;
top: 0;
width: 28px;
height: 28px;
background: #222;
@ -265,37 +298,7 @@ h1{
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
font-weight: 700;
}
.instructions a{
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid rgba(0,255,255,0.3);
transition: all 0.2s;
}
.instructions a:hover{
border-bottom-color: var(--accent);
color: #00ffff;
}
.instructions kbd{
background: #1e1e1e;
border: 1px solid #444;
border-radius: 5px;
padding: 3px 7px;
font-family: monospace;
color: #aaa;
}
.instructions ul{
margin: 12px 0 12px 24px;
list-style: disc;
color: #bbb;
}
.hidden{
display: none !important;
}
.hidden { display: none !important; }

View file

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Личный кабинет — SpectralVPN</title>
<link rel="stylesheet" href="Styles/control-panel.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header>
<h2><b>Spectral</b>VPN</h2>
<div class="user-info" id="userInfo" style="display: none;">
<span id="userEmail"></span>
<button id="logoutBtn">Выйти</button>
</div>
</header>
<main id="mainContent" class="hidden">
<div class="how-to-connect">
<h1>Как подключиться</h1>
<div class="platform-tabs">
<button class="tab-btn active" data-platform="android">Android</button>
<button class="tab-btn" data-platform="windows">Windows</button>
<button class="tab-btn" data-platform="ios">iOS</button>
</div>
<div class="instructions" id="android-instructions">
<ol>
<li>Создайте конфигурацию ниже и скопируйте ссылку.</li>
<li>Установите <a href="https://github.com/2dust/v2rayNG/releases" target="_blank">v2rayNG</a>.</li>
<li>Нажмите + → Импорт из буфера обмена.</li>
<li>Включайте соединение большой кнопкой.</li>
</ol>
</div>
<div class="instructions hidden" id="windows-instructions">
<ol>
<li>Создайте конфигурацию и скопируйте ссылку.</li>
<li>Установите <a href="https://github.com/qr243vbi/nekobox/releases" target="_blank">NekoBox</a>.</li>
<li>Ctrl + V — конфиг добавится автоматически.</li>
<li>Включите Системный прокси и/или TUN.</li>
</ol>
</div>
<div class="instructions hidden" id="ios-instructions">
<ol>
<li>Создайте конфигурацию.</li>
<li>Установите <a href="https://apps.apple.com/ru/app/v2raytun/id6476628951" target="_blank">V2RayTun</a>.</li>
<li>+ → Импорт из буфера обмена.</li>
</ol>
</div>
</div>
<h1>Ваши конфигурации</h1>
<div class="urls-list" id="urlsList"></div>
<button class="add-btn" id="addConfigBtn">+ Добавить конфигурацию</button>
<div class="danger-zone">
<button id="deleteAccountBtn" class="delete-account-btn">Удалить аккаунт навсегда</button>
</div>
</main>
<div class="modal active" id="loginModal">
<div class="modal-content">
<h2>Вход в аккаунт</h2>
<input type="email" id="loginEmail" placeholder="Email" autocomplete="email">
<input type="password" id="loginPassword" placeholder="Пароль" autocomplete="current-password">
<div class="error" id="loginError"></div>
<div class="buttons">
<button id="loginSubmit">Войти</button>
</div>
<p style="text-align: center; margin-top: 20px; color: #888; font-size: 14px;">
Нет аккаунта?
<a href="register.html" style="color: #00ffff; text-decoration: none;">Зарегистрироваться</a>
</p>
</div>
</div>
<div class="modal" id="addModal">
<div class="modal-content">
<h2>Новая конфигурация</h2>
<input type="text" id="configName" placeholder="Название (Телефон, Ноутбук...)" maxlength="64">
<div class="error" id="addError"></div>
<div class="buttons">
<button id="addSubmit">Создать</button>
<button id="addCancel">Отмена</button>
</div>
</div>
</div>
</div>
<script src="JS/control-panel.js"></script>
</body>
</html>

View file

@ -21,6 +21,9 @@
<label for="password_reply">Повторите пароль</label>
<input type="password" id="password_reply" class="input" placeholder="Повторите пароль">
<div class="error" id="passwordReplyError"></div>
<label for="promo_code">Промокод</label>
<input type="text" id="promo_code" class="input" placeholder="Введите прокод">
<div class="error" id="promoError"></div>
<div class="checkbox-container">
<input type="checkbox" id="terms" class="checkbox" required>
<label for="terms">Я прочитал и согласен с <a href="offer.html" target="_blank">пользовательским соглашением</a> и <a href="privacy.html" target="_blank">политикой конфиденциальности</a>.</label>

View file

@ -1,88 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Личный кабинет — SpectralVPN</title>
<link rel="stylesheet" href="Styles/control-panel.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header>
<h2><b>Spectral</b>VPN</h2>
<div class="user-info" id="userInfo">
<span id="userEmail"></span>
<button id="logoutBtn">Выйти</button>
</div>
</header>
<main id="mainContent" class="hidden">
<div class="how-to-connect">
<h1>Как подключиться</h1>
<div class="platform-tabs">
<button class="tab-btn active" data-platform="android">Android</button>
<button class="tab-btn" data-platform="windows">ПК (Windows)</button>
<button class="tab-btn" data-platform="ios">iOS</button>
</div>
<div class="instructions" id="android-instructions">
<ol>
<li>Создайте и скопируйте свою конфигурацию в личном кабинете.</li>
<li>Скачайте и установите <a href="https://github.com/2dust/v2rayNG/releases/download/2.0.13/v2rayNG_2.0.13_universal.apk" target="_blank" rel="noopener">приложение v2rayNG</a>.</li>
<li>Откройте приложение.</li>
<li>Нажмите <b>+</b> в правом верхнем углу → <b>Импорт из буфера обмена</b>.</li>
<li><b>Готово!</b> Включайте/выключайте соединение большой кнопкой внизу справа.</li>
</ol>
</div>
<div class="instructions hidden" id="windows-instructions">
<ol>
<li>Создайте и скопируйте свою конфигурацию в личном кабинете.</li>
<li>Скачайте и установите <a href="https://github.com/qr243vbi/nekobox/releases/download/5.10.25/nekobox-5.10.25-windows64-installer.exe" target="_blank" rel="noopener">NekoBox for Windows</a>.</li>
<li>Откройте программу.</li>
<li>Нажмите <kbd>Ctrl</kbd> + <kbd>V</kbd> — конфигурация добавится автоматически.</li>
<li>Выделите добавленную конфигурацию и нажмите <kbd>Enter</kbd>.</li>
<li>Вверху включите:
<ul>
<li><b>Системный прокси</b> — для работы в браузерах</li>
<li><b>Режим TUN</b> — для всей системы</li>
</ul>
</li>
<li><b>Готово!</b> Вы подключены.</li>
</ol>
</div>
<div class="instructions hidden" id="ios-instructions">
<ol>
<li>Создайте и скопируйте свою конфигурацию в личном кабинете.</li>
<li>Скачайте приложение <a href="https://apps.apple.com/ru/app/v2raytun/id6476628951" target="_blank" rel="noopener">V2RayTun</a> из App Store.</li>
<li>Откройте приложение.</li>
<li>Нажмите <b>+</b> в правом верхнем углу → <b>Импорт из буфера обмена</b>.</li>
<li><b>Готово!</b> Включайте/выключайте соединение центральной кнопкой.</li>
</ol>
</div>
</div>
<h1>Ваши конфигурации</h1>
<div class="urls-list" id="urlsList"></div>
<button class="add-btn" id="addUrlBtn">+ Добавить конфиг</button>
</main>
<div class="modal" id="loginModal">
<div class="modal-content">
<h2>Вход в аккаунт</h2>
<input type="email" id="loginEmail" placeholder="Email" required>
<input type="password" id="loginPassword" placeholder="Пароль" required>
<div class="error" id="loginError"></div>
<div class="buttons">
<button id="loginSubmit">Войти</button>
<button id="closeModal">Отмена</button>
</div>
</div>
</div>
</div>
<script src="JS/control-panel.js"></script>
</body>
</html>

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# SpectralVPN
> Быстрый, лёгкий и современный VPN-сервер с удобным веб-интерфейсом
![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?logo=javascript&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![License](https://img.shields.io/github/license/lev4ikysss/SpectralVPN)
SpectralVPN — это открытый VPN-решение, состоящее из бэкенда (API) и современного фронтенда. Проект предназначен для быстрого развёртывания собственного VPN-сервера с удобным управлением через веб-панель.
## ✨ Возможности
- Современный REST API на Python - Красивый и responsive веб-интерфейс (Frontend) - Поддержка Docker и docker-compose (быстрое развёртывание) - Управление пользователями, конфигурациями и сессиями - Лёгкая расширяемость
## 🛠 Технологический стек
- \*\*Backend\*\*: Python + FastAPI (предположительно) - \*\*Frontend\*\*: HTML, CSS, JavaScript (возможно React/Vue или vanilla с современным дизайном) - \*\*Контейнеризация\*\*: Docker + docker-compose - \*\*База данных\*\*: (укажи, если используешь SQLite/PostgreSQL/Redis)

View file

@ -1,7 +0,0 @@
[api]
path = spectralvpn.db
host =
username =
password =
inbaund_id = 0
inbaund_url = spectralvpn.ru:442

View file

@ -1,30 +0,0 @@
server {
listen 8500 ssl;
listen [::]:8500 ssl;
server_name vpn.spectralvpn.ru;
ssl_certificate /etc/letsencrypt/live/vpn.spectralvpn.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vpn.spectralvpn.ru/privkey.pem;
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://spectralvpn.ru';
add_header 'Access-Control-Allow-Origin' 'https://vpn.spectralvpn.ru';
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type';
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
add_header Access-Control-Allow-Origin "https://spectralvpn.ru" always;
add_header Access-Control-Allow-Origin "https://vpn.spectralvpn.ru" always;
}
}

View file

@ -1,16 +0,0 @@
[Unit]
Description=API SpectralVPN
After=network.target
Wants=network.target
[Service]
User = root
WorkingDirectory=/etc/spectralvpn_api
Environment="PATH=/etc/spectralvpn_api/.venv/bin"
ExecStart=/etc/spectralvpn_api/.venv/bin/uvicorn main:app
Restart=always
RestartSec=3
KillMode=control-group
[Install]
WantedBy=multi-user.target

46
docker-compose.yml Normal file
View file

@ -0,0 +1,46 @@
services:
api:
build: ./API
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:
build: ./Frontend
container_name: spectralvpn-web
restart: unless-stopped
depends_on:
- api
ports:
- 80:80
- 443:443
db:
image: postgres:latest
container_name: spectralvpn-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD", "pg_isready", "-U", "${DB_USER}", "-d", "${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- db-data:/var/lib/postgresql
#TODO Удалить проброс портов
ports:
- 5432:5432
volumes:
db-data:

View file

@ -1,4 +0,0 @@
fastapi[all]
pandas
configparser
requests