From da6b1070660088911dd1aa9370c2396bf93d07fd Mon Sep 17 00:00:00 2001 From: AVS Date: Fri, 26 Jun 2026 16:42:07 +0500 Subject: [PATCH] Initial commit --- .env.example | 22 + .gitignore | 7 + Dockerfile | 13 + README.md | 78 ++++ clients/esp32/send_telegram.ino | 51 +++ clients/powershell/send-telegram.ps1 | 30 ++ config.py | 34 ++ database.py | 20 + docker-compose.yml | 24 ++ models.py | 76 ++++ requirements.txt | 10 + server.py | 578 +++++++++++++++++++++++++++ templates/api.html | 63 +++ templates/index.html | 188 +++++++++ templates/logs.html | 96 +++++ templates/logtn.html | 63 +++ templates/password.html | 95 +++++ 17 files changed, 1448 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 clients/esp32/send_telegram.ino create mode 100644 clients/powershell/send-telegram.ps1 create mode 100644 config.py create mode 100644 database.py create mode 100644 docker-compose.yml create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 templates/api.html create mode 100644 templates/index.html create mode 100644 templates/logs.html create mode 100644 templates/logtn.html create mode 100644 templates/password.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59eea8a --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Security +SECRET_KEY=change-me-in-production + +# Admin access control (comma-separated IPs, empty = allow all) +# ALLOWED_ADMIN_IPS=192.168.1.1,10.0.0.5 + +# Database (optional, defaults to sqlite) +# DATABASE_URL=sqlite:///data/tnproxy.db + +# Logging +# LOG_DIR=/app/logs +# LOG_LEVEL=INFO +# LOG_ROTATE_MAX_BYTES=10485760 +# LOG_ROTATE_BACKUP_COUNT=5 + +# Email notifications (SMTP) +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USER=your-email@gmail.com +# SMTP_PASSWORD=your-app-password +# SMTP_FROM=your-email@gmail.com +# SMTP_TO=notify@example.com \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6a3b01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +*.db +data/ +logs/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0e8a97 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY config.py server.py models.py database.py ./ +COPY templates/ ./templates/ + +EXPOSE 8080 + +CMD ["python", "server.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f466d6 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# TNProxy - Telegram Notification Proxy + +Прокси-сервер для отправки сообщений в Telegram через внешний сервер с авторизацией приложений. + +## Возможности + +- Множество приложений с индивидуальными токенами +- Каждое приложение привязывается к своему боту Telegram +- Веб-интерфейс для управления приложениями +- Docker для простого развертывания + +## Быстрый старт (Docker) + +```bash +cp .env.example .env +# Отредактируйте .env +docker-compose up -d +``` + +Откройте `http://localhost:8880/adtn` для управления приложениями. + +## Использование + +### 1. Создайте приложение в веб-интерфейсе + +Перейдите на `/adtn`, нажмите "Добавить приложение", введите: +- Название +- Telegram Bot Token (получить у @BotFather) +- Telegram Chat ID (ваш ID или ID канала) + +Получите токен приложения. + +### 2. Отправка сообщения + +```http +POST /send +X-App-Token: <токен_приложения> +Content-Type: application/json + +{"text": "Hello, World!"} +``` + +### Пример PowerShell: + +```powershell +$body = @{text = "Alert!"} | ConvertTo-Json +Invoke-RestMethod -Uri "http://localhost:8080/send" -Method Post -Body $body -Headers @{ + "Content-Type" = "application/json" + "X-App-Token" = "YOUR_APP_TOKEN" +} +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | /send | Отправить сообщение | +| GET | /health | Проверка здоровья | +| GET | /adtn | Веб-интерфейс | +| POST | /adtn/applications | Создать приложение | +| DELETE | /adtn/applications/{id} | Удалить приложение | +| POST | /adtn/applications/{id}/toggle | Включить/отключить | + +## Структура проекта + +``` +. +├── config.py # Конфигурация +├── server.py # FastAPI сервер +├── models.py # Модели SQLAlchemy +├── database.py # База данных +├── requirements.txt # Python зависимости +├── Dockerfile # Docker образ +├── docker-compose.yml # Docker Compose +├── templates/ # HTML шаблоны +├── clients/ # Примеры клиентов +└── data/ # SQLite БД (создается при запуске) +``` \ No newline at end of file diff --git a/clients/esp32/send_telegram.ino b/clients/esp32/send_telegram.ino new file mode 100644 index 0000000..da29393 --- /dev/null +++ b/clients/esp32/send_telegram.ino @@ -0,0 +1,51 @@ +#include +#include + +const char* SERVER_URL = "http://192.168.1.100:8880/send"; +const char* APP_TOKEN = ""; // Set your application token here + +const char* WIFI_SSID = "YourWiFiSSID"; +const char* WIFI_PASSWORD = "YourWiFiPassword"; + +void setup() { + Serial.begin(115200); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println("\nWiFi connected"); +} + +void sendTelegramMessage(const char* message) { + if (WiFi.status() == WL_CONNECTED) { + HTTPClient http; + http.begin(SERVER_URL); + http.addHeader("Content-Type", "application/json"); + + if (strlen(APP_TOKEN) > 0) { + http.addHeader("X-App-Token", APP_TOKEN); + } + + String payload = "{\"text\":\"" + String(message) + "\"}"; + int httpResponseCode = http.POST(payload); + + if (httpResponseCode > 0) { + String response = http.getString(); + Serial.println("HTTP Response: " + String(httpResponseCode)); + Serial.println(response); + } else { + Serial.println("Error: " + String(httpResponseCode)); + } + + http.end(); + } else { + Serial.println("WiFi not connected"); + } +} + +void loop() { + sendTelegramMessage("Hello from ESP32!"); + delay(60000); // Send every minute +} \ No newline at end of file diff --git a/clients/powershell/send-telegram.ps1 b/clients/powershell/send-telegram.ps1 new file mode 100644 index 0000000..b17efca --- /dev/null +++ b/clients/powershell/send-telegram.ps1 @@ -0,0 +1,30 @@ +param( + [Parameter(Mandatory=$true)] + [string]$Message, + + [Parameter(Mandatory=$false)] + [string]$ServerUrl = "http://localhost:8880", + + [Parameter(Mandatory=$true)] + [string]$AppToken +) + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$body = @{ + text = $Message +} | ConvertTo-Json -Depth 3 + +$headers = @{ + "Content-Type" = "application/json; charset=utf-8" + "X-App-Token" = $AppToken +} + +try { + $response = Invoke-RestMethod -Uri "$ServerUrl/send" -Method Post -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -Headers $headers + Write-Host "Message sent successfully. Message ID: $($response.message_id)" + exit 0 +} catch { + Write-Error "Failed to send message: $_" + exit 1 +} \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..a44a170 --- /dev/null +++ b/config.py @@ -0,0 +1,34 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).parent + +DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/tnproxy.db") + +SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0") +SERVER_PORT = int(os.getenv("SERVER_PORT", "8080")) + +SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production") + +LOG_DIR = os.getenv("LOG_DIR", f"{BASE_DIR}/logs") +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + +LOG_ROTATE_MAX_BYTES = int(os.getenv("LOG_ROTATE_MAX_BYTES", 10485760)) +LOG_ROTATE_BACKUP_COUNT = int(os.getenv("LOG_ROTATE_BACKUP_COUNT", 5)) + +SMTP_HOST = os.getenv("SMTP_HOST", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM = os.getenv("SMTP_FROM", "") +SMTP_TO = os.getenv("SMTP_TO", "") + +ADMIN_SESSION_SECRET = os.getenv("ADMIN_SESSION_SECRET", SECRET_KEY) + +ALLOWED_ADMIN_IPS = os.getenv("ALLOWED_ADMIN_IPS", "").strip() +if ALLOWED_ADMIN_IPS: + ALLOWED_ADMIN_IPS = [ + ip.strip() for ip in ALLOWED_ADMIN_IPS.split(",") if ip.strip() + ] +else: + ALLOWED_ADMIN_IPS = [] diff --git a/database.py b/database.py new file mode 100644 index 0000000..e4c0d4e --- /dev/null +++ b/database.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from config import DATABASE_URL +from models import Base + +engine = create_engine(DATABASE_URL, echo=False) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def init_db(): + Base.metadata.create_all(bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e4874c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + tnproxy: + build: . + ports: + - "8880:8080" + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + - DATABASE_URL=sqlite:///data/tnproxy.db + - SECRET_KEY=${SECRET_KEY:-change-me} + - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:-change-me} + - ALLOWED_ADMIN_IPS=${ALLOWED_ADMIN_IPS:-} + - LOG_DIR=/app/logs + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - LOG_ROTATE_MAX_BYTES=${LOG_ROTATE_MAX_BYTES:-10485760} + - LOG_ROTATE_BACKUP_COUNT=${LOG_ROTATE_BACKUP_COUNT:-5} + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM=${SMTP_FROM:-} + - SMTP_TO=${SMTP_TO:-} + restart: unless-stopped \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..6be65f1 --- /dev/null +++ b/models.py @@ -0,0 +1,76 @@ +import secrets +from datetime import datetime +from typing import Optional + +import bcrypt +from sqlalchemy import DateTime, Integer, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class Application(Base): + __tablename__ = "applications" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + token: Mapped[str] = mapped_column(String(64), unique=True, index=True) + telegram_bot_token: Mapped[str] = mapped_column(String(255)) + telegram_chat_id: Mapped[str] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + is_active: Mapped[bool] = mapped_column(default=True) + + @staticmethod + def generate_token() -> str: + return secrets.token_hex(32) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "token": self.token, + "telegram_bot_token": self.telegram_bot_token, + "telegram_chat_id": self.telegram_chat_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "is_active": self.is_active, + } + + +class LogSettings(Base): + __tablename__ = "log_settings" + + id: Mapped[int] = mapped_column(primary_key=True) + max_bytes: Mapped[int] = mapped_column(Integer, default=10485760) + backup_count: Mapped[int] = mapped_column(Integer, default=5) + log_level: Mapped[str] = mapped_column(String(20), default="INFO") + + +class AdminUser(Base): + __tablename__ = "admin_users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(100), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + @staticmethod + def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + def check_password(self, password: str) -> bool: + return bcrypt.checkpw(password.encode(), self.password_hash.encode()) + + @staticmethod + def create_default(db) -> "AdminUser": + default_password = "admin123" + user = AdminUser( + username="admin", + password_hash=AdminUser.hash_password(default_password), + ) + db.add(user) + db.commit() + return user diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5f599d6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +aiohttp>=3.9.0 +fastapi>=0.109.0 +uvicorn>=0.27.0 +pydantic>=2.5.0 +sqlalchemy>=2.0.0 +jinja2>=3.1.0 +httpx>=0.25.0 +bcrypt>=4.0.0 +aiosmtplib>=3.0.0 +itsdangerous>=2.0.0 \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..e1dbec8 --- /dev/null +++ b/server.py @@ -0,0 +1,578 @@ +import logging +import os +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path +from typing import Optional + +import aiohttp +import aiosmtplib +import uvicorn +from email.message import EmailMessage +from fastapi import FastAPI, HTTPException, Header, Request, Response, status +from fastapi.responses import HTMLResponse +from fastapi.security import HTTPBasicCredentials, HTTPBasic +from jinja2 import Environment, FileSystemLoader +from pydantic import BaseModel +from sqlalchemy.orm import Session +from starlette.middleware.sessions import SessionMiddleware + +from config import ( + ADMIN_SESSION_SECRET, + ALLOWED_ADMIN_IPS, + DATABASE_URL, + LOG_DIR, + LOG_LEVEL, + LOG_ROTATE_BACKUP_COUNT, + LOG_ROTATE_MAX_BYTES, + SECRET_KEY, + SERVER_HOST, + SERVER_PORT, + SMTP_FROM, + SMTP_HOST, + SMTP_PASSWORD, + SMTP_PORT, + SMTP_TO, + SMTP_USER, +) +from database import SessionLocal, init_db +from models import AdminUser, Application, LogSettings + +os.makedirs(LOG_DIR, exist_ok=True) + +logging.basicConfig( + level=getattr(logging, LOG_LEVEL), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.handlers.RotatingFileHandler( + f"{LOG_DIR}/tnproxy.log", + maxBytes=LOG_ROTATE_MAX_BYTES, + backupCount=LOG_ROTATE_BACKUP_COUNT, + ), + logging.StreamHandler(), + ], +) +logger = logging.getLogger(__name__) + + +class MessageRequest(BaseModel): + text: str + + +class ApplicationCreate(BaseModel): + name: str + telegram_bot_token: str + telegram_chat_id: str + + +class LogSettingsUpdate(BaseModel): + max_bytes: int + backup_count: int + log_level: str + + +class PasswordChange(BaseModel): + old_password: str + new_password: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TelegramResponse(BaseModel): + ok: bool + message_id: Optional[int] = None + error: Optional[str] = None + + +security = HTTPBasic() + + +async def send_email(subject: str, body: str): + if not SMTP_HOST or not SMTP_TO: + logger.warning("Email not configured, skipping notification") + return + + message = EmailMessage() + message["From"] = SMTP_FROM or SMTP_USER + message["To"] = SMTP_TO + message["Subject"] = subject + message.set_content(body) + + try: + await aiosmtplib.send( + message, + hostname=SMTP_HOST, + port=SMTP_PORT, + username=SMTP_USER, + password=SMTP_PASSWORD, + use_tls=True, + ) + logger.info(f"Email sent: {subject}") + except Exception as e: + logger.error(f"Failed to send email: {e}") + + +async def notify_login_success(username: str, ip: str): + await send_email( + "TNProxy - Successful Login", + f"Successful login to TNProxy admin panel\n\nUsername: {username}\nIP: {ip}\nTime: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + ) + + +async def notify_login_failure(username: str, ip: str): + await send_email( + "TNProxy - Failed Login Attempt", + f"Failed login attempt to TNProxy admin panel\n\nUsername: {username}\nIP: {ip}\nTime: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + ) + + +async def send_to_telegram(text: str, bot_token: str, chat_id: str) -> TelegramResponse: + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = {"chat_id": chat_id, "text": text} + + async with aiohttp.ClientSession() as session: + try: + async with session.post(url, json=payload) as response: + data = await response.json() + if response.status == 200 and data.get("ok"): + return TelegramResponse( + ok=True, message_id=data.get("result", {}).get("message_id") + ) + else: + return TelegramResponse( + ok=False, error=data.get("description", "Unknown error") + ) + except aiohttp.ClientError as e: + return TelegramResponse(ok=False, error=str(e)) + + +def get_log_settings(db: Session) -> LogSettings: + settings = db.query(LogSettings).first() + if not settings: + settings = LogSettings() + db.add(settings) + db.commit() + db.refresh(settings) + return settings + + +def update_logging_config(settings: LogSettings): + root_logger = logging.getLogger() + root_logger.setLevel(getattr(logging, settings.log_level)) + + for handler in root_logger.handlers[:]: + if isinstance(handler, logging.handlers.RotatingFileHandler): + handler.maxBytes = settings.max_bytes + handler.backupCount = settings.backup_count + + +def get_admin_user(db: Session) -> Optional[AdminUser]: + return db.query(AdminUser).first() + + +def check_ip_allowed(request: Request): + if not ALLOWED_ADMIN_IPS: + return True + client_ip = request.client.host if request.client else None + if client_ip in ALLOWED_ADMIN_IPS: + return True + logger.warning(f"Blocked admin access from IP: {client_ip}") + return False + + +def require_auth(request: Request): + if not check_ip_allowed(request): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + if not request.session.get("admin_user"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + db = SessionLocal() + try: + if not db.query(AdminUser).first(): + admin = AdminUser.create_default(db) + logger.info(f"Created default admin user: {admin.username}") + finally: + db.close() + logger.info("TNProxy started") + yield + + +app = FastAPI(title="TNProxy", lifespan=lifespan) +app.add_middleware(SessionMiddleware, secret_key=ADMIN_SESSION_SECRET) + +BASE_DIR = Path(__file__).parent +jinja_env = Environment(loader=FileSystemLoader(str(BASE_DIR / "templates"))) + + +def render_template(template_name: str, context: dict) -> str: + template = jinja_env.get_template(template_name) + return template.render(**context) + + +def get_application_by_token(token: str, db: Session) -> Optional[Application]: + return db.query(Application).filter(Application.token == token).first() + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.post("/send") +async def send_message( + request: MessageRequest, + x_app_token: Optional[str] = Header(None, alias="X-App-Token"), +): + if not x_app_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="X-App-Token header is required", + ) + + db = SessionLocal() + try: + app = get_application_by_token(x_app_token, db) + if not app: + logger.warning(f"Invalid token attempt: {x_app_token[:8]}...") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid application token", + ) + if not app.is_active: + logger.warning(f"Disabled app attempted to send: {app.name}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Application is disabled", + ) + + if not request.text: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Text is required", + ) + + logger.info(f"Message from app '{app.name}': {request.text[:50]!r}") + result = await send_to_telegram( + request.text, app.telegram_bot_token, app.telegram_chat_id + ) + + if result.ok: + logger.info(f"Message sent successfully to {app.name}") + return {"success": True, "message_id": result.message_id} + else: + logger.error(f"Telegram API error for {app.name}: {result.error}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Telegram API error: {result.error}", + ) + finally: + db.close() + + +@app.get("/logtn", response_class=HTMLResponse) +async def login_page(request: Request, error: str = None): + html = render_template("logtn.html", {"request": request, "error": error}) + return HTMLResponse(html) + + +@app.post("/logtn") +async def login(request: Request, login_data: LoginRequest): + db = SessionLocal() + try: + admin = ( + db.query(AdminUser) + .filter(AdminUser.username == login_data.username) + .first() + ) + + client_ip = request.client.host if request.client else "unknown" + + if admin and admin.check_password(login_data.password): + admin.last_login = datetime.utcnow() + db.commit() + request.session["admin_user"] = admin.username + await notify_login_success(login_data.username, client_ip) + logger.info(f"Admin login: {login_data.username} from {client_ip}") + return {"success": True, "redirect": "/adtn"} + else: + await notify_login_failure(login_data.username, client_ip) + logger.warning( + f"Failed login attempt: {login_data.username} from {client_ip}" + ) + return {"success": False, "error": "Invalid credentials"} + finally: + db.close() + + +@app.post("/logout") +async def logout(request: Request): + request.session.clear() + return {"success": True, "redirect": "/logtn"} + + +@app.get("/adtn", response_class=HTMLResponse) +async def admin_dashboard(request: Request): + try: + require_auth(request) + except HTTPException: + return Response(headers={"Location": "/logtn"}, status_code=302) + + db = SessionLocal() + try: + apps = [app.to_dict() for app in db.query(Application).all()] + log_settings = get_log_settings(db) + finally: + db.close() + html = render_template( + "index.html", + {"request": request, "apps": apps, "settings": log_settings}, + ) + return HTMLResponse(html) + + +@app.get("/adtn/password", response_class=HTMLResponse) +async def password_page(request: Request): + try: + require_auth(request) + except HTTPException: + return Response(headers={"Location": "/logtn"}, status_code=302) + + html = render_template("password.html", {"request": request}) + return HTMLResponse(html) + + +@app.post("/adtn/password") +async def change_password(request: Request, password_data: PasswordChange): + try: + require_auth(request) + except HTTPException: + return {"success": False, "error": "Not authenticated"} + + db = SessionLocal() + try: + admin = db.query(AdminUser).first() + if not admin.check_password(password_data.old_password): + return {"success": False, "error": "Current password is incorrect"} + + admin.password_hash = AdminUser.hash_password(password_data.new_password) + db.commit() + logger.info(f"Password changed for user: {admin.username}") + return {"success": True} + finally: + db.close() + + +@app.get("/adtn/api", response_class=HTMLResponse) +async def admin_api_docs(request: Request): + try: + require_auth(request) + except HTTPException: + return Response(headers={"Location": "/logtn"}, status_code=302) + + html = render_template("api.html", {"request": request}) + return HTMLResponse(html) + + +@app.post("/adtn/applications") +async def create_application(request: Request, app_data: ApplicationCreate): + try: + require_auth(request) + except HTTPException: + raise HTTPException(status_code=401, detail="Not authenticated") + + db = SessionLocal() + try: + app = Application( + name=app_data.name, + token=Application.generate_token(), + telegram_bot_token=app_data.telegram_bot_token, + telegram_chat_id=app_data.telegram_chat_id, + ) + db.add(app) + db.commit() + db.refresh(app) + logger.info(f"Created application: {app.name}") + return {"id": app.id, "token": app.token} + finally: + db.close() + + +@app.delete("/adtn/applications/{app_id}") +async def delete_application(request: Request, app_id: int): + try: + require_auth(request) + except HTTPException: + raise HTTPException(status_code=401, detail="Not authenticated") + + db = SessionLocal() + try: + app = db.query(Application).filter(Application.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="Application not found") + db.delete(app) + db.commit() + logger.info(f"Deleted application: {app.name}") + return {"success": True} + finally: + db.close() + + +@app.post("/adtn/applications/{app_id}/toggle") +async def toggle_application(request: Request, app_id: int): + try: + require_auth(request) + except HTTPException: + raise HTTPException(status_code=401, detail="Not authenticated") + + db = SessionLocal() + try: + app = db.query(Application).filter(Application.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="Application not found") + app.is_active = not app.is_active + db.commit() + logger.info(f"Toggled application {app.name}: is_active={app.is_active}") + return {"is_active": app.is_active} + finally: + db.close() + + +@app.get("/adtn/logs", response_class=HTMLResponse) +async def admin_logs(request: Request): + try: + require_auth(request) + except HTTPException: + return Response(headers={"Location": "/logtn"}, status_code=302) + + db = SessionLocal() + try: + log_settings = get_log_settings(db) + finally: + db.close() + html = render_template( + "logs.html", + {"request": request, "settings": log_settings}, + ) + return HTMLResponse(html) + + +@app.post("/adtn/logs") +async def update_log_settings(request: Request, settings: LogSettingsUpdate): + try: + require_auth(request) + except HTTPException: + raise HTTPException(status_code=401, detail="Not authenticated") + + db = SessionLocal() + try: + log_settings = get_log_settings(db) + log_settings.max_bytes = settings.max_bytes + log_settings.backup_count = settings.backup_count + log_settings.log_level = settings.log_level + db.commit() + + update_logging_config(log_settings) + logger.info( + f"Updated log settings: level={log_settings.log_level}, max_bytes={log_settings.max_bytes}, backup_count={log_settings.backup_count}" + ) + return {"success": True} + finally: + db.close() + + +@app.get("/adtn/export", response_class=HTMLResponse) +async def export_settings(request: Request): + try: + require_auth(request) + except HTTPException: + return Response(headers={"Location": "/logtn"}, status_code=302) + + db = SessionLocal() + try: + apps = db.query(Application).all() + log_settings = get_log_settings(db) + export_data = { + "applications": [app.to_dict() for app in apps], + "log_settings": { + "log_level": log_settings.log_level, + "max_bytes": log_settings.max_bytes, + "backup_count": log_settings.backup_count, + }, + } + finally: + db.close() + + import json + from fastapi.responses import Response + + return Response( + content=json.dumps(export_data, indent=2), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=tnproxy_backup.json"}, + ) + + +@app.post("/adtn/import") +async def import_settings(request: Request): + try: + require_auth(request) + except HTTPException: + raise HTTPException(status_code=401, detail="Not authenticated") + + import json + + try: + data = await request.json() + except Exception: + return {"success": False, "error": "Invalid JSON"} + + db = SessionLocal() + try: + if "applications" in data: + for app_data in data["applications"]: + existing = ( + db.query(Application) + .filter(Application.token == app_data.get("token")) + .first() + ) + if not existing: + app = Application( + name=app_data["name"], + token=app_data["token"], + telegram_bot_token=app_data["telegram_bot_token"], + telegram_chat_id=app_data["telegram_chat_id"], + is_active=app_data.get("is_active", True), + ) + db.add(app) + + if "log_settings" in data: + log_settings = get_log_settings(db) + log_settings.log_level = data["log_settings"].get("log_level", "INFO") + log_settings.max_bytes = data["log_settings"].get("max_bytes", 10485760) + log_settings.backup_count = data["log_settings"].get("backup_count", 5) + + db.commit() + logger.info("Settings imported successfully") + return {"success": True} + except Exception as e: + logger.error(f"Import error: {e}") + return {"success": False, "error": str(e)} + finally: + db.close() + + +if __name__ == "__main__": + uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT) diff --git a/templates/api.html b/templates/api.html new file mode 100644 index 0000000..9a8fa82 --- /dev/null +++ b/templates/api.html @@ -0,0 +1,63 @@ + + + + + + TNProxy - API Docs + + + +
+ + +

