Initial commit

This commit is contained in:
AVS
2026-06-26 16:42:07 +05:00
commit da6b107066
17 changed files with 1448 additions and 0 deletions
+578
View File
@@ -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)