commit 59ce64be5cc7b5dc0fe997280cb518dcd822e7d3 Author: Антон Date: Sun Sep 20 19:47:02 2026 +0300 сборка для Ильи diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..28e7fd8 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e92c24c --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..25de6e1 --- /dev/null +++ b/README.md @@ -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 `* + +#### **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/`: Remove assignment. +- **PUT** `/api/v1/addresses//enable`: Activate IP. +- **PUT** `/api/v1/addresses//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/`: 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`. diff --git a/__pycache__/database.cpython-314.pyc b/__pycache__/database.cpython-314.pyc new file mode 100644 index 0000000..0a0d767 Binary files /dev/null and b/__pycache__/database.cpython-314.pyc differ diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..e8913e5 Binary files /dev/null and b/__pycache__/main.cpython-314.pyc differ diff --git a/app_data.db b/app_data.db new file mode 100644 index 0000000..0918386 Binary files /dev/null and b/app_data.db differ diff --git a/config.json b/config.json new file mode 100644 index 0000000..16d6b52 --- /dev/null +++ b/config.json @@ -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 + } +} \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..847d0d7 --- /dev/null +++ b/database.py @@ -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.") diff --git a/main.py b/main.py new file mode 100644 index 0000000..73154d9 --- /dev/null +++ b/main.py @@ -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/', 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/', 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//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//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']) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e2b02a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask>=2.0.0 +python-dotenv>=1.0.0 +PyJWT>=2.0.0 diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..c291237 --- /dev/null +++ b/static/css/admin.css @@ -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; + } +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..afa51ae --- /dev/null +++ b/static/css/style.css @@ -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; + } +} \ No newline at end of file diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..1f3957e --- /dev/null +++ b/static/js/admin.js @@ -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 = ` + ${list.id} + ${list.name} + + + + `; + 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 = ` + ${item.id} + ${item.ip} + ${item.list_name} + ${item.comment || '-'} + + + ${item.enabled ? 'Активен' : 'Отключен'} + + + + + + + `; + 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'; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..567ac04 --- /dev/null +++ b/static/js/app.js @@ -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 = ' Загрузка...'; + 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 = ' Включено'; + 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 = ' Выключено'; + 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 = ' Нет доступа'; + 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 = ' Ошибка'; + 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 = ' Отправка...'; + 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 = `

${title}

${message}
`; + 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); diff --git a/static/js/login.js b/static/js/login.js new file mode 100644 index 0000000..39e7a54 --- /dev/null +++ b/static/js/login.js @@ -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 = ' Авторизация...'; + + 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 = ' Войти в панель'; +} + +function showStatus(message, type) { + const notification = document.getElementById('notification'); + notification.className = 'notification'; + if (type === 'error') { + notification.classList.add('notification-error'); + notification.innerHTML = `

Ошибка:

${message}
`; + } else { + notification.classList.add('notification-success'); + notification.innerHTML = `

Успешно:

${message}
`; + } + notification.style.display = 'block'; + notification.style.opacity = '1'; +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..27530aa --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,148 @@ + + + + + + + Админ-панель | MikroTik Proxy + + + + + + + + +
+
+

MikroTik Proxy Admin

+
+
+ К дашборду + +
+
+ +
+ +
+
+

Всего IP

+
0
+
+
+

Активных

+
0
+
+
+

Отключенных

+
0
+
+
+ + +
+

Добавить новый адрес

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

Управление списками доступа

+

+ Создавайте именованные списки доступа здесь. Вы сможете выбирать их при добавлении новых IP. +

+
+
+ + +
+ +
+ +
+ + + + + + + + + + + +
IDНазваниеДействие
+
+
+ + +
+
+

Управление списком доступа

+
+ +
+
+ +
+ + + + + + + + + + + + + + +
IDIP АдресСписокКомментарийСтатусДействия
+
+
+ + +
+

Настройки администратора

+

+ Здесь вы можете изменить имя пользователя и пароль для входа в панель управления. +

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..df8870a --- /dev/null +++ b/templates/index.html @@ -0,0 +1,60 @@ + + + + + + + Управление устройством + + + + + + + + +
+

Управление устройством

+

Удаленное управление с проверкой IP-адреса

+ +
+
Текущий статус устройства:
+
+ Загрузка... +
+
+ Получение текущего состояния с сервера... +
+
+ +
+ + +
+ +
+

Информация:

+
+ Система определяет ваш IP-адрес и проверяет доступ к управлению. +
+
+ +
+ Как это работает: +
    +
  • Сервер автоматически определяет ваш IP-адрес при подключении
  • +
  • IP-адрес проверяется по списку доверенных адресов
  • +
  • При успешной проверке отображаются кнопки управления
  • +
  • При отсутствии интеграции показывается сообщение об ошибке
  • +
+
+
+ + + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..7953472 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,146 @@ + + + + + + + Авторизация | MikroTik Proxy + + + + + + + + + + + + + + + \ No newline at end of file