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_PANEL_ENABLED, 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() if ADMIN_PANEL_ENABLED: register_admin_routes(app) logger.warning("Admin panel ENABLED - configure apps then disable!") else: logger.info("Admin panel DISABLED (ADMIN_PANEL_ENABLED=false)") 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() def register_admin_routes(app: FastAPI): @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_list = [a.to_dict() for a in db.query(Application).all()] log_settings = get_log_settings(db) finally: db.close() html = render_template( "index.html", {"request": request, "apps": apps_list, "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: new_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(new_app) db.commit() db.refresh(new_app) logger.info(f"Created application: {new_app.name}") return {"id": new_app.id, "token": new_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_list = db.query(Application).all() log_settings = get_log_settings(db) export_data = { "applications": [a.to_dict() for a in apps_list], "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: new_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(new_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() @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() if __name__ == "__main__": uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT)