Initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
*.db
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
+13
@@ -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"]
|
||||||
@@ -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 БД (создается при запуске)
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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: <ваш_токен_приложения></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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user