сборка для Ильи
This commit is contained in:
commit
59ce64be5c
18 files changed
+2400
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# MikroTik Credentials
|
||||
MT_SECURE=false
|
||||
MT_PORT=8728
|
||||
|
||||
# App Settings
|
||||
APP_SECRET_KEY=generate_a_random_string_here
|
||||
ADMIN_PASS=your_strong_admin_password_here
|
||||
your_secret_password
|
||||
|
||||
# Overrides (optional)
|
||||
# MT_HOST=10.0.0.1
|
||||
# MT_SECURE=true
|
||||
@@ -0,0 +1,187 @@
|
||||
# MikroTik Proxy API Dashboard
|
||||
|
||||
A modern, secure web interface and proxy API for managing MikroTik Firewall Address Lists. Designed for remote control with IP identification and administrative management.
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
- **JWT Authentication**: Secure administrative access with username/password hashing.
|
||||
- **Structured List Management**: Define named lists (e.g., `trusted`, `guests`) and assign IPs via a strict interface.
|
||||
- **Client Dashboard**: Pure IP-based identification for end-users to toggle their own access status.
|
||||
- **Glassmorphism UI**: Premium, responsive interface using modern CSS and the "Outfit" typography.
|
||||
- **SQLite Integration**: Local source of truth for synchronization consistency.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[End-User Browser] -->|IP Auth| API[Flask Proxy]
|
||||
Admin[Admin Browser] -->|JWT Auth| API
|
||||
API --> DB[(SQLite DB)]
|
||||
API --> MT[MikroTik RouterOS]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your details:
|
||||
|
||||
| Variable | Description |
|
||||
| :--- | :--- |
|
||||
| `MT_HOST` | MikroTik Router IP/Hostname |
|
||||
| `MT_USER` | MikroTik API User |
|
||||
| `MT_PASS` | MikroTik API Password |
|
||||
| `APP_SECRET_KEY` | Key for signing JWT tokens |
|
||||
| `ADMIN_PASS` | Initial password for the `admin` user |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation & Deployment
|
||||
|
||||
### 1. Requirements
|
||||
- Python 3.8+
|
||||
- `pip install -r requirements.txt`
|
||||
|
||||
### 2. Running as a Service
|
||||
|
||||
#### **Alpine Linux (OpenRC)**
|
||||
Create `/etc/init.d/mt-proxy`:
|
||||
```bash
|
||||
#!/sbin/openrc-run
|
||||
description="MikroTik Proxy API"
|
||||
command="/usr/bin/python3"
|
||||
command_args="/path/to/app/main.py"
|
||||
command_background="yes"
|
||||
pidfile="/run/mt-proxy.pid"
|
||||
directory="/path/to/app"
|
||||
environment="PYTHONPATH=/path/to/app"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
```
|
||||
`chmod +x /etc/init.d/mt-proxy && rc-update add mt-proxy default && rc-service mt-proxy start`
|
||||
|
||||
#### **Debian / Ubuntu (systemd)**
|
||||
Create `/etc/systemd/system/mt-proxy.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=MikroTik Proxy API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/mt-proxy
|
||||
Environment="PATH=/var/www/mt-proxy/venv/bin"
|
||||
ExecStart=/var/www/mt-proxy/venv/bin/python main.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
`systemctl enable mt-proxy && systemctl start mt-proxy`
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Nginx Reverse Proxy
|
||||
|
||||
To access the API on port 80/443:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name proxy.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 API Documentation
|
||||
|
||||
### 1. Authentication
|
||||
*Used to obtain a JWT token for administrative actions.*
|
||||
|
||||
**POST** `/api/v1/auth/login`
|
||||
- **Body**: `{"username": "admin", "password": "..."}`
|
||||
- **Success (200)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "token": "ey..." }
|
||||
}
|
||||
```
|
||||
- **Error (401)**: `{"success": false, "error": "Invalid credentials"}`
|
||||
|
||||
---
|
||||
|
||||
### 2. Administrative API (Protected)
|
||||
*Required Header: `Authorization: Bearer <token>`*
|
||||
|
||||
#### **Address Assignments**
|
||||
- **GET** `/api/v1/addresses`: List all IP assignments.
|
||||
- **Success**: `{"success": true, "data": [{"id": 1, "ip": "1.2.3.4", "list_name": "trusted", "enabled": true, ...}]}`
|
||||
- **POST** `/api/v1/addresses`: Assign IP to a list.
|
||||
- **Body**: `{"address": "1.2.3.4", "list": "trusted", "comment": "Work PC"}`
|
||||
- **Success (201)**: `{"success": true, "data": {"mikrotik_id": "*1A"}}`
|
||||
- **DELETE** `/api/v1/addresses/<id>`: Remove assignment.
|
||||
- **PUT** `/api/v1/addresses/<id>/enable`: Activate IP.
|
||||
- **PUT** `/api/v1/addresses/<id>/disable`: Deactivate IP.
|
||||
|
||||
#### **Named Lists Management**
|
||||
- **GET** `/api/v1/named-lists`: List all managed address lists.
|
||||
- **Success**: `{"success": true, "data": [{"id": 1, "name": "trusted"}, {"id": 2, "name": "guests"}]}`
|
||||
- **POST** `/api/v1/named-lists`: Create a new list name.
|
||||
- **Body**: `{"name": "office_vpn"}`
|
||||
- **DELETE** `/api/v1/named-lists/<id>`: Remove list definition.
|
||||
- **Error (400)**: `{"success": false, "error": "Cannot delete: 5 addresses are still in this list"}`
|
||||
|
||||
#### **Profile Management**
|
||||
- **POST** `/api/v1/admin/profile`: Update your credentials.
|
||||
- **Body**: `{"username": "new_admin", "password": "new_password"}` (password optional)
|
||||
|
||||
---
|
||||
|
||||
### 3. Client Dashboard API (No Auth)
|
||||
*Determines status based on the calling user's public IP.*
|
||||
|
||||
#### **Check Status**
|
||||
- **GET** `/api/v1/client/status`
|
||||
- **Success (200)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"ip": "82.202.10.5",
|
||||
"enabled": true,
|
||||
"list": "trusted",
|
||||
"comment": "Home Office"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Forbidden (403)**: `{"success": false, "error": "Access denied for IP 8.8.8.8"}` (IP not in any whitelist)
|
||||
|
||||
#### **Toggle Status**
|
||||
- **PUT** `/api/v1/client/toggle`
|
||||
- **Success**: `{"success": true, "data": {"new_state": false}}`
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Architecture & Sync Details
|
||||
|
||||
- **Master Database**: SQLite is the source of truth. Changes are committed to DB *before* calling the MikroTik API to ensure consistency.
|
||||
- **MikroTik Sync**: The proxy uses the native MikroTik API (`/ip/firewall/address-list/set`) using internal IDs for high performance.
|
||||
- **Security**: Admin passwords are salted and hashed using PBKDF2. JWT tokens expire after 24 hours.
|
||||
|
||||
## 📜 Maintenance
|
||||
|
||||
- **Primary Admin**: Created automatically on first run using values from `.env`.
|
||||
- **Logs**: Standard output. On Alpine/Debian, use `logread` or `journalctl -u mt-proxy`.
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mikrotik": {
|
||||
"host": "192.168.5.253",
|
||||
"username": "apiuser",
|
||||
"password": "apipass",
|
||||
"port": 8728,
|
||||
"port_ssl": 8729,
|
||||
"use_ssl": false
|
||||
},
|
||||
"api": {
|
||||
"port": 5001,
|
||||
"debug": true
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'app_data.db')
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS address_list_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT UNIQUE NOT NULL,
|
||||
list_name TEXT NOT NULL,
|
||||
comment TEXT,
|
||||
mikrotik_id TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
last_sync DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS named_lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
# Миграция: добавляем существующие уникальные имена списков в новую таблицу
|
||||
cursor.execute('SELECT DISTINCT list_name FROM address_list_items')
|
||||
existing_lists = cursor.fetchall()
|
||||
for row in existing_lists:
|
||||
cursor.execute('INSERT OR IGNORE INTO named_lists (name) VALUES (?)', (row[0],))
|
||||
|
||||
# Если список пуст, создаем дефолтный
|
||||
cursor.execute('SELECT COUNT(*) FROM named_lists')
|
||||
if cursor.fetchone()[0] == 0:
|
||||
cursor.execute('INSERT INTO named_lists (name) VALUES (?)', ('trusted',))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def get_item_by_ip(ip):
|
||||
conn = get_db_connection()
|
||||
item = conn.execute('SELECT * FROM address_list_items WHERE ip = ?', (ip,)).fetchone()
|
||||
conn.close()
|
||||
return item
|
||||
|
||||
def get_all_items(list_name=None):
|
||||
conn = get_db_connection()
|
||||
if list_name:
|
||||
items = conn.execute('SELECT * FROM address_list_items WHERE list_name = ?', (list_name,)).fetchall()
|
||||
else:
|
||||
items = conn.execute('SELECT * FROM address_list_items').fetchall()
|
||||
conn.close()
|
||||
return [dict(i) for i in items]
|
||||
|
||||
def add_item(ip, list_name, comment, mikrotik_id, enabled=True):
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'INSERT INTO address_list_items (ip, list_name, comment, mikrotik_id, enabled) VALUES (?, ?, ?, ?, ?)',
|
||||
(ip, list_name, comment, mikrotik_id, enabled)
|
||||
)
|
||||
conn.commit()
|
||||
return True, cursor.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
return False, "IP already exists"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_item_status(ip, enabled):
|
||||
conn = get_db_connection()
|
||||
conn.execute(
|
||||
'UPDATE address_list_items SET enabled = ?, last_sync = CURRENT_TIMESTAMP WHERE ip = ?',
|
||||
(enabled, ip)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def update_mikrotik_id(ip, mikrotik_id):
|
||||
conn = get_db_connection()
|
||||
conn.execute(
|
||||
'UPDATE address_list_items SET mikrotik_id = ? WHERE ip = ?',
|
||||
(mikrotik_id, ip)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def delete_item_by_ip(ip):
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM address_list_items WHERE ip = ?', (ip,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def delete_item_by_id(db_id):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
item = conn.execute('SELECT ip, mikrotik_id FROM address_list_items WHERE id = ?', (db_id,)).fetchone()
|
||||
if item:
|
||||
conn.execute('DELETE FROM address_list_items WHERE id = ?', (db_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return dict(item)
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
def get_item_by_id(db_id):
|
||||
conn = get_db_connection()
|
||||
item = conn.execute('SELECT * FROM address_list_items WHERE id = ?', (db_id,)).fetchone()
|
||||
conn.close()
|
||||
return item
|
||||
|
||||
def get_unique_lists():
|
||||
conn = get_db_connection()
|
||||
lists = conn.execute('SELECT name FROM named_lists ORDER BY name').fetchall()
|
||||
conn.close()
|
||||
return [row['name'] for row in lists]
|
||||
|
||||
def get_named_lists_detailed():
|
||||
conn = get_db_connection()
|
||||
lists = conn.execute('SELECT * FROM named_lists ORDER BY name').fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in lists]
|
||||
|
||||
def add_named_list(name):
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
conn.execute('INSERT INTO named_lists (name) VALUES (?)', (name,))
|
||||
conn.commit()
|
||||
return True, ""
|
||||
except sqlite3.IntegrityError:
|
||||
return False, "List already exists"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_named_list(list_id):
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
# Проверяем, используются ли записи из этого списка
|
||||
list_name_row = conn.execute('SELECT name FROM named_lists WHERE id = ?', (list_id,)).fetchone()
|
||||
if not list_name_row:
|
||||
return False, "Not found"
|
||||
|
||||
usage_count = conn.execute('SELECT COUNT(*) FROM address_list_items WHERE list_name = ?', (list_name_row['name'],)).fetchone()[0]
|
||||
if usage_count > 0:
|
||||
return False, f"Cannot delete: {usage_count} addresses are still in this list"
|
||||
|
||||
conn.execute('DELETE FROM named_lists WHERE id = ?', (list_id,))
|
||||
conn.commit()
|
||||
return True, ""
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# --- Admin Operations ---
|
||||
|
||||
def get_admin(username):
|
||||
conn = get_db_connection()
|
||||
admin = conn.execute('SELECT * FROM admins WHERE username = ?', (username,)).fetchone()
|
||||
conn.close()
|
||||
return admin
|
||||
|
||||
def add_admin(username, password_hash):
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
conn.execute('INSERT INTO admins (username, password_hash) VALUES (?, ?)', (username, password_hash))
|
||||
conn.commit()
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_admin(old_username, new_username, new_password_hash):
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
'UPDATE admins SET username = ?, password_hash = ? WHERE username = ?',
|
||||
(new_username, new_password_hash, old_username)
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
print("Database initialized.")
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import logging
|
||||
from flask import Flask, request, jsonify, g, render_template
|
||||
from contextlib import contextmanager
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.exceptions import HTTPException
|
||||
import database
|
||||
import jwt
|
||||
import datetime
|
||||
from functools import wraps
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
# Загрузка .env файла для безопасности
|
||||
load_dotenv()
|
||||
|
||||
# Инициализация БД при запуске
|
||||
database.init_db()
|
||||
|
||||
# --- Настройки безопасности ---
|
||||
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'super-secret-key-change-me')
|
||||
DEFAULT_ADMIN = os.environ.get('MT_USER', 'admin') # Используем MT_USER как дефолтный логин
|
||||
DEFAULT_PASS = os.environ.get('ADMIN_PASS', 'admin123')
|
||||
|
||||
def ensure_admin_exists():
|
||||
"""Создает дефолтного админа, если таблица пуста"""
|
||||
import sqlite3
|
||||
conn = database.get_db_connection()
|
||||
count = conn.execute('SELECT COUNT(*) FROM admins').fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
if count == 0:
|
||||
hash_pass = generate_password_hash(DEFAULT_PASS)
|
||||
database.add_admin(DEFAULT_ADMIN, hash_pass)
|
||||
logging.info(f"Default admin created: {DEFAULT_ADMIN}")
|
||||
|
||||
ensure_admin_exists()
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Загрузка конфигурации ---
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json')
|
||||
|
||||
def load_config():
|
||||
"""Загрузка настроек из config.json и переменных окружения"""
|
||||
config = {
|
||||
"mikrotik": {
|
||||
"host": "10.0.0.1",
|
||||
"username": "admin",
|
||||
"password": "password",
|
||||
"port": 8728,
|
||||
"port_ssl": 8729,
|
||||
"use_ssl": False
|
||||
},
|
||||
"api": {
|
||||
"port": 5000,
|
||||
"debug": True
|
||||
}
|
||||
}
|
||||
|
||||
# 1. Загрузка из config.json
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
user_config = json.load(f)
|
||||
if 'mikrotik' in user_config:
|
||||
config['mikrotik'].update(user_config['mikrotik'])
|
||||
if 'api' in user_config:
|
||||
config['api'].update(user_config['api'])
|
||||
logger.info(f"Configuration loaded from {CONFIG_PATH}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load {CONFIG_PATH}: {e}")
|
||||
|
||||
# 2. Приоритет переменных окружения
|
||||
config['mikrotik']['host'] = os.environ.get('MT_HOST', config['mikrotik']['host'])
|
||||
config['mikrotik']['username'] = os.environ.get('MT_USER', config['mikrotik']['username'])
|
||||
config['mikrotik']['password'] = os.environ.get('MT_PASS', config['mikrotik']['password'])
|
||||
config['mikrotik']['use_ssl'] = os.environ.get('MT_SECURE', str(config['mikrotik']['use_ssl'])).lower() == 'true'
|
||||
|
||||
if config['mikrotik']['use_ssl']:
|
||||
config['mikrotik']['port_active'] = int(os.environ.get('MT_PORT', config['mikrotik']['port_ssl']))
|
||||
else:
|
||||
config['mikrotik']['port_active'] = int(os.environ.get('MT_PORT', config['mikrotik']['port']))
|
||||
|
||||
return config
|
||||
|
||||
APP_CONFIG = load_config()
|
||||
|
||||
# --- Настройки безопасности ---
|
||||
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'super-secret-key-change-me')
|
||||
|
||||
# --- Вспомогательные функции ---
|
||||
def get_client_ip():
|
||||
"""Определение IP адреса клиента с учетом прокси"""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
|
||||
class MikroTikAPIError(Exception):
|
||||
"""Базовое исключение для MikroTik API"""
|
||||
pass
|
||||
|
||||
class MikroTikConnectionError(MikroTikAPIError):
|
||||
"""Ошибка при установке соединения"""
|
||||
pass
|
||||
|
||||
class MikroTikAPI:
|
||||
"""Класс для взаимодействия с MikroTik RouterOS API"""
|
||||
|
||||
def __init__(self, host, username, password, port=8728, secure=False, timeout=10):
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.port = port
|
||||
self.secure = secure
|
||||
self.timeout = timeout
|
||||
self.connection = None
|
||||
|
||||
def connect(self):
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
if self.secure:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
self.connection = context.wrap_socket(sock, server_hostname=self.host)
|
||||
else:
|
||||
self.connection = sock
|
||||
self.connection.connect((self.host, self.port))
|
||||
logger.info(f"Connected to {self.host}:{self.port}")
|
||||
result = self._talk(["/login", f"=name={self.username}", f"=password={self.password}"])
|
||||
for reply, attrs in result:
|
||||
if reply == '!trap':
|
||||
error_msg = attrs.get('=message', 'Unknown login error')
|
||||
raise MikroTikConnectionError(f"Login failed: {error_msg}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Connection error: {e}")
|
||||
raise MikroTikConnectionError(str(e))
|
||||
|
||||
def _write_len(self, length):
|
||||
if length < 0x80: self.connection.sendall(length.to_bytes(1, 'big'))
|
||||
elif length < 0x4000: self.connection.sendall((length | 0x8000).to_bytes(2, 'big'))
|
||||
elif length < 0x200000: self.connection.sendall((length | 0xC00000).to_bytes(3, 'big'))
|
||||
elif length < 0x10000000: self.connection.sendall((length | 0xE0000000).to_bytes(4, 'big'))
|
||||
else:
|
||||
self.connection.sendall((0xF0).to_bytes(1, 'big'))
|
||||
self.connection.sendall(length.to_bytes(4, 'big'))
|
||||
|
||||
def _read_len(self):
|
||||
byte = self.connection.recv(1)
|
||||
if not byte: return 0
|
||||
c = byte[0]
|
||||
if (c & 0x80) == 0x00: return c
|
||||
elif (c & 0xC0) == 0x80: return ((c & ~0xC0) << 8) + self.connection.recv(1)[0]
|
||||
elif (c & 0xE0) == 0xC0: return ((c & ~0xE0) << 16) + int.from_bytes(self.connection.recv(2), 'big')
|
||||
elif (c & 0xF0) == 0xE0: return ((c & ~0xF0) << 24) + int.from_bytes(self.connection.recv(3), 'big')
|
||||
elif (c & 0xF8) == 0xF0: return int.from_bytes(self.connection.recv(4), 'big')
|
||||
return 0
|
||||
|
||||
def _write_word(self, word):
|
||||
encoded = word.encode('utf-8')
|
||||
self._write_len(len(encoded))
|
||||
self.connection.sendall(encoded)
|
||||
|
||||
def _read_word(self):
|
||||
length = self._read_len()
|
||||
if length == 0: return ''
|
||||
data = b''
|
||||
while len(data) < length:
|
||||
chunk = self.connection.recv(length - len(data))
|
||||
if not chunk: break
|
||||
data += chunk
|
||||
return data.decode('utf-8', errors='replace')
|
||||
|
||||
def _talk(self, words):
|
||||
for word in words: self._write_word(word)
|
||||
self._write_word('')
|
||||
result = []
|
||||
while True:
|
||||
sentence = []
|
||||
while True:
|
||||
word = self._read_word()
|
||||
if word == '': break
|
||||
sentence.append(word)
|
||||
if not sentence: continue
|
||||
reply = sentence[0]
|
||||
attrs = {}
|
||||
for word in sentence[1:]:
|
||||
eq_pos = word.find('=', 1)
|
||||
if eq_pos == -1: attrs[word.lstrip('=.')] = ''
|
||||
else: attrs[word[1:eq_pos]] = word[eq_pos+1:]
|
||||
result.append((reply, attrs))
|
||||
if reply == '!done': break
|
||||
if reply == '!trap': break
|
||||
return result
|
||||
|
||||
def get_address_list(self, list_name=None):
|
||||
cmd = ["/ip/firewall/address-list/print"]
|
||||
if list_name: cmd.append(f"?list={list_name}")
|
||||
result = self._talk(cmd)
|
||||
return [{ 'id': a.get('.id'), 'address': a.get('address'), 'list': a.get('list'), 'disabled': a.get('disabled') == 'true' } for r, a in result if r == '!re']
|
||||
|
||||
def add_address(self, address, list_name, comment=""):
|
||||
cmd = ["/ip/firewall/address-list/add", f"=address={address}", f"=list={list_name}"]
|
||||
if comment: cmd.append(f"=comment={comment}")
|
||||
result = self._talk(cmd)
|
||||
for r, a in result:
|
||||
if r == '!trap': return False, a.get('message', 'Unknown error')
|
||||
if r == '!done': return True, a.get('ret')
|
||||
return False, "Failed"
|
||||
|
||||
def set_address_state(self, address_id, disabled=False):
|
||||
cmd = ["/ip/firewall/address-list/set", f"=.id={address_id}", f"=disabled={'yes' if disabled else 'no'}"]
|
||||
result = self._talk(cmd)
|
||||
for r, a in result:
|
||||
if r == '!trap': return False, a.get('message', 'Unknown error')
|
||||
return True, None
|
||||
|
||||
def remove_address(self, address_id):
|
||||
cmd = ["/ip/firewall/address-list/remove", f"=.id={address_id}"]
|
||||
result = self._talk(cmd)
|
||||
for r, a in result:
|
||||
if r == '!trap': return False, a.get('message', 'Unknown error')
|
||||
return True, None
|
||||
|
||||
def close(self):
|
||||
if self.connection:
|
||||
try: self.connection.close()
|
||||
except: pass
|
||||
self.connection = None
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@contextmanager
|
||||
def get_mt_api():
|
||||
mt = APP_CONFIG['mikrotik']
|
||||
api = MikroTikAPI(mt['host'], mt['username'], mt['password'], port=mt['port_active'], secure=mt['use_ssl'])
|
||||
try:
|
||||
api.connect()
|
||||
yield api
|
||||
finally:
|
||||
api.close()
|
||||
|
||||
def response_json(success, data=None, error=None, status=200):
|
||||
resp = {"success": success}
|
||||
if data is not None: resp["data"] = data
|
||||
if error is not None: resp["error"] = error
|
||||
return jsonify(resp), status
|
||||
|
||||
# --- Authentication Helpers ---
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = None
|
||||
if 'Authorization' in request.headers:
|
||||
auth_header = request.headers['Authorization']
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
if not token:
|
||||
return response_json(False, error="Token is missing", status=401)
|
||||
|
||||
try:
|
||||
data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
g.admin_user = data['user']
|
||||
except Exception:
|
||||
return response_json(False, error="Token is invalid or expired", status=401)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@app.route('/api/v1/auth/login', methods=['POST'])
|
||||
def login():
|
||||
auth = request.json
|
||||
username = auth.get('username')
|
||||
password = auth.get('password')
|
||||
|
||||
if not username or not password:
|
||||
return response_json(False, error="Username and Password required", status=400)
|
||||
|
||||
admin = database.get_admin(username)
|
||||
if admin and check_password_hash(admin['password_hash'], password):
|
||||
token = jwt.encode({
|
||||
'user': username,
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)
|
||||
}, SECRET_KEY)
|
||||
return response_json(True, data={'token': token})
|
||||
|
||||
return response_json(False, error="Invalid credentials", status=401)
|
||||
|
||||
@app.route('/api/v1/admin/profile', methods=['POST'])
|
||||
@token_required
|
||||
def update_profile():
|
||||
data = request.json
|
||||
new_username = data.get('username')
|
||||
new_password = data.get('password')
|
||||
|
||||
if not new_username:
|
||||
return response_json(False, error="Username is required", status=400)
|
||||
|
||||
# Хешируем новый пароль, если он предоставлен
|
||||
admin = database.get_admin(g.admin_user)
|
||||
new_hash = admin['password_hash']
|
||||
if new_password:
|
||||
new_hash = generate_password_hash(new_password)
|
||||
|
||||
if database.update_admin(g.admin_user, new_username, new_hash):
|
||||
# Если имя сменилось, нужно будет перелогиниться или выдать новый токен
|
||||
return response_json(True, data={"message": "Profile updated successfully"})
|
||||
return response_json(False, error="Failed to update profile", status=500)
|
||||
|
||||
@app.errorhandler(MikroTikAPIError)
|
||||
def handle_api_error(e): return response_json(False, error=str(e), status=502)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_generic_error(e):
|
||||
if isinstance(e, HTTPException):
|
||||
return response_json(False, error=e.description, status=e.code)
|
||||
logger.exception("Unhandled server error")
|
||||
return response_json(False, error="Internal Server Error", status=500)
|
||||
|
||||
@app.route('/api/v1/addresses', methods=['GET'])
|
||||
@token_required
|
||||
def list_addresses():
|
||||
return response_json(True, data=database.get_all_items(request.args.get('list')))
|
||||
|
||||
@app.route('/api/v1/lists', methods=['GET'])
|
||||
@token_required
|
||||
def list_names():
|
||||
# Возвращает простой список строк для выпадающих списков
|
||||
return response_json(True, data=database.get_unique_lists())
|
||||
|
||||
@app.route('/api/v1/named-lists', methods=['GET'])
|
||||
@token_required
|
||||
def get_named_lists():
|
||||
# Возвращает подробный список с ID для управления
|
||||
return response_json(True, data=database.get_named_lists_detailed())
|
||||
|
||||
@app.route('/api/v1/named-lists', methods=['POST'])
|
||||
@token_required
|
||||
def create_named_list():
|
||||
data = request.json
|
||||
name = data.get('name')
|
||||
if not name:
|
||||
return response_json(False, error="Name is required", status=400)
|
||||
|
||||
success, error = database.add_named_list(name)
|
||||
if success:
|
||||
return response_json(True, status=201)
|
||||
return response_json(False, error=error, status=400)
|
||||
|
||||
@app.route('/api/v1/named-lists/<int:list_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_named_list(list_id):
|
||||
success, error = database.delete_named_list(list_id)
|
||||
if success:
|
||||
return response_json(True)
|
||||
return response_json(False, error=error, status=400)
|
||||
|
||||
@app.route('/api/v1/addresses', methods=['POST'])
|
||||
@token_required
|
||||
def create_address():
|
||||
req = request.json or {}
|
||||
address, list_name, comment = req.get('address'), req.get('list', 'default'), req.get('comment', '')
|
||||
if not address: return response_json(False, error="Address required", status=400)
|
||||
with get_mt_api() as api:
|
||||
success, res = api.add_address(address, list_name, comment)
|
||||
if success:
|
||||
database.add_item(address, list_name, comment, res)
|
||||
return response_json(True, data={"mikrotik_id": res}, status=201)
|
||||
return response_json(False, error=res, status=400)
|
||||
|
||||
@app.route('/api/v1/addresses/<int:db_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_address(db_id):
|
||||
item = database.delete_item_by_id(db_id)
|
||||
if not item: return response_json(False, error="Not found", status=404)
|
||||
with get_mt_api() as api:
|
||||
api.remove_address(item['mikrotik_id'])
|
||||
return response_json(True)
|
||||
|
||||
@app.route('/api/v1/addresses/<int:db_id>/enable', methods=['PUT'])
|
||||
@token_required
|
||||
def enable_address(db_id):
|
||||
item = database.get_item_by_id(db_id)
|
||||
if not item: return response_json(False, error="Not found", status=404)
|
||||
with get_mt_api() as api:
|
||||
success, error = api.set_address_state(item['mikrotik_id'], disabled=False)
|
||||
if success:
|
||||
database.update_item_status(item['ip'], enabled=True)
|
||||
return response_json(True)
|
||||
return response_json(False, error=error, status=400)
|
||||
|
||||
@app.route('/api/v1/addresses/<int:db_id>/disable', methods=['PUT'])
|
||||
@token_required
|
||||
def disable_address(db_id):
|
||||
item = database.get_item_by_id(db_id)
|
||||
if not item: return response_json(False, error="Not found", status=404)
|
||||
with get_mt_api() as api:
|
||||
success, error = api.set_address_state(item['mikrotik_id'], disabled=True)
|
||||
if success:
|
||||
database.update_item_status(item['ip'], enabled=False)
|
||||
return response_json(True)
|
||||
return response_json(False, error=error, status=400)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Служит основной дашборд"""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/admin')
|
||||
def admin():
|
||||
"""Служит админ-панель"""
|
||||
return render_template('admin.html')
|
||||
|
||||
@app.route('/login')
|
||||
def login_page():
|
||||
"""Служит страницу входа"""
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/api/v1/client/status', methods=['GET'])
|
||||
def client_status():
|
||||
ip = get_client_ip()
|
||||
item = database.get_item_by_ip(ip)
|
||||
if not item: return response_json(False, error="Нет интеграции с сервисом", status=403)
|
||||
return response_json(True, data={"ip": ip, "enabled": bool(item['enabled']), "list": item['list_name']})
|
||||
|
||||
@app.route('/api/v1/client/toggle', methods=['PUT'])
|
||||
def client_toggle():
|
||||
ip = get_client_ip()
|
||||
item = database.get_item_by_ip(ip)
|
||||
if not item: return response_json(False, error="Access denied", status=403)
|
||||
new_state = not bool(item['enabled'])
|
||||
with get_mt_api() as api:
|
||||
success, error = api.set_address_state(item['mikrotik_id'], disabled=not new_state)
|
||||
if success:
|
||||
database.update_item_status(ip, enabled=new_state)
|
||||
return response_json(True, data={"enabled": new_state})
|
||||
return response_json(False, error=error, status=400)
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health(): return response_json(True, data={"status": "ok"})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=APP_CONFIG['api']['port'], debug=APP_CONFIG['api']['debug'])
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
PyJWT>=2.0.0
|
||||
@@ -0,0 +1,289 @@
|
||||
/* admin.css - Updated to match index.html aesthetics */
|
||||
:root {
|
||||
--primary-gradient: linear-gradient(135deg, #1a2980 0%, #26d0ce 100%);
|
||||
--glass-bg: rgba(255, 255, 255, 0.95);
|
||||
--text-color: #333;
|
||||
--heading-color: #2c3e50;
|
||||
--success-color: #2ecc71;
|
||||
--danger-color: #e74c3c;
|
||||
--info-color: #3498db;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 12px;
|
||||
--shadow-heavy: 0 15px 35px rgba(0, 0, 0, 0.2);
|
||||
--shadow-light: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Outfit', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--primary-gradient);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
padding: 30px 20px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 40px auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-header h2 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.admin-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Management Cards - Glassmorphism */
|
||||
.management-card {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-heavy);
|
||||
padding: 40px;
|
||||
margin-bottom: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--heading-color);
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
padding: 25px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-light);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.inline-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)) auto;
|
||||
gap: 15px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 12px 15px;
|
||||
border: 2px solid #eee;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2395a5a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 15px center;
|
||||
background-size: 18px;
|
||||
padding-right: 45px;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--info-color);
|
||||
box-shadow: 0 0 0 4px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
/* Table Styles - Premium Look */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #fcfdfe;
|
||||
}
|
||||
|
||||
/* Buttons - Matching index.html */
|
||||
.btn {
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--info-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
border: 1px solid #2ecc71;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: rgba(231, 76, 60, 0.15);
|
||||
color: #c0392b;
|
||||
border: 1px solid #e74c3c;
|
||||
}
|
||||
|
||||
/* Notification / Toast */
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 25px;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-heavy);
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inline-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Outfit', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #1a2980 0%, #26d0ce 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 50px 40px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 30px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin-bottom: 30px;
|
||||
border: 2px solid #e0e0e0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 12px 25px;
|
||||
border-radius: 50px;
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status-on {
|
||||
background-color: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
border: 2px solid #2ecc71;
|
||||
}
|
||||
|
||||
.status-off {
|
||||
background-color: rgba(231, 76, 60, 0.15);
|
||||
color: #c0392b;
|
||||
border: 2px solid #e74c3c;
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
background-color: rgba(52, 152, 219, 0.15);
|
||||
color: #2980b9;
|
||||
border: 2px solid #3498db;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background-color: rgba(149, 165, 166, 0.15);
|
||||
color: #7f8c8d;
|
||||
border: 2px solid #95a5a6;
|
||||
}
|
||||
|
||||
.status-indicator i {
|
||||
margin-right: 10px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 18px 30px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn-on {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover:not(.btn-disabled) {
|
||||
background-color: #27ae60;
|
||||
box-shadow: 0 8px 20px rgba(46, 204, 113, 0.4);
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
}
|
||||
|
||||
.btn-off {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-off:hover:not(.btn-disabled) {
|
||||
background-color: #c0392b;
|
||||
box-shadow: 0 8px 20px rgba(231, 76, 60, 0.4);
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
}
|
||||
|
||||
.btn i {
|
||||
margin-right: 10px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.notification {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: 12px;
|
||||
padding: 15px 20px;
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
border-left: 4px solid #3498db;
|
||||
transition: all 0.3s ease;
|
||||
display: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.notification h3 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification h3 i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
border-left-color: rgba(231, 76, 60, 0.5);
|
||||
background-color: rgba(231, 76, 60, 0.08);
|
||||
}
|
||||
|
||||
.notification-success {
|
||||
border-left-color: rgba(46, 204, 113, 0.5);
|
||||
background-color: rgba(46, 204, 113, 0.08);
|
||||
}
|
||||
|
||||
.notification-warning {
|
||||
border-left-color: rgba(243, 156, 18, 0.5);
|
||||
background-color: rgba(243, 156, 18, 0.08);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(52, 152, 219, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #3498db;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.api-info {
|
||||
background-color: rgba(241, 248, 255, 0.6);
|
||||
backdrop-filter: blur(5px);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-top: 30px;
|
||||
font-size: 0.95rem;
|
||||
color: #4a5a6a;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.api-info strong {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.api-info ul {
|
||||
padding-left: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.api-info li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.container {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// admin.js
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Проверка наличия токена перед загрузкой
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
fetchAddresses();
|
||||
fetchNamedLists();
|
||||
|
||||
const addForm = document.getElementById('addAddressForm');
|
||||
addForm.addEventListener('submit', handleAddAddress);
|
||||
|
||||
const addListForm = document.getElementById('addListForm');
|
||||
addListForm.addEventListener('submit', handleAddList);
|
||||
|
||||
const filterInput = document.getElementById('tableFilter');
|
||||
filterInput.addEventListener('input', handleFilter);
|
||||
|
||||
const profileForm = document.getElementById('profileForm');
|
||||
profileForm.addEventListener('submit', handleProfileUpdate);
|
||||
});
|
||||
|
||||
// Хелпер для авторизованных запросов
|
||||
async function authorizedFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
|
||||
const defaultHeaders = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function fetchNamedLists() {
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/named-lists');
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
renderListsTable(data.data);
|
||||
populateListDropdown(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке списков:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderListsTable(lists) {
|
||||
const tbody = document.getElementById('listsTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
lists.forEach(list => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${list.id}</td>
|
||||
<td><strong>${list.name}</strong></td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteNamedList(${list.id})" title="Удалить список">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function populateListDropdown(lists) {
|
||||
const select = document.getElementById('inputList');
|
||||
const currentValue = select.value;
|
||||
|
||||
select.innerHTML = '';
|
||||
lists.forEach(list => {
|
||||
const option = document.createElement('option');
|
||||
option.value = list.name;
|
||||
option.textContent = list.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentValue && lists.some(l => l.name === currentValue)) {
|
||||
select.value = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddList(e) {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('newListName').value;
|
||||
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/named-lists', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Список создан', 'success');
|
||||
document.getElementById('addListForm').reset();
|
||||
fetchNamedLists();
|
||||
} else {
|
||||
showToast('Ошибка: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Сетевая ошибка', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNamedList(id) {
|
||||
if (!confirm('Удалить этот список? Это возможно только если в нем нет адресов.')) return;
|
||||
|
||||
try {
|
||||
const response = await authorizedFetch(`/api/v1/named-lists/${id}`, { method: 'DELETE' });
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Список удален', 'success');
|
||||
fetchNamedLists();
|
||||
} else {
|
||||
showToast('Ошибка: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Сетевая ошибка', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAddresses() {
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/addresses');
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
renderTable(data.data);
|
||||
updateStats(data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Ошибка при загрузке данных', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(addresses) {
|
||||
const tbody = document.getElementById('addressesTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
addresses.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${item.id}</td>
|
||||
<td><strong>${item.ip}</strong></td>
|
||||
<td><span class="badge" style="background: #eee;">${item.list_name}</span></td>
|
||||
<td>${item.comment || '-'}</td>
|
||||
<td>
|
||||
<span class="badge ${item.enabled ? 'badge-success' : 'badge-danger'}">
|
||||
${item.enabled ? 'Активен' : 'Отключен'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-primary btn-sm" onclick="toggleAddress(${item.id}, ${item.enabled})" title="${item.enabled ? 'Отключить' : 'Включить'}">
|
||||
<i class="fas fa-power-off"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteAddress(${item.id})" title="Удалить">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function updateStats(addresses) {
|
||||
document.getElementById('statTotal').textContent = addresses.length;
|
||||
document.getElementById('statActive').textContent = addresses.filter(a => a.enabled).length;
|
||||
document.getElementById('statInactive').textContent = addresses.filter(a => !a.enabled).length;
|
||||
}
|
||||
|
||||
async function handleAddAddress(e) {
|
||||
e.preventDefault();
|
||||
const address = document.getElementById('inputAddress').value;
|
||||
const list = document.getElementById('inputList').value;
|
||||
const comment = document.getElementById('inputComment').value;
|
||||
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/addresses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address, list, comment })
|
||||
});
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Адрес успешно добавлен', 'success');
|
||||
document.getElementById('addAddressForm').reset();
|
||||
fetchAddresses();
|
||||
} else {
|
||||
showToast('Ошибка: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Сетевая ошибка', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAddress(id, currentEnabled) {
|
||||
const action = currentEnabled ? 'disable' : 'enable';
|
||||
try {
|
||||
const response = await authorizedFetch(`/api/v1/addresses/${id}/${action}`, { method: 'PUT' });
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(`Статус успешно изменен`, 'success');
|
||||
fetchAddresses();
|
||||
} else {
|
||||
showToast('Ошибка: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Сетевая ошибка', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAddress(id) {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот адрес?')) return;
|
||||
try {
|
||||
const response = await authorizedFetch(`/api/v1/addresses/${id}`, { method: 'DELETE' });
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Запись удалена', 'success');
|
||||
fetchAddresses();
|
||||
} else {
|
||||
showToast('Ошибка при удалении', 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Сетевая ошибка', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileUpdate(e) {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('profileUsername').value;
|
||||
const password = document.getElementById('profilePassword').value;
|
||||
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/admin/profile', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!response) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Профиль успешно обновлен. Если вы сменили имя, потребуется перелогиниться.', 'success');
|
||||
document.getElementById('profilePassword').value = '';
|
||||
} else {
|
||||
showToast('Ошибка: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Сетевая ошибка', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilter(e) {
|
||||
const text = e.target.value.toLowerCase();
|
||||
const rows = document.querySelectorAll('#addressesTableBody tr');
|
||||
rows.forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(text) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.style.backgroundColor = type === 'success' ? '#2ecc71' : '#e74c3c';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Добавим функцию выхода
|
||||
function logout() {
|
||||
localStorage.removeItem('admin_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Элементы DOM
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const statusInfo = document.getElementById('statusInfo');
|
||||
const btnOn = document.getElementById('btnOn');
|
||||
const btnOff = document.getElementById('btnOff');
|
||||
const notification = document.getElementById('notification');
|
||||
const notificationMessage = document.getElementById('notificationMessage');
|
||||
|
||||
// Конфигурация API
|
||||
const API_BASE_URL = window.location.origin;
|
||||
const API_STATUS_ENDPOINT = `${API_BASE_URL}/api/v1/client/status`;
|
||||
const API_TOGGLE_ENDPOINT = `${API_BASE_URL}/api/v1/client/toggle`;
|
||||
|
||||
// Переменные состояния
|
||||
let currentStatus = null;
|
||||
let isRequestInProgress = false;
|
||||
let hasIntegration = false;
|
||||
|
||||
// Инициализация при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
loadInitialStatus();
|
||||
|
||||
btnOn.addEventListener('click', () => sendCommand(true));
|
||||
btnOff.addEventListener('click', () => sendCommand(false));
|
||||
});
|
||||
|
||||
// Функция загрузки начального статуса
|
||||
async function loadInitialStatus() {
|
||||
try {
|
||||
updateUIForLoading();
|
||||
|
||||
const response = await fetch(API_STATUS_ENDPOINT);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
hasIntegration = true;
|
||||
currentStatus = data.data.enabled;
|
||||
updateUIForStatus(currentStatus);
|
||||
showNotification(`Доступ разрешен. Ваш IP: ${data.data.ip}.`, 'success', true);
|
||||
} else {
|
||||
hasIntegration = false;
|
||||
updateUIForNoIntegration(data.error || 'Нет интеграции с сервисом');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке статуса:', error);
|
||||
updateUIForError('Ошибка соединения с сервером API');
|
||||
}
|
||||
}
|
||||
|
||||
// Функция отправки команды на сервер
|
||||
async function sendCommand(enabled) {
|
||||
if (!hasIntegration || isRequestInProgress) return;
|
||||
|
||||
// Прерываем, если запрашиваемое состояние уже активно
|
||||
if (enabled === currentStatus) return;
|
||||
|
||||
isRequestInProgress = true;
|
||||
const action = enabled ? 'включения' : 'выключения';
|
||||
|
||||
updateUIForSendingCommand(enabled);
|
||||
|
||||
try {
|
||||
const response = await fetch(API_TOGGLE_ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentStatus = data.data.enabled;
|
||||
updateUIForStatus(currentStatus);
|
||||
showNotification(`Команда ${action} успешно выполнена.`, 'success');
|
||||
provideVisualFeedback(currentStatus);
|
||||
} else {
|
||||
showNotification(`Ошибка: ${data.error}`, 'error');
|
||||
updateUIForStatus(currentStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отправке команды:', error);
|
||||
showNotification('Ошибка сети при отправке команды', 'error');
|
||||
updateUIForStatus(currentStatus);
|
||||
} finally {
|
||||
isRequestInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление UI при загрузке
|
||||
function updateUIForLoading() {
|
||||
statusIndicator.className = 'status-indicator status-loading';
|
||||
statusIndicator.innerHTML = '<i class="fas fa-sync-alt fa-spin"></i> Загрузка...';
|
||||
statusInfo.textContent = 'Получение текущего состояния с сервера...';
|
||||
|
||||
btnOn.classList.add('btn-disabled');
|
||||
btnOff.classList.add('btn-disabled');
|
||||
btnOn.disabled = true;
|
||||
btnOff.disabled = true;
|
||||
}
|
||||
|
||||
// Обновление UI на основе текущего статуса
|
||||
function updateUIForStatus(enabled) {
|
||||
if (enabled) {
|
||||
statusIndicator.className = 'status-indicator status-on';
|
||||
statusIndicator.innerHTML = '<i class="fas fa-check-circle"></i> Включено';
|
||||
statusInfo.textContent = 'Устройство активно и работает в нормальном режиме';
|
||||
|
||||
btnOn.classList.add('btn-disabled');
|
||||
btnOn.disabled = true;
|
||||
|
||||
btnOff.classList.remove('btn-disabled');
|
||||
btnOff.disabled = false;
|
||||
} else {
|
||||
statusIndicator.className = 'status-indicator status-off';
|
||||
statusIndicator.innerHTML = '<i class="fas fa-power-off"></i> Выключено';
|
||||
statusInfo.textContent = 'Устройство отключено и не выполняет свои функции';
|
||||
|
||||
btnOff.classList.add('btn-disabled');
|
||||
btnOff.disabled = true;
|
||||
|
||||
btnOn.classList.remove('btn-disabled');
|
||||
btnOn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление UI при отсутствии интеграции
|
||||
function updateUIForNoIntegration(message) {
|
||||
statusIndicator.className = 'status-indicator status-disabled';
|
||||
statusIndicator.innerHTML = '<i class="fas fa-ban"></i> Нет доступа';
|
||||
statusInfo.textContent = 'Ваш IP-адрес не найден в списке доверенных адресов';
|
||||
btnOn.classList.add('btn-disabled');
|
||||
btnOff.classList.add('btn-disabled');
|
||||
btnOn.disabled = true;
|
||||
btnOff.disabled = true;
|
||||
showNotification(message, 'error', true);
|
||||
}
|
||||
|
||||
// Обновление UI при ошибке
|
||||
function updateUIForError(errorMessage) {
|
||||
statusIndicator.className = 'status-indicator status-disabled';
|
||||
statusIndicator.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Ошибка';
|
||||
statusInfo.textContent = 'Не удалось получить информацию о статусе устройства';
|
||||
btnOn.classList.add('btn-disabled');
|
||||
btnOff.classList.add('btn-disabled');
|
||||
btnOn.disabled = true;
|
||||
btnOff.disabled = true;
|
||||
showNotification(errorMessage, 'error', true);
|
||||
}
|
||||
|
||||
// Обновление UI при отправке команды
|
||||
function updateUIForSendingCommand(enabled) {
|
||||
const action = enabled ? 'включения' : 'выключения';
|
||||
statusIndicator.className = 'status-indicator status-loading';
|
||||
statusIndicator.innerHTML = '<i class="fas fa-sync-alt fa-spin"></i> Отправка...';
|
||||
statusInfo.textContent = `Отправка команды ${action} на сервер...`;
|
||||
btnOn.classList.add('btn-disabled');
|
||||
btnOff.classList.add('btn-disabled');
|
||||
btnOn.disabled = true;
|
||||
btnOff.disabled = true;
|
||||
showNotification(`Отправка команды ${action}...`, 'warning');
|
||||
}
|
||||
|
||||
// Переменные для управления уведомлениями
|
||||
let baseNotificationContent = null;
|
||||
let notificationTimeout = null;
|
||||
|
||||
// Показать уведомление
|
||||
function showNotification(message, type = 'info', isPersistent = false) {
|
||||
if (notificationTimeout) {
|
||||
clearTimeout(notificationTimeout);
|
||||
notificationTimeout = null;
|
||||
}
|
||||
|
||||
notification.className = 'notification';
|
||||
let icon = 'fa-info-circle';
|
||||
let title = 'Информация:';
|
||||
|
||||
if (type === 'error') {
|
||||
notification.classList.add('notification-error');
|
||||
icon = 'fa-exclamation-circle';
|
||||
title = 'Ошибка:';
|
||||
} else if (type === 'success') {
|
||||
notification.classList.add('notification-success');
|
||||
icon = 'fa-check-circle';
|
||||
title = 'Успешно:';
|
||||
} else if (type === 'warning') {
|
||||
notification.classList.add('notification-warning');
|
||||
icon = 'fa-sync-alt fa-spin';
|
||||
title = 'Обработка:';
|
||||
}
|
||||
|
||||
const content = `<h3><i class="fas ${icon}"></i> ${title}</h3><div class="notification-message">${message}</div>`;
|
||||
notification.innerHTML = content;
|
||||
|
||||
if (isPersistent) {
|
||||
baseNotificationContent = content;
|
||||
}
|
||||
|
||||
notification.style.opacity = '0';
|
||||
notification.style.display = 'block';
|
||||
|
||||
// Плавное появление
|
||||
setTimeout(() => {
|
||||
notification.style.transition = 'opacity 0.3s ease';
|
||||
notification.style.opacity = '1';
|
||||
}, 10);
|
||||
|
||||
// Авто-возврат к базовому уведомлению через 5 секунд (если это не само базовое и не "в обработке")
|
||||
if (!isPersistent && baseNotificationContent && type !== 'warning') {
|
||||
notificationTimeout = setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
notification.className = 'notification';
|
||||
notification.innerHTML = baseNotificationContent;
|
||||
notification.style.opacity = '1';
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Визуальная обратная связь
|
||||
function provideVisualFeedback(enabled) {
|
||||
if (enabled) {
|
||||
document.body.style.background = 'linear-gradient(135deg, #2ecc71 0%, #27ae60 100%)';
|
||||
setTimeout(() => {
|
||||
document.body.style.background = 'linear-gradient(135deg, #1a2980 0%, #26d0ce 100%)';
|
||||
}, 800);
|
||||
} else {
|
||||
document.body.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
|
||||
setTimeout(() => {
|
||||
document.body.style.background = 'linear-gradient(135deg, #1a2980 0%, #26d0ce 100%)';
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
// Дополнительная функция для ручного обновления статуса
|
||||
function refreshStatus() {
|
||||
if (!isRequestInProgress) {
|
||||
loadInitialStatus();
|
||||
}
|
||||
}
|
||||
|
||||
statusIndicator.addEventListener('dblclick', refreshStatus);
|
||||
@@ -0,0 +1,58 @@
|
||||
// login.js
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
|
||||
// UI Feedback
|
||||
loginBtn.classList.add('btn-disabled');
|
||||
loginBtn.innerHTML = '<i class="fas fa-sync-alt fa-spin"></i> Авторизация...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Сохраняем токен
|
||||
localStorage.setItem('admin_token', data.data.token);
|
||||
|
||||
showStatus('Успешно! Вход в систему...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin';
|
||||
}, 800);
|
||||
} else {
|
||||
showStatus(data.error || 'Ошибка входа', 'error');
|
||||
resetButton(loginBtn);
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('Ошибка сети или сервера', 'error');
|
||||
resetButton(loginBtn);
|
||||
}
|
||||
});
|
||||
|
||||
function resetButton(btn) {
|
||||
btn.classList.remove('btn-disabled');
|
||||
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Войти в панель';
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.className = 'notification';
|
||||
if (type === 'error') {
|
||||
notification.classList.add('notification-error');
|
||||
notification.innerHTML = `<h3><i class="fas fa-exclamation-circle"></i> Ошибка:</h3><div class="notification-message">${message}</div>`;
|
||||
} else {
|
||||
notification.classList.add('notification-success');
|
||||
notification.innerHTML = `<h3><i class="fas fa-check-circle"></i> Успешно:</h3><div class="notification-message">${message}</div>`;
|
||||
}
|
||||
notification.style.display = 'block';
|
||||
notification.style.opacity = '1';
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Админ-панель | MikroTik Proxy</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="admin-header">
|
||||
<div class="header-left">
|
||||
<h2><i class="fas fa-shield-alt"></i> MikroTik Proxy Admin</h2>
|
||||
</div>
|
||||
<div class="header-right" style="display: flex; gap: 10px;">
|
||||
<a href="/" class="btn btn-primary"><i class="fas fa-home"></i> К дашборду</a>
|
||||
<button onclick="logout()" class="btn btn-danger"><i class="fas fa-sign-out-alt"></i> Выход</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-container">
|
||||
<!-- Статистика -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Всего IP</h3>
|
||||
<div id="statTotal" class="value">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Активных</h3>
|
||||
<div id="statActive" class="value" style="color: var(--success-color)">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Отключенных</h3>
|
||||
<div id="statInactive" class="value" style="color: var(--danger-color)">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма добавления -->
|
||||
<div class="management-card">
|
||||
<h3 class="card-title"><i class="fas fa-plus-circle"></i> Добавить новый адрес</h3>
|
||||
<form id="addAddressForm" class="inline-form">
|
||||
<div class="form-group">
|
||||
<label>IP Адрес</label>
|
||||
<input type="text" id="inputAddress" placeholder="например, 192.168.1.50" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Название списка</label>
|
||||
<select id="inputList" required>
|
||||
<!-- Опции загружаются через JS -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Комментарий</label>
|
||||
<input type="text" id="inputComment" placeholder="например, Мой ПК">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success"><i class="fas fa-plus"></i> Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Управление списками -->
|
||||
<div class="management-card">
|
||||
<h3 class="card-title"><i class="fas fa-tags"></i> Управление списками доступа</h3>
|
||||
<p style="margin-bottom: 20px; color: #666; font-size: 0.9rem;">
|
||||
Создавайте именованные списки доступа здесь. Вы сможете выбирать их при добавлении новых IP.
|
||||
</p>
|
||||
<form id="addListForm" class="inline-form" style="margin-bottom: 25px;">
|
||||
<div class="form-group">
|
||||
<label>Новое имя списка</label>
|
||||
<input type="text" id="newListName" placeholder="например, guest_access" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success"><i class="fas fa-plus"></i> Создать список</button>
|
||||
</form>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th style="width: 100px;">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listsTableBody">
|
||||
<!-- Данные загружаются через JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список адресов -->
|
||||
<div class="management-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 class="card-title" style="margin: 0;"><i class="fas fa-list"></i> Управление списком доступа</h3>
|
||||
<div class="form-group" style="width: 250px;">
|
||||
<input type="text" id="tableFilter" placeholder="Поиск по IP или списку...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="addressesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>IP Адрес</th>
|
||||
<th>Список</th>
|
||||
<th>Комментарий</th>
|
||||
<th>Статус</th>
|
||||
<th style="width: 150px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="addressesTableBody">
|
||||
<!-- Данные загружаются через JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Настройки профиля -->
|
||||
<div class="management-card">
|
||||
<h3 class="card-title"><i class="fas fa-user-cog"></i> Настройки администратора</h3>
|
||||
<p style="margin-bottom: 20px; color: #666; font-size: 0.9rem;">
|
||||
Здесь вы можете изменить имя пользователя и пароль для входа в панель управления.
|
||||
</p>
|
||||
<form id="profileForm" class="inline-form">
|
||||
<div class="form-group">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" id="profileUsername" placeholder="admin" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Новый пароль (оставьте пустым для сохранения текущего)</label>
|
||||
<input type="password" id="profilePassword" placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Обновить профиль</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="notification"></div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Управление устройством</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><i class="fas fa-sliders-h"></i> Управление устройством</h1>
|
||||
<p class="subtitle">Удаленное управление с проверкой IP-адреса</p>
|
||||
|
||||
<div class="status-panel">
|
||||
<div class="status-title">Текущий статус устройства:</div>
|
||||
<div id="statusIndicator" class="status-indicator status-loading">
|
||||
<i class="fas fa-sync-alt fa-spin"></i> Загрузка...
|
||||
</div>
|
||||
<div id="statusInfo" class="status-info">
|
||||
Получение текущего состояния с сервера...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="btnOn" class="btn btn-on btn-disabled">
|
||||
<i class="fas fa-play"></i> Включить
|
||||
</button>
|
||||
<button id="btnOff" class="btn btn-off btn-disabled">
|
||||
<i class="fas fa-stop"></i> Выключить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notification" class="notification">
|
||||
<h3><i class="fas fa-info-circle"></i> Информация:</h3>
|
||||
<div id="notificationMessage" class="notification-message">
|
||||
Система определяет ваш IP-адрес и проверяет доступ к управлению.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-info">
|
||||
<strong>Как это работает:</strong>
|
||||
<ul>
|
||||
<li>Сервер автоматически определяет ваш IP-адрес при подключении</li>
|
||||
<li>IP-адрес проверяется по списку доверенных адресов</li>
|
||||
<li>При успешной проверке отображаются кнопки управления</li>
|
||||
<li>При отсутствии интеграции показывается сообщение об ошибке</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Авторизация | MikroTik Proxy</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<style>
|
||||
.login-container {
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
padding: 60px 40px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
display: inline-flex;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
border-radius: 50%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
color: #3498db;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-bottom: 25px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: #4a5a6a;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.input-with-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-with-icon i {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #95a5a6;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.input-with-icon input {
|
||||
width: 100%;
|
||||
padding: 14px 15px 14px 45px;
|
||||
border: 2px solid #edf2f7;
|
||||
border-radius: 14px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.input-with-icon input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 4px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.input-with-icon input:focus+i {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin-top: 30px;
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container login-container">
|
||||
<div class="login-header">
|
||||
<div class="login-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<h1>Безопасный вход</h1>
|
||||
<p class="subtitle">Управление прокси-сервером MikroTik</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label>Имя пользователя</label>
|
||||
<div class="input-with-icon">
|
||||
<i class="fas fa-user"></i>
|
||||
<input type="text" id="username" placeholder="admin" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Пароль</label>
|
||||
<div class="input-with-icon">
|
||||
<i class="fas fa-key"></i>
|
||||
<input type="password" id="password" placeholder="••••••••" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-on btn-login" id="loginBtn">
|
||||
<i class="fas fa-sign-in-alt"></i> Войти в панель
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="notification" class="notification"></div>
|
||||
|
||||
<div class="footer-links">
|
||||
<p><a href="/"><i class="fas fa-arrow-left"></i> На главную</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in new issue
Block a user