diff --git a/.drone.yml b/.drone.yml index 3ceb7da..7a4b8c2 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,18 +8,19 @@ trigger: - pull_request steps: - - name: python-lint + - name: backend-sanity image: python:3.11-slim commands: - cd backend - - pip install flake8 - - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + - pip install --no-cache-dir -r requirements.txt + - python -m py_compile main.py auth.py daemons.py oidc_config.py - - name: frontend-lint + - name: frontend-build-check image: node:20-alpine commands: - cd frontend - npm ci --silent + - npm run build - npm run lint || echo "ESLint warnings found" - name: frontend-security @@ -32,7 +33,7 @@ steps: --- kind: pipeline type: docker -name: build-backend +name: build-images trigger: branch: @@ -54,6 +55,7 @@ steps: repo: registry.nevetime.ru/mc-panel-backend tags: - latest + - "${DRONE_BUILD_NUMBER}" - "${DRONE_COMMIT_SHA:0:8}" - "${DRONE_BRANCH}" auto_tag: true @@ -63,33 +65,7 @@ steps: from_secret: docker_username password: from_secret: docker_password - build_args: - - BUILD_DATE=${DRONE_BUILD_CREATED} - - VCS_REF=${DRONE_COMMIT_SHA} - - VERSION=${DRONE_TAG:-${DRONE_BRANCH}} - when: - event: - - push - - tag ---- -kind: pipeline -type: docker -name: build-frontend - -trigger: - branch: - - main - - master - - develop - event: - - push - - tag - -depends_on: - - code-quality - -steps: - name: build-frontend-image image: plugins/docker settings: @@ -97,12 +73,12 @@ steps: repo: registry.nevetime.ru/mc-panel-frontend tags: - latest + - "${DRONE_BUILD_NUMBER}" - "${DRONE_COMMIT_SHA:0:8}" - "${DRONE_BRANCH}" auto_tag: true dockerfile: frontend/Dockerfile context: frontend - target: production username: from_secret: docker_username password: diff --git a/HOSTING_DEPLOY.md b/HOSTING_DEPLOY.md new file mode 100644 index 0000000..260196f --- /dev/null +++ b/HOSTING_DEPLOY.md @@ -0,0 +1,33 @@ +# Hosting Deployment + +## 1) Prerequisites +- Docker Engine + Docker Compose plugin +- Domain pointing to your host IP +- (Optional) HTTPS reverse proxy in front of port 80 + +## 2) Prepare environment +```bash +cp backend/.env.example backend/.env +cp deploy/.env.hosting.example deploy/.env +``` + +Edit `backend/.env`: +- `SECRET_KEY` +- `BASE_URL` and `FRONTEND_URL` (your real domain) +- `SSO_ENABLED=true` + `ZITADEL_*` only if you use SSO + +## 3) Pull and run published images +```bash +docker compose --env-file deploy/.env -f deploy/docker-compose.hosting.yml pull +docker compose --env-file deploy/.env -f deploy/docker-compose.hosting.yml up -d +``` + +## 4) Verify +- Frontend: `http:///` +- Backend health: `docker compose -f deploy/docker-compose.hosting.yml logs backend` +- Frontend health: `docker compose -f deploy/docker-compose.hosting.yml logs frontend` + +## Notes +- Frontend uses same-origin `/api` in production, so no hardcoded API host is required. +- Backend health endpoint is `/health`. +- If you need local frontend->local backend development, use `frontend/.env.local`. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..8cf2594 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# JWT +SECRET_KEY=change-me-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=43200 + +# OpenID Connect (SSO) +SSO_ENABLED=false +ZITADEL_ISSUER= +ZITADEL_CLIENT_ID= +ZITADEL_CLIENT_SECRET= + +# URLs +BASE_URL=https://panel.example.com +FRONTEND_URL=https://panel.example.com diff --git a/backend/main.py b/backend/main.py index 13243ed..de71eaa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -172,6 +172,11 @@ def check_server_access(user: dict, server_name: str): return False return server_name in user.get("servers", []) +# Healthcheck endpoint for Docker/hosting probes +@app.get("/health") +async def health(): + return {"status": "ok"} + # API для аутентификации # OpenID Connect endpoints @@ -1936,4 +1941,4 @@ app.include_router(daemons_router) if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + uvicorn.run(app, host="0.0.0.0", port=4546) diff --git a/backend/oidc_config.py b/backend/oidc_config.py index 7964814..140c1aa 100644 --- a/backend/oidc_config.py +++ b/backend/oidc_config.py @@ -4,28 +4,58 @@ import os from typing import Dict, Any -# Конфигурация провайдеров OpenID Connect -OIDC_PROVIDERS = { - "zitadel": { - "name": "ZITADEL", - "client_id": os.getenv("ZITADEL_CLIENT_ID", ""), - "client_secret": os.getenv("ZITADEL_CLIENT_SECRET", ""), - "server_metadata_url": os.getenv("ZITADEL_ISSUER", "") + "/.well-known/openid-configuration", - "issuer": os.getenv("ZITADEL_ISSUER", ""), - "scopes": ["openid", "email", "profile"], - "icon": "🔐", - "color": "bg-purple-600 hover:bg-purple-700" + +def _is_truthy(value: str) -> bool: + """Безопасный парсинг bool из переменных окружения.""" + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _is_config_value_set(value: str) -> bool: + """Проверка, что значение реально задано, а не заглушка.""" + normalized = value.strip().lower() + return normalized not in {"", "none", "null", "undefined"} + + +def is_sso_enabled() -> bool: + """Глобальный флаг включения SSO через env.""" + # По умолчанию SSO включён, чтобы не ломать существующее поведение. + raw = os.getenv("SSO_ENABLED", "true") + return _is_truthy(raw) + + +def get_oidc_providers() -> Dict[str, Dict[str, Any]]: + """Собрать конфигурацию OpenID Connect провайдеров из env.""" + issuer = os.getenv("ZITADEL_ISSUER", "") + return { + "zitadel": { + "name": "ZITADEL", + "client_id": os.getenv("ZITADEL_CLIENT_ID", ""), + "client_secret": os.getenv("ZITADEL_CLIENT_SECRET", ""), + "server_metadata_url": issuer.rstrip("/") + "/.well-known/openid-configuration" if issuer else "", + "issuer": issuer, + "scopes": ["openid", "email", "profile"], + "icon": "🔐", + "color": "bg-purple-600 hover:bg-purple-700" + } } -} + + +# Для обратной совместимости с импортами из других модулей +OIDC_PROVIDERS = get_oidc_providers() + def get_enabled_providers() -> Dict[str, Dict[str, Any]]: - """Получить список включённых провайдеров (с настроенными client_id)""" - enabled = {} - for provider_id, config in OIDC_PROVIDERS.items(): - if config.get("client_id") and config.get("issuer"): + """Получить список включённых провайдеров (с настроенным client_id).""" + if not is_sso_enabled(): + return {} + + enabled: Dict[str, Dict[str, Any]] = {} + for provider_id, config in get_oidc_providers().items(): + if _is_config_value_set(config.get("client_id", "")) and _is_config_value_set(config.get("issuer", "")): enabled[provider_id] = config return enabled + def get_redirect_uri(provider_id: str, base_url: str = "http://localhost:8000") -> str: - """Получить redirect URI для провайдера""" - return f"{base_url}/api/auth/oidc/{provider_id}/callback" \ No newline at end of file + """Получить redirect URI для провайдера.""" + return f"{base_url}/api/auth/oidc/{provider_id}/callback" diff --git a/deploy/.env.hosting.example b/deploy/.env.hosting.example new file mode 100644 index 0000000..0a1eeb9 --- /dev/null +++ b/deploy/.env.hosting.example @@ -0,0 +1,9 @@ +# Image tag produced by Drone (latest or build number) +IMAGE_TAG=latest + +# Optional explicit image names +# BACKEND_IMAGE=registry.nevetime.ru/mc-panel-backend:latest +# FRONTEND_IMAGE=registry.nevetime.ru/mc-panel-frontend:latest + +# External port for the frontend container +FRONTEND_PORT=80 diff --git a/deploy/docker-compose.hosting.yml b/deploy/docker-compose.hosting.yml new file mode 100644 index 0000000..97116ac --- /dev/null +++ b/deploy/docker-compose.hosting.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + backend: + image: ${BACKEND_IMAGE:-registry.nevetime.ru/mc-panel-backend:${IMAGE_TAG:-latest}} + container_name: mc-panel-backend + restart: unless-stopped + env_file: + - ../backend/.env + environment: + PORT: 8000 + WORKERS: 2 + PYTHONPATH: /app + DEBUG: 'false' + volumes: + - mc_servers:/app/servers + - mc_data:/app/data + - mc_logs:/app/logs + networks: + - mc-panel-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + frontend: + image: ${FRONTEND_IMAGE:-registry.nevetime.ru/mc-panel-frontend:${IMAGE_TAG:-latest}} + container_name: mc-panel-frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-80}:80" + depends_on: + backend: + condition: service_healthy + networks: + - mc-panel-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + mc_servers: + driver: local + mc_data: + driver: local + mc_logs: + driver: local + +networks: + mc-panel-network: + driver: bridge diff --git a/frontend/.env b/frontend/.env index 097dbc2..292a14c 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -VITE_API_URL=http://26.62.117.104:8000 +VITE_API_URL= diff --git a/frontend/.env.example b/frontend/.env.example index 606a002..a4e0e91 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,4 @@ -# API URL (необязательно, по умолчанию определяется автоматически) -# Раскомментируйте и укажите ваш IP для удаленного доступа -# VITE_API_URL=http://26.123.45.67:8000 +# API URL: +# - пусто: same-origin (/api), рекомендуется для production с nginx proxy +# - http://localhost:4546: локальный backend +VITE_API_URL= diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 60703f0..7aa8f91 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -1,10 +1,10 @@ # Создайте файл .env.local и раскомментируйте нужную строку # Для локального использования (по умолчанию) -# VITE_API_URL=http://localhost:8000 +# VITE_API_URL=http://localhost:4546 # Для Radmin VPN (замените на ваш IP) -# VITE_API_URL=http://26.62.117.104:8000 +# VITE_API_URL=http://26.62.117.104:4546 # Для Hamachi (замените на ваш IP) -# VITE_API_URL=http://25.123.45.67:8000 +# VITE_API_URL=http://25.123.45.67:4546 diff --git a/frontend/src/components/Auth.jsx b/frontend/src/components/Auth.jsx index 11a283b..73cd83e 100644 --- a/frontend/src/components/Auth.jsx +++ b/frontend/src/components/Auth.jsx @@ -41,7 +41,7 @@ export default function Auth({ onLogin }) { try { await onLogin(username, password, isLogin); } catch (err) { - setError(err.message || 'Ошибка авторизации'); + setError(err?.response?.data?.detail || err.message || 'Ошибка авторизации'); } finally { setLoading(false); } @@ -182,7 +182,7 @@ export default function Auth({ onLogin }) { {isLogin && (

