Update project configuration and templates

This commit is contained in:
AVS
2026-08-05 16:56:06 +05:00
parent da6b107066
commit 85fa94ae92
7 changed files with 327 additions and 302 deletions
+7 -1
View File
@@ -1,9 +1,15 @@
# Security # Security
SECRET_KEY=change-me-in-production SECRET_KEY=change-me-in-production
# Admin access control (comma-separated IPs, empty = allow all) # Admin session secret (separate from SECRET_KEY)
# ADMIN_SESSION_SECRET=
# Admin panel access control (comma-separated IPs, empty = allow all)
# ALLOWED_ADMIN_IPS=192.168.1.1,10.0.0.5 # ALLOWED_ADMIN_IPS=192.168.1.1,10.0.0.5
# Admin panel (enable only for configuration, then disable)
ADMIN_PANEL_ENABLED=false
# Database (optional, defaults to sqlite) # Database (optional, defaults to sqlite)
# DATABASE_URL=sqlite:///data/tnproxy.db # DATABASE_URL=sqlite:///data/tnproxy.db
+19 -1
View File
@@ -17,7 +17,25 @@ cp .env.example .env
docker-compose up -d docker-compose up -d
``` ```
Откройте `http://localhost:8880/adtn` для управления приложениями. Откройте `http://localhost:8880/adtn` для управления приложениями (требуется `ADMIN_PANEL_ENABLED=true`).
## Управление админ-панелью
По умолчанию админ-панель **отключена** (`ADMIN_PANEL_ENABLED=false`).
**Включить для настройки:**
```bash
# В .env установить ADMIN_PANEL_ENABLED=true
docker-compose up -d
```
Откройте `http://localhost:8880/adtn` — настройте приложения.
**Выключить после настройки:**
```bash
# В .env вернуть ADMIN_PANEL_ENABLED=false
docker-compose restart tnproxy
```
Админ-панель вернёт 404. API `/send` и `/health` продолжают работать.
## Использование ## Использование
+2
View File
@@ -32,3 +32,5 @@ if ALLOWED_ADMIN_IPS:
] ]
else: else:
ALLOWED_ADMIN_IPS = [] ALLOWED_ADMIN_IPS = []
ADMIN_PANEL_ENABLED = os.getenv("ADMIN_PANEL_ENABLED", "false").lower() == "true"
+1
View File
@@ -21,4 +21,5 @@ services:
- SMTP_PASSWORD=${SMTP_PASSWORD:-} - SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM=${SMTP_FROM:-}
- SMTP_TO=${SMTP_TO:-} - SMTP_TO=${SMTP_TO:-}
- ADMIN_PANEL_ENABLED=${ADMIN_PANEL_ENABLED:-false}
restart: unless-stopped restart: unless-stopped
+288 -292
View File
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from config import ( from config import (
ADMIN_PANEL_ENABLED,
ADMIN_SESSION_SECRET, ADMIN_SESSION_SECRET,
ALLOWED_ADMIN_IPS, ALLOWED_ADMIN_IPS,
DATABASE_URL, DATABASE_URL,
@@ -206,6 +207,13 @@ async def lifespan(app: FastAPI):
logger.info(f"Created default admin user: {admin.username}") logger.info(f"Created default admin user: {admin.username}")
finally: finally:
db.close() db.close()
if ADMIN_PANEL_ENABLED:
register_admin_routes(app)
logger.warning("Admin panel ENABLED - configure apps then disable!")
else:
logger.info("Admin panel DISABLED (ADMIN_PANEL_ENABLED=false)")
logger.info("TNProxy started") logger.info("TNProxy started")
yield yield
@@ -226,6 +234,286 @@ def get_application_by_token(token: str, db: Session) -> Optional[Application]:
return db.query(Application).filter(Application.token == token).first() return db.query(Application).filter(Application.token == token).first()
def register_admin_routes(app: FastAPI):
@app.get("/logtn", response_class=HTMLResponse)
async def login_page(request: Request, error: str = None):
html = render_template("logtn.html", {"request": request, "error": error})
return HTMLResponse(html)
@app.post("/logtn")
async def login(request: Request, login_data: LoginRequest):
db = SessionLocal()
try:
admin = (
db.query(AdminUser)
.filter(AdminUser.username == login_data.username)
.first()
)
client_ip = request.client.host if request.client else "unknown"
if admin and admin.check_password(login_data.password):
admin.last_login = datetime.utcnow()
db.commit()
request.session["admin_user"] = admin.username
await notify_login_success(login_data.username, client_ip)
logger.info(f"Admin login: {login_data.username} from {client_ip}")
return {"success": True, "redirect": "/adtn"}
else:
await notify_login_failure(login_data.username, client_ip)
logger.warning(
f"Failed login attempt: {login_data.username} from {client_ip}"
)
return {"success": False, "error": "Invalid credentials"}
finally:
db.close()
@app.post("/logout")
async def logout(request: Request):
request.session.clear()
return {"success": True, "redirect": "/logtn"}
@app.get("/adtn", response_class=HTMLResponse)
async def admin_dashboard(request: Request):
try:
require_auth(request)
except HTTPException:
return Response(headers={"Location": "/logtn"}, status_code=302)
db = SessionLocal()
try:
apps_list = [a.to_dict() for a in db.query(Application).all()]
log_settings = get_log_settings(db)
finally:
db.close()
html = render_template(
"index.html",
{"request": request, "apps": apps_list, "settings": log_settings},
)
return HTMLResponse(html)
@app.get("/adtn/password", response_class=HTMLResponse)
async def password_page(request: Request):
try:
require_auth(request)
except HTTPException:
return Response(headers={"Location": "/logtn"}, status_code=302)
html = render_template("password.html", {"request": request})
return HTMLResponse(html)
@app.post("/adtn/password")
async def change_password(request: Request, password_data: PasswordChange):
try:
require_auth(request)
except HTTPException:
return {"success": False, "error": "Not authenticated"}
db = SessionLocal()
try:
admin = db.query(AdminUser).first()
if not admin.check_password(password_data.old_password):
return {"success": False, "error": "Current password is incorrect"}
admin.password_hash = AdminUser.hash_password(password_data.new_password)
db.commit()
logger.info(f"Password changed for user: {admin.username}")
return {"success": True}
finally:
db.close()
@app.get("/adtn/api", response_class=HTMLResponse)
async def admin_api_docs(request: Request):
try:
require_auth(request)
except HTTPException:
return Response(headers={"Location": "/logtn"}, status_code=302)
html = render_template("api.html", {"request": request})
return HTMLResponse(html)
@app.post("/adtn/applications")
async def create_application(request: Request, app_data: ApplicationCreate):
try:
require_auth(request)
except HTTPException:
raise HTTPException(status_code=401, detail="Not authenticated")
db = SessionLocal()
try:
new_app = Application(
name=app_data.name,
token=Application.generate_token(),
telegram_bot_token=app_data.telegram_bot_token,
telegram_chat_id=app_data.telegram_chat_id,
)
db.add(new_app)
db.commit()
db.refresh(new_app)
logger.info(f"Created application: {new_app.name}")
return {"id": new_app.id, "token": new_app.token}
finally:
db.close()
@app.delete("/adtn/applications/{app_id}")
async def delete_application(request: Request, app_id: int):
try:
require_auth(request)
except HTTPException:
raise HTTPException(status_code=401, detail="Not authenticated")
db = SessionLocal()
try:
app = db.query(Application).filter(Application.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
db.delete(app)
db.commit()
logger.info(f"Deleted application: {app.name}")
return {"success": True}
finally:
db.close()
@app.post("/adtn/applications/{app_id}/toggle")
async def toggle_application(request: Request, app_id: int):
try:
require_auth(request)
except HTTPException:
raise HTTPException(status_code=401, detail="Not authenticated")
db = SessionLocal()
try:
app = db.query(Application).filter(Application.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
app.is_active = not app.is_active
db.commit()
logger.info(f"Toggled application {app.name}: is_active={app.is_active}")
return {"is_active": app.is_active}
finally:
db.close()
@app.get("/adtn/logs", response_class=HTMLResponse)
async def admin_logs(request: Request):
try:
require_auth(request)
except HTTPException:
return Response(headers={"Location": "/logtn"}, status_code=302)
db = SessionLocal()
try:
log_settings = get_log_settings(db)
finally:
db.close()
html = render_template(
"logs.html",
{"request": request, "settings": log_settings},
)
return HTMLResponse(html)
@app.post("/adtn/logs")
async def update_log_settings(request: Request, settings: LogSettingsUpdate):
try:
require_auth(request)
except HTTPException:
raise HTTPException(status_code=401, detail="Not authenticated")
db = SessionLocal()
try:
log_settings = get_log_settings(db)
log_settings.max_bytes = settings.max_bytes
log_settings.backup_count = settings.backup_count
log_settings.log_level = settings.log_level
db.commit()
update_logging_config(log_settings)
logger.info(
f"Updated log settings: level={log_settings.log_level}, max_bytes={log_settings.max_bytes}, backup_count={log_settings.backup_count}"
)
return {"success": True}
finally:
db.close()
@app.get("/adtn/export", response_class=HTMLResponse)
async def export_settings(request: Request):
try:
require_auth(request)
except HTTPException:
return Response(headers={"Location": "/logtn"}, status_code=302)
db = SessionLocal()
try:
apps_list = db.query(Application).all()
log_settings = get_log_settings(db)
export_data = {
"applications": [a.to_dict() for a in apps_list],
"log_settings": {
"log_level": log_settings.log_level,
"max_bytes": log_settings.max_bytes,
"backup_count": log_settings.backup_count,
},
}
finally:
db.close()
import json
from fastapi.responses import Response
return Response(
content=json.dumps(export_data, indent=2),
media_type="application/json",
headers={"Content-Disposition": "attachment; filename=tnproxy_backup.json"},
)
@app.post("/adtn/import")
async def import_settings(request: Request):
try:
require_auth(request)
except HTTPException:
raise HTTPException(status_code=401, detail="Not authenticated")
import json
try:
data = await request.json()
except Exception:
return {"success": False, "error": "Invalid JSON"}
db = SessionLocal()
try:
if "applications" in data:
for app_data in data["applications"]:
existing = (
db.query(Application)
.filter(Application.token == app_data.get("token"))
.first()
)
if not existing:
new_app = Application(
name=app_data["name"],
token=app_data["token"],
telegram_bot_token=app_data["telegram_bot_token"],
telegram_chat_id=app_data["telegram_chat_id"],
is_active=app_data.get("is_active", True),
)
db.add(new_app)
if "log_settings" in data:
log_settings = get_log_settings(db)
log_settings.log_level = data["log_settings"].get("log_level", "INFO")
log_settings.max_bytes = data["log_settings"].get("max_bytes", 10485760)
log_settings.backup_count = data["log_settings"].get("backup_count", 5)
db.commit()
logger.info("Settings imported successfully")
return {"success": True}
except Exception as e:
logger.error(f"Import error: {e}")
return {"success": False, "error": str(e)}
finally:
db.close()
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}
@@ -282,297 +570,5 @@ async def send_message(
db.close() 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__": if __name__ == "__main__":
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT) uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT)
+7 -5
View File
@@ -25,8 +25,8 @@
<body> <body>
<div class="container"> <div class="container">
<div class="nav"> <div class="nav">
<a href="/admin">Приложения</a> <a href="/adtn">Приложения</a>
<a href="/admin/api">API Docs</a> <a href="/adtn/api">API Docs</a>
</div> </div>
<h1>API Документация</h1> <h1>API Документация</h1>
@@ -44,13 +44,15 @@
<h3>Пример PowerShell:</h3> <h3>Пример PowerShell:</h3>
<pre>$body = @{text = "Alert!"} | ConvertTo-Json <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> Invoke-RestMethod -Uri "http://localhost:8880/send" -Method Post -Body $body -Headers @{"Content-Type"="application/json"; "X-App-Token"="YOUR_APP_TOKEN"}
# В Docker контейнере порт 8080, снаружи — 8880 (см. docker-compose.yml)</pre>
<h3>Пример cURL:</h3> <h3>Пример cURL:</h3>
<pre>curl -X POST http://localhost:8080/send \ <pre>curl -X POST http://localhost:8880/send \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-App-Token: YOUR_APP_TOKEN" \ -H "X-App-Token: YOUR_APP_TOKEN" \
-d '{"text": "Hello!"}'</pre> -d '{"text": "Hello!"}'
# В Docker контейнере порт 8080, снаружи — 8880</pre>
</div> </div>
<div class="card"> <div class="card">
+3 -3
View File
@@ -44,11 +44,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Новый пароль</label> <label>Новый пароль</label>
<input type="password" name="new_password" required minlength="6"> <input type="password" name="new_password" required minlength="10">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Подтверждение пароля</label> <label>Подтверждение пароля</label>
<input type="password" name="confirm_password" required> <input type="password" name="confirm_password" required minlength="10">
</div> </div>
<button type="submit" class="btn">Изменить пароль</button> <button type="submit" class="btn">Изменить пароль</button>
</form> </form>
@@ -59,7 +59,7 @@
document.getElementById('logout').onclick = async (e) => { document.getElementById('logout').onclick = async (e) => {
e.preventDefault(); e.preventDefault();
await fetch('/logout', { method: 'POST' }); await fetch('/logout', { method: 'POST' });
window.location.href = '/login'; window.location.href = '/logtn';
}; };
document.getElementById('passwordForm').onsubmit = async (e) => { document.getElementById('passwordForm').onsubmit = async (e) => {