сборка для Ильи

This commit is contained in:
Антон committed 2026-09-20 19:47:02 +03:00
commit 59ce64be5c
18 files changed
+2400

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+12
View File
@@ -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
+187
View File
@@ -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
View File
Binary file not shown.
+14
View File
@@ -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
View File
@@ -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.")
+459
View File
@@ -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'])
+3
View File
@@ -0,0 +1,3 @@
Flask>=2.0.0
python-dotenv>=1.0.0
PyJWT>=2.0.0
+289
View File
@@ -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;
}
}
+274
View File
@@ -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;
}
}
+307
View File
@@ -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';
}
+241
View File
@@ -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);
+58
View File
@@ -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';
}
+148
View File
@@ -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>
+60
View File
@@ -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>
+146
View File
@@ -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>