Учётные данные по умолчанию:

-

none / none

+

admin / Admin

)} diff --git a/frontend/src/components/UserManagement.jsx b/frontend/src/components/UserManagement.jsx index 4ba79ad..b633b19 100644 --- a/frontend/src/components/UserManagement.jsx +++ b/frontend/src/components/UserManagement.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Users, Shield, Ban, Trash2, UserCheck, Server } from 'lucide-react'; import axios from 'axios'; import { notify } from './NotificationSystem'; +import { API_URL } from '../config'; const UserManagement = ({ token, currentUser }) => { const [users, setUsers] = useState([]); @@ -10,8 +11,6 @@ const UserManagement = ({ token, currentUser }) => { const [showRoleModal, setShowRoleModal] = useState(false); const [showAccessModal, setShowAccessModal] = useState(false); - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; - // Загрузка пользователей const loadUsers = async () => { try { diff --git a/frontend/src/config.js b/frontend/src/config.js index 3a0871a..ce91233 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -1,21 +1,23 @@ // Автоматически определяем API URL const getApiUrl = () => { - // Если задана переменная окружения, используем её - if (import.meta.env.VITE_API_URL) { - return import.meta.env.VITE_API_URL; + // Если переменная задана даже пустой строкой, используем её как явный override. + // Пустая строка = same-origin (/api через nginx proxy). + if (Object.prototype.hasOwnProperty.call(import.meta.env, 'VITE_API_URL')) { + const value = import.meta.env.VITE_API_URL || ''; + return value.replace(/\/$/, ''); } - // Иначе используем текущий хост с портом 8000 + // Иначе используем текущий хост с портом 4546 const protocol = window.location.protocol; const hostname = window.location.hostname; - // Если localhost, используем localhost:8000 + // Если localhost, используем localhost:4546 if (hostname === 'localhost' || hostname === '127.0.0.1') { - return `${protocol}//localhost:8000`; + return `${protocol}//localhost:4546`; } - // Для удаленного доступа используем IP:8000 - return `${protocol}//${hostname}:8000`; + // Для удаленного доступа используем IP:4546 + return `${protocol}//${hostname}:4546`; }; export const API_URL = getApiUrl();