API Документация

+ +
+

Отправка сообщения

+

POST /send

+

Headers:

+
    +
  • Content-Type: application/json
  • +
  • X-App-Token: <ваш_токен_приложения>
  • +
+

Body:

+
{"text": "Hello, World!"}
+ +

Пример PowerShell:

+
$body = @{text = "Alert!"} | ConvertTo-Json
+Invoke-RestMethod -Uri "http://localhost:8080/send" -Method Post -Body $body -Headers @{"Content-Type"="application/json"; "X-App-Token"="YOUR_APP_TOKEN"}
+ +

Пример cURL:

+
curl -X POST http://localhost:8080/send \
+  -H "Content-Type: application/json" \
+  -H "X-App-Token: YOUR_APP_TOKEN" \
+  -d '{"text": "Hello!"}'
+
+ +
+

Проверка здоровья

+

GET /health

+
{"status": "ok"}
+
+
+ + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..628e434 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,188 @@ + + + + + + TNProxy - Telegram Notification Proxy + + + +
+

TNProxy - Dashboard

+ + + +
+
+

Приложения

+ +
+ + + + + + + + + + + + + + {% for app in apps %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDНазваниеTokenChat IDСтатусДействия
{{ app.id }}{{ app.name }}{{ app.token }}{{ app.telegram_chat_id }} + + {{ 'Активно' if app.is_active else 'Отключено' }} + + + + +
Нет приложений
+
+
+ + + + + + \ No newline at end of file diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..069888c --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,96 @@ + + + + + + TNProxy - Настройки логов + + + +
+ + +

Настройки логирования

+ +
+

Log Rotate

+
+
+ + +
+ +
+ + +
Текущее значение: {{ settings.max_bytes }} байт ({{ (settings.max_bytes / 1024 / 1024)|round(2) }} MB)
+
+ +
+ + +
Сколько архивных файлов хранить
+
+ + +
+
+ +
+

Файл логов

+

Путь: /app/logs/tnproxy.log

+

В Docker подключите volume ./logs:/app/logs для доступа к файлам

+
+
+ + + + \ No newline at end of file diff --git a/templates/logtn.html b/templates/logtn.html new file mode 100644 index 0000000..dbde715 --- /dev/null +++ b/templates/logtn.html @@ -0,0 +1,63 @@ + + + + + + TNProxy - Вход + + + + + + + + \ No newline at end of file diff --git a/templates/password.html b/templates/password.html new file mode 100644 index 0000000..be563d6 --- /dev/null +++ b/templates/password.html @@ -0,0 +1,95 @@ + + + + + + TNProxy - Смена пароля + + + +
+

TNProxy

+ + + +
+

Смена пароля

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + \ No newline at end of file