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
+22
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
.pytest_cache/
*.db
data/
logs/
+13
View File
@@ -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"]
+78
View File
@@ -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 БД (создается при запуске)
```
+51
View File
@@ -0,0 +1,51 @@
#include <HTTPClient.h>
#include <WiFi.h>
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
}
+30
View File
@@ -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
}
+34
View File
@@ -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 = []
+20
View File
@@ -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()
+24
View File
@@ -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
+76
View File
@@ -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
+10
View File
@@ -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
+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)
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TNProxy - API Docs</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 900px; margin: 0 auto; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
h1 { color: #333; margin-bottom: 20px; }
h2 { color: #555; margin-top: 30px; margin-bottom: 15px; }
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 5px; overflow-x: auto; }
.method { display: inline-block; padding: 4px 8px; border-radius: 4px; font-weight: bold; font-size: 12px; }
.method.get { background: #61affe; color: white; }
.method.post { background: #49cc90; color: white; }
.method.delete { background: #f93e3e; color: white; }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/admin">Приложения</a>
<a href="/admin/api">API Docs</a>
</div>
<h1>API Документация</h1>
<div class="card">
<h2>Отправка сообщения</h2>
<p><span class="method post">POST</span> <code>/send</code></p>
<p><strong>Headers:</strong></p>
<ul>
<li><code>Content-Type: application/json</code></li>
<li><code>X-App-Token: &lt;ваш_токен_приложения&gt;</code></li>
</ul>
<p><strong>Body:</strong></p>
<pre>{"text": "Hello, World!"}</pre>
<h3>Пример PowerShell:</h3>
<pre>$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"}</pre>
<h3>Пример cURL:</h3>
<pre>curl -X POST http://localhost:8080/send \
-H "Content-Type: application/json" \
-H "X-App-Token: YOUR_APP_TOKEN" \
-d '{"text": "Hello!"}'</pre>
</div>
<div class="card">
<h2>Проверка здоровья</h2>
<p><span class="method get">GET</span> <code>/health</code></p>
<pre>{"status": "ok"}</pre>
</div>
</div>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TNProxy - Telegram Notification Proxy</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.btn { display: inline-block; padding: 10px 20px; background: #0088cc; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
.btn:hover { background: #006699; }
.btn-secondary { background: #666; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
table { width: 100%; background: white; border-collapse: collapse; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; font-weight: 600; }
.status { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
.status.active { background: #d4edda; color: #155724; }
.status.inactive { background: #f8d7da; color: #721c24; }
.token { font-family: monospace; background: #f8f9fa; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: 500; }
input[type="text"] { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; }
.modal.show { display: flex; }
.modal-content { background: white; padding: 20px; border-radius: 8px; width: 400px; }
.modal-actions { margin-top: 15px; text-align: right; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>TNProxy - Dashboard</h1>
<div class="nav">
<a href="/adtn">Приложения</a>
<a href="/adtn/logs">Логи</a>
<a href="/adtn/api">API Docs</a>
<a href="/adtn/password">Пароль</a>
<a href="/adtn/export">Экспорт</a>
<a href="#" id="importBtn">Импорт</a>
<a href="#" id="logout">Выход</a>
</div>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>Приложения</h2>
<button class="btn" onclick="showModal()">+ Добавить приложение</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Token</th>
<th>Chat ID</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for app in apps %}
<tr>
<td>{{ app.id }}</td>
<td>{{ app.name }}</td>
<td><span class="token">{{ app.token }}</span></td>
<td>{{ app.telegram_chat_id }}</td>
<td>
<span class="status {{ 'active' if app.is_active else 'inactive' }}">
{{ 'Активно' if app.is_active else 'Отключено' }}
</span>
</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="toggleApp({{ app.id }})">
{{ 'Отключить' if app.is_active else 'Включить' }}
</button>
<button class="btn btn-danger btn-sm" onclick="deleteApp({{ app.id }})">Удалить</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" style="text-align: center; color: #666;">Нет приложений</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal" id="addModal">
<div class="modal-content">
<h3>Добавить приложение</h3>
<form id="addForm">
<div class="form-group">
<label>Название</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Telegram Bot Token</label>
<input type="text" name="telegram_bot_token" required>
</div>
<div class="form-group">
<label>Telegram Chat ID</label>
<input type="text" name="telegram_chat_id" required>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="hideModal()">Отмена</button>
<button type="submit" class="btn">Создать</button>
</div>
</form>
</div>
</div>
<script>
function showModal() { document.getElementById('addModal').classList.add('show'); }
function hideModal() { document.getElementById('addModal').classList.remove('show'); }
document.getElementById('addForm').onsubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
const res = await fetch('/adtn/applications', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await res.json();
if (result.token) {
alert('Приложение создано!\n\nToken: ' + result.token + '\n\nСкопируйте токен - он больше не будет показан!');
hideModal();
location.reload();
}
};
async function deleteApp(id) {
if (!confirm('Удалить приложение?')) return;
await fetch('/adtn/applications/' + id, { method: 'DELETE' });
location.reload();
}
async function toggleApp(id) {
await fetch('/adtn/applications/' + id + '/toggle', { method: 'POST' });
location.reload();
}
document.getElementById('logout').onclick = async (e) => {
e.preventDefault();
await fetch('/logout', { method: 'POST' });
window.location.href = '/logtn';
};
document.getElementById('importBtn').onclick = async (e) => {
e.preventDefault();
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (ev) => {
const file = ev.target.files[0];
if (!file) return;
const text = await file.text();
const res = await fetch('/adtn/import', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: text
});
const result = await res.json();
if (result.success) {
alert('Настройки импортированы!');
location.reload();
} else {
alert('Ошибка: ' + result.error);
}
};
input.click();
};
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TNProxy - Настройки логов</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.btn { display: inline-block; padding: 10px 20px; background: #0088cc; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
.btn:hover { background: #006699; }
.btn-secondary { background: #666; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: 500; }
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
.help { font-size: 12px; color: #666; margin-top: 4px; }
.log-path { font-family: monospace; background: #f8f9fa; padding: 4px 8px; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="/adtn">Приложения</a>
<a href="/adtn/logs">Логи</a>
<a href="/adtn/api">API Docs</a>
</div>
<h1>Настройки логирования</h1>
<div class="card">
<h2>Log Rotate</h2>
<form id="logForm">
<div class="form-group">
<label>Уровень логирования</label>
<select name="log_level">
<option value="DEBUG" {% if settings.log_level == 'DEBUG' %}selected{% endif %}>DEBUG</option>
<option value="INFO" {% if settings.log_level == 'INFO' %}selected{% endif %}>INFO</option>
<option value="WARNING" {% if settings.log_level == 'WARNING' %}selected{% endif %}>WARNING</option>
<option value="ERROR" {% if settings.log_level == 'ERROR' %}selected{% endif %}>ERROR</option>
</select>
</div>
<div class="form-group">
<label>Максимальный размер файла (байт)</label>
<input type="number" name="max_bytes" value="{{ settings.max_bytes }}" min="1024">
<div class="help">Текущее значение: {{ settings.max_bytes }} байт ({{ (settings.max_bytes / 1024 / 1024)|round(2) }} MB)</div>
</div>
<div class="form-group">
<label>Количество backup файлов</label>
<input type="number" name="backup_count" value="{{ settings.backup_count }}" min="1" max="100">
<div class="help">Сколько архивных файлов хранить</div>
</div>
<button type="submit" class="btn">Сохранить</button>
</form>
</div>
<div class="card">
<h2>Файл логов</h2>
<p>Путь: <span class="log-path">/app/logs/tnproxy.log</span></p>
<p class="help">В Docker подключите volume <code>./logs:/app/logs</code> для доступа к файлам</p>
</div>
</div>
<script>
document.getElementById('logForm').onsubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = {
log_level: formData.get('log_level'),
max_bytes: parseInt(formData.get('max_bytes')),
backup_count: parseInt(formData.get('backup_count'))
};
const res = await fetch('/adtn/logs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (res.ok) {
alert('Настройки сохранены!');
} else {
alert('Ошибка сохранения');
}
};
</script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TNProxy - Вход</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.login-box { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
h1 { color: #333; margin-bottom: 20px; text-align: center; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: 500; }
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
input:focus { outline: none; border-color: #0088cc; }
.btn { width: 100%; padding: 12px; background: #0088cc; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
.btn:hover { background: #006699; }
.error { color: #dc3545; margin-bottom: 15px; text-align: center; }
</style>
</head>
<body>
<div class="login-box">
<h1>TNProxy - Вход</h1>
<div id="error" class="error" {% if not error %}style="display:none"{% endif %}>{{ error }}</div>
<form id="loginForm">
<div class="form-group">
<label>Логин</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">Войти</button>
</form>
</div>
<script>
document.getElementById('loginForm').onsubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = {
username: formData.get('username'),
password: formData.get('password')
};
const res = await fetch('/logtn', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
window.location.href = result.redirect;
} else {
document.getElementById('error').textContent = result.error;
document.getElementById('error').style.display = 'block';
}
};
</script>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TNProxy - Смена пароля</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 500px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.btn { display: inline-block; padding: 10px 20px; background: #0088cc; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
.btn:hover { background: #006699; }
.btn-secondary { background: #666; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: 500; }
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
.success { color: #28a745; margin-bottom: 15px; }
.error { color: #dc3545; margin-bottom: 15px; }
</style>
</head>
<body>
<div class="container">
<h1>TNProxy</h1>
<div class="nav">
<a href="/adtn">Приложения</a>
<a href="/adtn/logs">Логи</a>
<a href="/adtn/api">API Docs</a>
<a href="/adtn/password">Пароль</a>
<a href="#" id="logout">Выход</a>
</div>
<div class="card">
<h2>Смена пароля</h2>
<div id="message"></div>
<form id="passwordForm">
<div class="form-group">
<label>Текущий пароль</label>
<input type="password" name="old_password" required>
</div>
<div class="form-group">
<label>Новый пароль</label>
<input type="password" name="new_password" required minlength="6">
</div>
<div class="form-group">
<label>Подтверждение пароля</label>
<input type="password" name="confirm_password" required>
</div>
<button type="submit" class="btn">Изменить пароль</button>
</form>
</div>
</div>
<script>
document.getElementById('logout').onclick = async (e) => {
e.preventDefault();
await fetch('/logout', { method: 'POST' });
window.location.href = '/login';
};
document.getElementById('passwordForm').onsubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
if (formData.get('new_password') !== formData.get('confirm_password')) {
document.getElementById('message').innerHTML = '<div class="error">Пароли не совпадают</div>';
return;
}
const data = {
old_password: formData.get('old_password'),
new_password: formData.get('new_password')
};
const res = await fetch('/adtn/password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
document.getElementById('message').innerHTML = '<div class="success">Пароль изменен!</div>';
e.target.reset();
} else {
document.getElementById('message').innerHTML = '<div class="error">' + result.error + '</div>';
}
};
</script>
</body>
</html>