From 59ce64be5cc7b5dc0fe997280cb518dcd822e7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD?= Date: Sun, 20 Sep 2026 19:47:02 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=98=D0=BB=D1=8C=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes .env.example | 12 + README.md | 187 +++++++++++ __pycache__/database.cpython-314.pyc | Bin 0 -> 10579 bytes __pycache__/main.cpython-314.pyc | Bin 0 -> 17574 bytes app_data.db | Bin 0 -> 32768 bytes config.json | 14 + database.py | 202 ++++++++++++ main.py | 459 +++++++++++++++++++++++++++ requirements.txt | 3 + static/css/admin.css | 289 +++++++++++++++++ static/css/style.css | 274 ++++++++++++++++ static/js/admin.js | 307 ++++++++++++++++++ static/js/app.js | 241 ++++++++++++++ static/js/login.js | 58 ++++ templates/admin.html | 148 +++++++++ templates/index.html | 60 ++++ templates/login.html | 146 +++++++++ 18 files changed, 2400 insertions(+) create mode 100644 .DS_Store create mode 100644 .env.example create mode 100644 README.md create mode 100644 __pycache__/database.cpython-314.pyc create mode 100644 __pycache__/main.cpython-314.pyc create mode 100644 app_data.db create mode 100644 config.json create mode 100644 database.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/css/admin.css create mode 100644 static/css/style.css create mode 100644 static/js/admin.js create mode 100644 static/js/app.js create mode 100644 static/js/login.js create mode 100644 templates/admin.html create mode 100644 templates/index.html create mode 100644 templates/login.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..28e7fd80d504b901471d086198c16274cfe604fc GIT binary patch literal 6148 zcmeHKy-EW?5T4aTV$#Gy5UZ=~6haz1&v5oaz)li#2}Zc{0{MYd9{2*m%15xY6np^R zLJMC)v2kW+Np{U8U?V|hVE5bGotgdagT1{0096~dD*$BxC}1P>irD2Cty7z`H8Zn? zsC149E+B#~bf7WkEfb=ED6nb@(Ah2FX>C9c#&~vT=eH4GR_alj^q6w;x!5yF!>|>n zE%YcHRL`I8?q80VdAEPz-Rs&cGE55_(1!ss9l#YtF3arU=eo`9t@>KLVQx;2B%Lu@ z5-|YaK;O)`=WXuGo32j<=>oys+*Q9;LR!#xDv&jl2M(ITXQ9u-!DnQ={ z9~)s{Fk{r84(xOZfau}0HXKteK{39;z+lFRD`<*C5jj+u5<_t~&ilqMFqkoNI4Dzm zD6_LN6^hckV}4(|g9;d>7X?HCSAlhYEYtmevbg?tgXBpR5C#5~0?G^PVGUo&+^usj wM|Z7{t%;3{{4z!zf}OdJ^?|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 0000000000000000000000000000000000000000..0a0d767615fcffaae51678ebd016c6cc17f016be GIT binary patch literal 10579 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 literal 0 HcmV?d00001 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