Initial commit
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user