#!/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'])