diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md new file mode 100644 index 0000000..e6b6780 --- /dev/null +++ b/AI_CONTEXT.md @@ -0,0 +1,83 @@ +# AI_CONTEXT: MikroTik Telegram Bot + +## ARCH:CORE +- **Lang**: Python 3.10+ +- **Stack**: aiogram3+librouteros+fastapi+aiosqlite +- **Deploy**: Docker/bare +- **Auth**: TG_user_id whitelist + +## FUNCS:MAIN +1. **WiFi_Monitor**: /clients, live_polling_30s, pagination +2. **History**: /history, sessions_DB, date_filters +3. **Stats**: /stats, rx/tx_aggregation, top_users +4. **Events**: FastAPI_webhook + polling_hybrid + +## FILES:KEY +- `bot.py`: main_logic, TG_handlers, API_polling +- `db.py`: aiosqlite_schema, CRUD_ops +- `requirements.txt`: deps_list +- `docker-compose.yml`: container_config +- `docker-compose.portainer.yml`: watchtower_autodeploy +- `DEPLOY.md`: portainer_setup_instructions + +## API:MT_PATHS +- **WiFi_clients**: `/interface/wifi/registration-table` (RouterOS7+) +- **Fields**: mac-address, interface, ssid, uptime, signal, band + +## DB:SCHEMA +```sql +clients(mac PK, name, custom_name, last_seen) +sessions(id, mac FK, ip, start/end_time, rx/tx_bytes, source) +``` + +## ENVS:REQ +``` +TG_BOT_TOKEN=bot_token +MT_API_HOST=router_ip +MT_API_USER=api_user +MT_API_PASS=api_pass +ALLOWED_USER_IDS=123,456 +``` + +## DEPLOY:AUTODEPLOY +**Method1:Watchtower** (recommended) +- docker-compose.portainer.yml → portainer_stack +- watchtower checks updates every 5min +- auto rebuild+restart on code changes + +**Method2:GitHub_Registry** +- .github/workflows/docker.yml → ghcr.io +- webhook triggers portainer update +- requires public repo or PAT + +## DEPLOY:QUICK +```bash +docker build -t mt-bot . +docker run --env-file .env mt-bot +``` + +## CMDS:TG +- `/start` → main_menu +- `/clients` → wifi_list_paginated +- `/history [days]` → sessions_history +- `/stats [days]` → traffic_stats + +## ENDPOINTS:API +- `GET /health` → healthcheck +- `GET /event?mac=XX&ip=XX&type=XX` → webhook + +## POLL:LOGIC +- 30s_interval MT_API_check +- upsert_clients → track_sessions +- close_sessions_for_disconnected + +## WEBHOOK:FETCH +- GET `/event?mac=XX&ip=XX&type=XX` +- saves_to_DB + notifies_TG_users + +## NOTES:DEV +- RouterOS7 fields differ from v6 +- no rx/tx in wifi_registration_table +- polling+webhook hybrid for reliability +- pagination: 10items/page, nav_buttons +- healthcheck: curl localhost:8000/health \ No newline at end of file diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..41c6f34 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,58 @@ +# Автодеплой в Portainer + +## Вариант 1: Watchtower (рекомендуемый) + +### Шаги: +1. В Portainer создай Stack с именем `mikrotik-bot` +2. Скопируй содержимое `docker-compose.portainer.yml` +3. Создай `.env` файл с переменными: + ``` + TG_BOT_TOKEN=your_bot_token + MT_API_HOST=192.168.1.1 + MT_API_USER=admin + MT_API_PASS=password + ALLOWED_USER_IDS=123456,789012 + ``` +4. Deploy Stack + +### Как работает: +- Watchtower проверяет обновления каждые 5 минут +- При изменении кода rebuilds образ автоматически +- Чистит старые образы после обновления + +## Вариант 2: GitHub Registry + Webhook + +### Настройка GitHub: +1. Включи GitHub Actions в репозитории +2. Push код - автоматически собирется образ в ghcr.io +3. В `docker-compose.registry.yml` замени: + ```yaml + image: ghcr.io/YOUR_USERNAME/mikrotik-telegram-bot:latest + ``` + +### Настройка Portainer: +1. Создай Stack с `docker-compose.registry.yml` +2. В настройках Stack включи "Auto Update" +3. Создай Webhook для автообновления + +### Webhook URL: +``` +https://portainer.domain.com/api/webhooks/YOUR_WEBHOOK_KEY +``` + +## Проверка работы: +- Health check: `curl http://localhost:8000/health` +- Логи: `docker logs mikrotik-telegram-bot` +- Статус: Portainer → Stacks → mikrotik-bot + +## Откат: +Если что-то сломалось: +```bash +docker-compose down +docker-compose up -d +``` + +## Мониторинг: +- Portainer показывает статус health check +- Telegram бот отправляет уведомления при ошибках +- FastAPI endpoint `/health` для внешнего мониторинга \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af6c714 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.10-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN python -c "import db; db.init_db()" + +CMD ["python", "bot.py"] \ No newline at end of file diff --git a/INFRA_PLAN.md b/INFRA_PLAN.md new file mode 100644 index 0000000..53b5d68 --- /dev/null +++ b/INFRA_PLAN.md @@ -0,0 +1,240 @@ +# CI/CD Infrastructure Plan - Proxmox + +## ARCH:OVERVIEW +``` +[DEV] → [Git Server] → [CI/CD] → [Registry] → [Portainer] → [MikroTik Bot] +``` + +## QUICK:START +```bash +cd infra/ +chmod +x *.sh +./deploy-all.sh +``` + +**Результат:** Полная CI/CD инфраструктура за 10 минут! + +## VMs/LXC:SETUP + +### 1. Git Server (LXC) +- **OS**: Ubuntu 22.04 LXC +- **Memory**: 2GB RAM, 20GB disk +- **Service**: Gitea (lightweight GitHub alternative) +- **Features**: + - Web UI для управления репозиториями + - Webhook support для CI/CD + - Built-in Actions (CI/CD) + - Issues, PRs, Wiki + +### 2. CI/CD + Registry (LXC) +- **OS**: Ubuntu 22.04 LXC +- **Memory**: 4GB RAM, 50GB disk +- **Services**: + - GitLab Runner / Gitea Actions Runner + - Docker Registry (private) + - Nexus/Harbor (опционально) + +### 3. Production Environment (LXC) +- **OS**: Ubuntu 22.04 LXC +- **Memory**: 2GB RAM, 20GB disk +- **Services**: + - Portainer CE + - MikroTik Telegram Bot + - Watchtower (автообновление) + +## NETWORK:CONFIG +``` +Proxmox Bridge (vmbr1): 10.10.0.0/24 +├── Git Server: 10.10.0.10 +├── CI/CD Registry: 10.10.0.20 +├── Production: 10.10.0.30 +└── Gateway: 10.10.0.1 (access from home LAN) +``` + +## SERVICES:STACK + +### Git Server (Gitea) +```yaml +version: '3.8' +services: + gitea: + image: gitea/gitea:latest + container_name: gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__server__DOMAIN=git.home.lab + - GITEA__server__ROOT_URL=http://git.home.lab:3000 + volumes: + - gitea_data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "2222:22" + restart: unless-stopped +``` + +### CI/CD + Registry +```yaml +version: '3.8' +services: + # Private Docker Registry + registry: + image: registry:2 + container_name: docker-registry + environment: + REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /data + volumes: + - registry_data:/data + ports: + - "5000:5000" + restart: unless-stopped + + # Gitea Actions Runner + runner: + image: gitea/act_runner:latest + container_name: gitea-runner + environment: + GITEA_INSTANCE_URL: http://10.10.0.10:3000 + GITEA_RUNNER_REGISTRATION_TOKEN: "your_token" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - runner_data:/data + restart: unless-stopped +``` + +### Production (Portainer + Apps) +```yaml +version: '3.8' +services: + portainer: + image: portainer/portainer-ce:latest + container_name: portainer + command: -H unix:///var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer_data:/data + ports: + - "9000:9000" + restart: unless-stopped +``` + +## WORKFLOW:CI_CD + +### 1. Development Flow +``` +git push → Gitea → webhook → Actions → build → registry → webhook → Portainer → deploy +``` + +### 2. Gitea Actions Workflow (.gitea/workflows/deploy.yml) +```yaml +name: Build and Deploy +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build Image + run: | + docker build -t 10.10.0.20:5000/mikrotik-bot:${{ github.sha }} . + docker push 10.10.0.20:5000/mikrotik-bot:${{ github.sha }} + docker tag 10.10.0.20:5000/mikrotik-bot:${{ github.sha }} 10.10.0.20:5000/mikrotik-bot:latest + docker push 10.10.0.20:5000/mikrotik-bot:latest + - name: Deploy to Production + run: | + curl -X POST "http://10.10.0.30:9000/api/webhooks/YOUR_WEBHOOK_KEY" +``` + +## DEPLOY:STEPS + +### Phase 1: Infrastructure Setup +1. **Create LXC containers** in Proxmox +2. **Network configuration** (vmbr1 bridge) +3. **Install Docker** on all containers +4. **Configure DNS** (local or Pi-hole): git.home.lab, registry.home.lab, portainer.home.lab + +### Phase 2: Services Deployment +1. **Deploy Gitea** (10.10.0.10:3000) +2. **Create mikrotik-bot repository** +3. **Deploy Registry** (10.10.0.20:5000) +4. **Setup Gitea Actions Runner** +5. **Deploy Portainer** (10.10.0.30:9000) + +### Phase 3: CI/CD Configuration +1. **Configure Gitea webhook** → Actions +2. **Setup registry access** (insecure for local) +3. **Create Portainer webhook** for auto-deploy +4. **Test full pipeline** + +### Phase 4: Production Deploy +1. **Push code** to Gitea +2. **Verify automatic build** in registry +3. **Confirm auto-deploy** to Portainer +4. **Setup monitoring** and alerts + +## SECURITY:CONSIDERATIONS +- **Registry**: HTTP (internal network only) +- **Gitea**: HTTP (можно добавить HTTPS с self-signed) +- **Firewall**: только нужные порты +- **Backup**: регулярные снапшоты LXC +- **Access**: VPN или закрытая сеть + +## MONITORING:STACK (Optional) +```yaml +# Prometheus + Grafana +prometheus: + image: prom/prometheus:latest + ports: ["9090:9090"] + +grafana: + image: grafana/grafana:latest + ports: ["3001:3000"] + +# Log aggregation +loki: + image: grafana/loki:latest + ports: ["3100:3100"] +``` + +## RESOURCES:REQUIREMENTS +- **Total RAM**: 8GB (2+4+2) +- **Total Disk**: 90GB (20+50+20) +- **Network**: 1Gbps internal +- **Backup**: еженедельные снапшоты + +## BENEFITS:SELFHOSTED +✅ Полный контроль над кодом и инфраструктурой +✅ Нет зависимости от внешних сервисов +✅ Быстрая локальная сеть (Gigabit) +✅ Бесплатно (кроме электричества) +✅ Легкое масштабирование в Proxmox +✅ Интеграция с домашней сетью + +## MIGRATION:PATH +Existing code → Gitea → Actions → Registry → Portainer + +## FILES:CREATED +``` +infra/ +├── setup-lxc.sh # Автоматическое создание LXC +├── deploy-all.sh # Полное развертывание +├── gitea-compose.yml # Git сервер + DB +├── registry-compose.yml # Registry + UI + Watchtower +├── production-compose.yml # Portainer + Apps +└── README.md # Подробные инструкции +.gitea/workflows/ +└── deploy.yml # CI/CD pipeline +``` + +## USAGE:SUMMARY +1. **Один скрипт** развертывает всю инфраструктуру +2. **Настройка** заняет 15-20 минут +3. **Push код** → автоматический деплой +4. **Мониторинг** через веб интерфейсы +5. **Масштабирование** добавлением новых LXC \ No newline at end of file diff --git a/README.md b/README.md index 0bc43a0..e59fc8c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ -# mikrotik-bot +# Telegram-бот для мониторинга и управления MikroTik +## Описание +Бот для Telegram, который позволяет: +- Получать уведомления о новых Wi-Fi-подключениях, пропадании интернета, статистике +- Управлять устройствами (VLAN, блокировка, просмотр клиентов) +- Работать только через RouterOS API (минимум fetch-скриптов) + +## Архитектура +- Python 3.10+ +- aiogram (Telegram-бот) +- librouteros (интеграция с MikroTik API) +- Docker или virtualenv для изоляции + +## Быстрый старт (Docker) +1. Клонируй репозиторий или скопируй папку +2. Заполни .env (пример см. .env.example) +3. Собери и запусти контейнер: + ``` + docker build -t mikrotik-telegram-bot . + docker run --env-file .env --restart unless-stopped mikrotik-telegram-bot + ``` +4. Для автоматического обновления через Portainer используй Watchtower или настрой auto-pull (если репозиторий в GitHub/GitLab). + +## Быстрый старт (без Docker) +1. pip install -r requirements.txt +2. Заполни .env +3. python bot.py + +## Безопасность +- Токен Telegram и доступ к MikroTik храни только в .env +- Ограничь доступ к боту по user_id + +## Документация +- Все примеры и расширения смотри в файлах mikrotik_home/7step_telegram_bot.md и bot.py \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..cefcb91 --- /dev/null +++ b/bot.py @@ -0,0 +1,317 @@ +import os +import asyncio +from aiogram import Bot, Dispatcher, types +from aiogram.filters import Command +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from dotenv import load_dotenv +from librouteros import connect +from librouteros.exceptions import TrapError +from fastapi import FastAPI, Request +import uvicorn +from db import init_db, upsert_client, start_session, update_session, close_session, get_history, get_clients, get_stats + +load_dotenv() + +TG_BOT_TOKEN = os.getenv("TG_BOT_TOKEN") +MT_API_HOST = os.getenv("MT_API_HOST") +MT_API_USER = os.getenv("MT_API_USER") +MT_API_PASS = os.getenv("MT_API_PASS") +ALLOWED_USER_IDS = set(map(int, os.getenv("ALLOWED_USER_IDS", "").split(","))) + +bot = Bot(token=TG_BOT_TOKEN) +dp = Dispatcher() + +# Авторизация по user_id +async def check_user(message: types.Message) -> bool: + if message.from_user.id not in ALLOWED_USER_IDS: + await message.answer("⛔️ Нет доступа") + return False + return True + +# Подключение к MikroTik API + +def get_mt_api(): + return connect(username=MT_API_USER, password=MT_API_PASS, host=MT_API_HOST) + +@dp.message(Command("start")) +async def start_cmd(msg: types.Message): + if not await check_user(msg): + return + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="Клиенты Wi-Fi", callback_data="clients")] + ]) + await msg.answer("Добро пожаловать!", reply_markup=kb) + +@dp.callback_query(lambda c: c.data == "clients") +async def show_clients(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + try: + print("[DEBUG] Подключаюсь к MikroTik API...") + api = get_mt_api() + print("[DEBUG] Получаю список клиентов через /interface/wifi/registration-table...") + + # Правильный путь для RouterOS 7.x + clients = list(api.path('interface', 'wifi', 'registration-table')) + + print(f"[DEBUG] Найдено клиентов: {len(clients)}") + if not clients: + await callback.message.answer("Нет подключённых клиентов Wi-Fi.") + return + + text = "Wi-Fi клиенты:\n" + for i, c in enumerate(clients): + print(f"[DEBUG] Обрабатываю клиента {i}: type={type(c)}") + # Конвертируем Path объект в словарь + client_data = dict(c) + print(f"[DEBUG] Данные клиента: {client_data}") + + # Используем правильные поля из RouterOS 7.x + mac = client_data.get('mac-address', '-') + interface = client_data.get('interface', '-') + ssid = client_data.get('ssid', '-') + uptime = client_data.get('uptime', '-') + signal = client_data.get('signal', '-') + band = client_data.get('band', '-') + + text += f"\nMAC: {mac}\n" + text += f"Интерфейс: {interface}\n" + text += f"SSID: {ssid}\n" + text += f"Uptime: {uptime}\n" + text += f"Сигнал: {signal} | Диапазон: {band}\n" + + print("[DEBUG] Отправляю сообщение...") + await callback.message.answer(text, parse_mode="HTML") + except TrapError as e: + print(f"[ERROR] TrapError: {e}") + await callback.message.answer(f"Ошибка API MikroTik: {e}") + except Exception as e: + print(f"[ERROR] Exception: {e}") + print(f"[ERROR] Exception type: {type(e)}") + import traceback + traceback.print_exc() + await callback.message.answer(f"Ошибка: {e}") + +# --- FastAPI для fetch-уведомлений --- +app = FastAPI() + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "mikrotik-telegram-bot"} + +@app.get("/event") +async def event(request: Request): + mac = request.query_params.get('mac') + ip = request.query_params.get('ip') + event_type = request.query_params.get('type') + # Можно добавить секрет для безопасности: + # if request.query_params.get('secret') != os.getenv('MT_SECRET'): return 'forbidden' + text = f"MikroTik: событие {event_type}\nMAC: {mac}\nIP: {ip}" + # Сохраняем клиента и сессию (fetch) + await upsert_client(mac, name=None) + await start_session(mac, ip, source='fetch') + for uid in ALLOWED_USER_IDS: + await bot.send_message(uid, text) + return {"status": "ok"} + +# --- Периодический polling MikroTik API для сбора статистики --- +async def poll_mikrotik(): + known_clients = {} + while True: + try: + api = get_mt_api() + # Используем правильный путь для RouterOS 7.x + clients = list(api.path('interface', 'wifi', 'registration-table')) + current_macs = set() + for c in clients: + # Конвертируем Path объект в словарь + client_data = dict(c) + mac = client_data.get('mac-address') + # В RouterOS 7.x WiFi нет поля last-ip, используем другие поля + ip = client_data.get('last-ip', 'N/A') # возможно нет этого поля + name = client_data.get('host-name') or client_data.get('comment') + # В WiFi registration-table нет rx-bytes/tx-bytes, используем 0 + rx = 0 + tx = 0 + await upsert_client(mac, name=name) + await start_session(mac, ip, rx, tx, source='api') + await update_session(mac, rx, tx) + current_macs.add(mac) + # Закрываем сессии для клиентов, которых больше нет + for mac in list(known_clients): + if mac not in current_macs: + await close_session(mac) + known_clients.pop(mac) + known_clients = {mac: True for mac in current_macs} + except Exception as e: + print(f"[poll_mikrotik] Ошибка: {e}") + await asyncio.sleep(30) # интервал опроса + +# --- Одновременный запуск бота, FastAPI и polling --- +async def main(): + await init_db() + bot_task = asyncio.create_task(dp.start_polling(bot)) + config = uvicorn.Config(app, host="0.0.0.0", port=8000, log_level="info") + server = uvicorn.Server(config) + api_task = asyncio.create_task(server.serve()) + poll_task = asyncio.create_task(poll_mikrotik()) + await asyncio.gather(bot_task, api_task, poll_task) + +# --- Команда /history с постраничностью --- +@dp.message(Command("history")) +async def history_cmd(msg: types.Message): + if not await check_user(msg): + return + await send_history_page(msg, period_days=30, page=1) + +@dp.callback_query(lambda c: c.data.startswith("history_")) +async def history_period(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + parts = callback.data.split("_") + days = int(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 1 + await send_history_page(callback.message, period_days=days, page=page, edit=True, callback=callback) + +async def send_history_page(message, period_days, page, edit=False, callback=None): + page_size = 10 + rows, total = await get_history(period_days=period_days, page=page, page_size=page_size) + max_page = max(1, (total + page_size - 1) // page_size) + text = f"История подключений за {period_days} дн. (стр. {page}/{max_page}):\n" + if not rows: + text += "Нет подключений за выбранный период." + else: + for row in rows: + mac = row[1] + ip = row[2] + start = row[3] + end = row[4] or "..." + rx = row[5] or 0 + tx = row[6] or 0 + name = row[-2] or row[-1] or mac + text += f"\n{name} ({mac})\nIP: {ip}\nВремя: {start} — {end}\nRX: {rx//1024} KB, TX: {tx//1024} KB\n" + # Кнопки: Назад, Обновить, Вперёд (одна строка) + buttons = [] + if page > 1: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data=f"history_{period_days}_{page-1}")) + else: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data="noop", disabled=True)) + buttons.append(InlineKeyboardButton("Обновить", callback_data=f"history_{period_days}_{page}")) + if page < max_page: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data=f"history_{period_days}_{page+1}")) + else: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data="noop", disabled=True)) + kb = InlineKeyboardMarkup(inline_keyboard=[buttons]) + if edit and callback: + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb) + await callback.answer() + else: + await message.answer(text, parse_mode="HTML", reply_markup=kb) + +# --- Команда /clients с постраничностью --- +@dp.message(Command("clients")) +async def clients_cmd(msg: types.Message): + if not await check_user(msg): + return + await send_clients_page(msg, page=1) + +@dp.callback_query(lambda c: c.data.startswith("clients_")) +async def clients_page_cb(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + parts = callback.data.split("_") + page = int(parts[1]) if len(parts) > 1 else 1 + await send_clients_page(callback.message, page=page, edit=True, callback=callback) + +async def send_clients_page(message, page, edit=False, callback=None): + page_size = 10 + rows, total = await get_clients(page=page, page_size=page_size) + max_page = max(1, (total + page_size - 1) // page_size) + text = f"Устройства (стр. {page}/{max_page}):\n" + if not rows: + text += "Нет известных клиентов." + else: + for row in rows: + mac = row[0] + name = row[1] or mac + custom = row[2] + last_seen = row[3] + text += f"\n{custom or name} ({mac})\nПоследняя активность: {last_seen}\n" + # Кнопки: Назад, Обновить, Вперёд (одна строка) + buttons = [] + if page > 1: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data=f"clients_{page-1}")) + else: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data="noop", disabled=True)) + buttons.append(InlineKeyboardButton("Обновить", callback_data=f"clients_{page}")) + if page < max_page: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data=f"clients_{page+1}")) + else: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data="noop", disabled=True)) + kb = InlineKeyboardMarkup(inline_keyboard=[buttons]) + if edit and callback: + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb) + await callback.answer() + else: + await message.answer(text, parse_mode="HTML", reply_markup=kb) + +# --- Обработчик для неактивных кнопок --- +@dp.callback_query(lambda c: c.data == "noop") +async def noop_callback(callback: types.CallbackQuery): + await callback.answer() + +# --- Команда /stats с постраничностью --- +@dp.message(Command("stats")) +async def stats_cmd(msg: types.Message): + if not await check_user(msg): + return + await send_stats_page(msg, period_days=30, page=1) + +@dp.callback_query(lambda c: c.data.startswith("stats_")) +async def stats_period(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + parts = callback.data.split("_") + days = int(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 1 + await send_stats_page(callback.message, period_days=days, page=page, edit=True, callback=callback) + +async def send_stats_page(message, period_days, page, edit=False, callback=None): + page_size = 10 + rows, total = await get_stats(period_days=period_days, page=page, page_size=page_size) + max_page = max(1, (total + page_size - 1) // page_size) + text = f"Топ по трафику за {period_days} дн. (стр. {page}/{max_page}):\n" + if not rows: + text += "Нет данных за выбранный период." + else: + for row in rows: + mac = row[0] + name = row[1] or row[2] or mac + rx = row[3] or 0 + tx = row[4] or 0 + count = row[5] + text += f"\n{name} ({mac})\nСессий: {count}\nRX: {rx//1024} KB, TX: {tx//1024} KB, Всего: {(rx+tx)//1024} KB\n" + # Кнопки: Назад, Обновить, Вперёд (одна строка) + buttons = [] + if page > 1: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data=f"stats_{period_days}_{page-1}")) + else: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data="noop", disabled=True)) + buttons.append(InlineKeyboardButton("Обновить", callback_data=f"stats_{period_days}_{page}")) + if page < max_page: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data=f"stats_{period_days}_{page+1}")) + else: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data="noop", disabled=True)) + kb = InlineKeyboardMarkup(inline_keyboard=[buttons]) + if edit and callback: + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb) + await callback.answer() + else: + await message.answer(text, parse_mode="HTML", reply_markup=kb) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/bot_original.py b/bot_original.py new file mode 100644 index 0000000..53e87e8 --- /dev/null +++ b/bot_original.py @@ -0,0 +1,278 @@ +import os +import asyncio +from aiogram import Bot, Dispatcher, types +from aiogram.filters import Command +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from dotenv import load_dotenv +from librouteros import connect +from librouteros.exceptions import TrapError +from fastapi import FastAPI, Request +import uvicorn +from db import init_db, upsert_client, start_session, update_session, close_session, get_history, get_clients, get_stats + +load_dotenv() + +TG_BOT_TOKEN = os.getenv("TG_BOT_TOKEN") +MT_API_HOST = os.getenv("MT_API_HOST") +MT_API_USER = os.getenv("MT_API_USER") +MT_API_PASS = os.getenv("MT_API_PASS") +ALLOWED_USER_IDS = set(map(int, os.getenv("ALLOWED_USER_IDS", "").split(","))) + +bot = Bot(token=TG_BOT_TOKEN) +dp = Dispatcher() + +# Авторизация по user_id +async def check_user(message: types.Message) -> bool: + if message.from_user.id not in ALLOWED_USER_IDS: + await message.answer("⛔️ Нет доступа") + return False + return True + +# Подключение к MikroTik API + +def get_mt_api(): + return connect(username=MT_API_USER, password=MT_API_PASS, host=MT_API_HOST) + +@dp.message(Command("start")) +async def start_cmd(msg: types.Message): + if not await check_user(msg): + return + kb = InlineKeyboardMarkup() + kb.add(InlineKeyboardButton("Клиенты Wi-Fi", callback_data="clients")) + await msg.answer("Добро пожаловать!", reply_markup=kb) + +@dp.callback_query(lambda c: c.data == "clients") +async def show_clients(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + try: + api = get_mt_api() + clients = api.path('caps-man', 'registration-table').get() + if not clients: + await callback.message.answer("Нет подключённых клиентов Wi-Fi.") + return + text = "Wi-Fi клиенты:\n" + for c in clients: + text += f"\nMAC: {c.get('mac-address')} | IP: {c.get('last-ip', '-')} +VLAN: {c.get('vlan-id', '-')} | AP: {c.get('interface', '-')} +Uptime: {c.get('uptime', '-')}\n" + await callback.message.answer(text, parse_mode="HTML") + except TrapError as e: + await callback.message.answer(f"Ошибка API MikroTik: {e}") + except Exception as e: + await callback.message.answer(f"Ошибка: {e}") + +# --- FastAPI для fetch-уведомлений --- +app = FastAPI() + +@app.get("/event") +async def event(request: Request): + mac = request.query_params.get('mac') + ip = request.query_params.get('ip') + event_type = request.query_params.get('type') + # Можно добавить секрет для безопасности: + # if request.query_params.get('secret') != os.getenv('MT_SECRET'): return 'forbidden' + text = f"MikroTik: событие {event_type}\nMAC: {mac}\nIP: {ip}" + # Сохраняем клиента и сессию (fetch) + await upsert_client(mac, name=None) + await start_session(mac, ip, source='fetch') + for uid in ALLOWED_USER_IDS: + await bot.send_message(uid, text) + return {"status": "ok"} + +# --- Периодический polling MikroTik API для сбора статистики --- +async def poll_mikrotik(): + known_clients = {} + while True: + try: + api = get_mt_api() + clients = list(api.path('caps-man', 'registration-table').get()) + current_macs = set() + for c in clients: + mac = c.get('mac-address') + ip = c.get('last-ip', '-') + name = c.get('host-name') or c.get('comment') + rx = int(c.get('rx-bytes', 0)) + tx = int(c.get('tx-bytes', 0)) + await upsert_client(mac, name=name) + await start_session(mac, ip, rx, tx, source='api') + await update_session(mac, rx, tx) + current_macs.add(mac) + # Закрываем сессии для клиентов, которых больше нет + for mac in list(known_clients): + if mac not in current_macs: + await close_session(mac) + known_clients.pop(mac) + known_clients = {mac: True for mac in current_macs} + except Exception as e: + print(f"[poll_mikrotik] Ошибка: {e}") + await asyncio.sleep(30) # интервал опроса + +# --- Одновременный запуск бота, FastAPI и polling --- +async def main(): + await init_db() + bot_task = asyncio.create_task(dp.start_polling(bot)) + config = uvicorn.Config(app, host="0.0.0.0", port=8000, log_level="info") + server = uvicorn.Server(config) + api_task = asyncio.create_task(server.serve()) + poll_task = asyncio.create_task(poll_mikrotik()) + await asyncio.gather(bot_task, api_task, poll_task) + +# --- Команда /history с постраничностью --- +@dp.message(Command("history")) +async def history_cmd(msg: types.Message): + if not await check_user(msg): + return + await send_history_page(msg, period_days=30, page=1) + +@dp.callback_query(lambda c: c.data.startswith("history_")) +async def history_period(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + parts = callback.data.split("_") + days = int(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 1 + await send_history_page(callback.message, period_days=days, page=page, edit=True, callback=callback) + +async def send_history_page(message, period_days, page, edit=False, callback=None): + page_size = 10 + rows, total = await get_history(period_days=period_days, page=page, page_size=page_size) + max_page = max(1, (total + page_size - 1) // page_size) + text = f"История подключений за {period_days} дн. (стр. {page}/{max_page}):\n" + if not rows: + text += "Нет подключений за выбранный период." + else: + for row in rows: + mac = row[1] + ip = row[2] + start = row[3] + end = row[4] or "..." + rx = row[5] or 0 + tx = row[6] or 0 + name = row[-2] or row[-1] or mac + text += f"\n{name} ({mac})\nIP: {ip}\nВремя: {start} — {end}\nRX: {rx//1024} KB, TX: {tx//1024} KB\n" + # Кнопки: Назад, Обновить, Вперёд (одна строка) + buttons = [] + if page > 1: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data=f"history_{period_days}_{page-1}")) + else: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data="noop", disabled=True)) + buttons.append(InlineKeyboardButton("Обновить", callback_data=f"history_{period_days}_{page}")) + if page < max_page: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data=f"history_{period_days}_{page+1}")) + else: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data="noop", disabled=True)) + kb = InlineKeyboardMarkup(inline_keyboard=[buttons]) + if edit and callback: + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb) + await callback.answer() + else: + await message.answer(text, parse_mode="HTML", reply_markup=kb) + +# --- Команда /clients с постраничностью --- +@dp.message(Command("clients")) +async def clients_cmd(msg: types.Message): + if not await check_user(msg): + return + await send_clients_page(msg, page=1) + +@dp.callback_query(lambda c: c.data.startswith("clients_")) +async def clients_page_cb(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + parts = callback.data.split("_") + page = int(parts[1]) if len(parts) > 1 else 1 + await send_clients_page(callback.message, page=page, edit=True, callback=callback) + +async def send_clients_page(message, page, edit=False, callback=None): + page_size = 10 + rows, total = await get_clients(page=page, page_size=page_size) + max_page = max(1, (total + page_size - 1) // page_size) + text = f"Устройства (стр. {page}/{max_page}):\n" + if not rows: + text += "Нет известных клиентов." + else: + for row in rows: + mac = row[0] + name = row[1] or mac + custom = row[2] + last_seen = row[3] + text += f"\n{custom or name} ({mac})\nПоследняя активность: {last_seen}\n" + # Кнопки: Назад, Обновить, Вперёд (одна строка) + buttons = [] + if page > 1: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data=f"clients_{page-1}")) + else: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data="noop", disabled=True)) + buttons.append(InlineKeyboardButton("Обновить", callback_data=f"clients_{page}")) + if page < max_page: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data=f"clients_{page+1}")) + else: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data="noop", disabled=True)) + kb = InlineKeyboardMarkup(inline_keyboard=[buttons]) + if edit and callback: + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb) + await callback.answer() + else: + await message.answer(text, parse_mode="HTML", reply_markup=kb) + +# --- Обработчик для неактивных кнопок --- +@dp.callback_query(lambda c: c.data == "noop") +async def noop_callback(callback: types.CallbackQuery): + await callback.answer() + +# --- Команда /stats с постраничностью --- +@dp.message(Command("stats")) +async def stats_cmd(msg: types.Message): + if not await check_user(msg): + return + await send_stats_page(msg, period_days=30, page=1) + +@dp.callback_query(lambda c: c.data.startswith("stats_")) +async def stats_period(callback: types.CallbackQuery): + if callback.from_user.id not in ALLOWED_USER_IDS: + await callback.answer("⛔️ Нет доступа", show_alert=True) + return + parts = callback.data.split("_") + days = int(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 1 + await send_stats_page(callback.message, period_days=days, page=page, edit=True, callback=callback) + +async def send_stats_page(message, period_days, page, edit=False, callback=None): + page_size = 10 + rows, total = await get_stats(period_days=period_days, page=page, page_size=page_size) + max_page = max(1, (total + page_size - 1) // page_size) + text = f"Топ по трафику за {period_days} дн. (стр. {page}/{max_page}):\n" + if not rows: + text += "Нет данных за выбранный период." + else: + for row in rows: + mac = row[0] + name = row[1] or row[2] or mac + rx = row[3] or 0 + tx = row[4] or 0 + count = row[5] + text += f"\n{name} ({mac})\nСессий: {count}\nRX: {rx//1024} KB, TX: {tx//1024} KB, Всего: {(rx+tx)//1024} KB\n" + # Кнопки: Назад, Обновить, Вперёд (одна строка) + buttons = [] + if page > 1: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data=f"stats_{period_days}_{page-1}")) + else: + buttons.append(InlineKeyboardButton("⏪ Назад", callback_data="noop", disabled=True)) + buttons.append(InlineKeyboardButton("Обновить", callback_data=f"stats_{period_days}_{page}")) + if page < max_page: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data=f"stats_{period_days}_{page+1}")) + else: + buttons.append(InlineKeyboardButton("Вперёд ⏩", callback_data="noop", disabled=True)) + kb = InlineKeyboardMarkup(inline_keyboard=[buttons]) + if edit and callback: + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb) + await callback.answer() + else: + await message.answer(text, parse_mode="HTML", reply_markup=kb) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..2f9849a --- /dev/null +++ b/db.py @@ -0,0 +1,121 @@ +import aiosqlite +import asyncio +from datetime import datetime + +DB_PATH = 'mikrotik_bot.db' + +CREATE_CLIENTS = ''' +CREATE TABLE IF NOT EXISTS clients ( + mac TEXT PRIMARY KEY, + name TEXT, + custom_name TEXT, + last_seen DATETIME +); +''' + +CREATE_SESSIONS = ''' +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT, + ip TEXT, + start_time DATETIME, + end_time DATETIME, + rx_bytes INTEGER, + tx_bytes INTEGER, + source TEXT, + last_update DATETIME, + FOREIGN KEY(mac) REFERENCES clients(mac) +); +''' + +async def init_db(): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(CREATE_CLIENTS) + await db.execute(CREATE_SESSIONS) + await db.commit() + +async def upsert_client(mac, name=None, custom_name=None): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(''' + INSERT INTO clients (mac, name, custom_name, last_seen) + VALUES (?, ?, ?, ?) + ON CONFLICT(mac) DO UPDATE SET + name=excluded.name, + last_seen=excluded.last_seen + ''', (mac, name, custom_name, datetime.now())) + await db.commit() + +async def start_session(mac, ip, rx_bytes=0, tx_bytes=0, source='api'): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(''' + INSERT INTO sessions (mac, ip, start_time, rx_bytes, tx_bytes, source, last_update) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', (mac, ip, datetime.now(), rx_bytes, tx_bytes, source, datetime.now())) + await db.commit() + +async def update_session(mac, rx_bytes, tx_bytes): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(''' + UPDATE sessions SET rx_bytes=?, tx_bytes=?, last_update=? + WHERE mac=? AND end_time IS NULL + ''', (rx_bytes, tx_bytes, datetime.now(), mac)) + await db.commit() + +async def close_session(mac): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(''' + UPDATE sessions SET end_time=? + WHERE mac=? AND end_time IS NULL + ''', (datetime.now(), mac)) + await db.commit() + +async def get_history(period_days=30, page=1, page_size=10): + async with aiosqlite.connect(DB_PATH) as db: + since = datetime.now().timestamp() - period_days*86400 + offset = (page - 1) * page_size + async with db.execute(''' + SELECT s.*, c.name, c.custom_name FROM sessions s + LEFT JOIN clients c ON s.mac = c.mac + WHERE s.start_time > datetime(?, 'unixepoch') + ORDER BY s.start_time DESC + LIMIT ? OFFSET ? + ''', (since, page_size, offset)) as cursor: + rows = await cursor.fetchall() + # Получаем общее количество + async with db.execute(''' + SELECT COUNT(*) FROM sessions WHERE start_time > datetime(?, 'unixepoch') + ''', (since,)) as cursor: + total = (await cursor.fetchone())[0] + return rows, total + +async def get_clients(page=1, page_size=10): + async with aiosqlite.connect(DB_PATH) as db: + offset = (page - 1) * page_size + async with db.execute(''' + SELECT * FROM clients ORDER BY last_seen DESC LIMIT ? OFFSET ? + ''', (page_size, offset)) as cursor: + rows = await cursor.fetchall() + async with db.execute('SELECT COUNT(*) FROM clients') as cursor: + total = (await cursor.fetchone())[0] + return rows, total + +async def get_stats(period_days=30, page=1, page_size=10): + async with aiosqlite.connect(DB_PATH) as db: + since = datetime.now().timestamp() - period_days*86400 + offset = (page - 1) * page_size + async with db.execute(''' + SELECT s.mac, c.name, c.custom_name, SUM(s.rx_bytes), SUM(s.tx_bytes), COUNT(s.id) + FROM sessions s + LEFT JOIN clients c ON s.mac = c.mac + WHERE s.start_time > datetime(?, 'unixepoch') + GROUP BY s.mac + ORDER BY SUM(s.rx_bytes + s.tx_bytes) DESC + LIMIT ? OFFSET ? + ''', (since, page_size, offset)) as cursor: + rows = await cursor.fetchall() + # Получаем общее количество клиентов за период + async with db.execute(''' + SELECT COUNT(DISTINCT mac) FROM sessions WHERE start_time > datetime(?, 'unixepoch') + ''', (since,)) as cursor: + total = (await cursor.fetchone())[0] + return rows, total \ No newline at end of file diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml new file mode 100644 index 0000000..f4ce3bb --- /dev/null +++ b/docker-compose.portainer.yml @@ -0,0 +1,44 @@ +version: '3.8' +services: + telegram-bot: + build: . + container_name: mikrotik-telegram-bot + env_file: + - .env + restart: unless-stopped + volumes: + - ./data:/app/data + - /var/run/docker.sock:/var/run/docker.sock:ro # для watchtower + labels: + - "com.centurylinklabs.watchtower.enable=true" + - "com.centurylinklabs.watchtower.scope=mikrotik-bot" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - mikrotik-net + + # Автообновление контейнеров при изменении кода + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower-mikrotik + restart: unless-stopped + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_POLL_INTERVAL=300 # проверка каждые 5 мин + - WATCHTOWER_SCOPE=mikrotik-bot + - WATCHTOWER_INCLUDE_STOPPED=true + - WATCHTOWER_REVIVE_STOPPED=true + volumes: + - /var/run/docker.sock:/var/run/docker.sock + labels: + - "com.centurylinklabs.watchtower.scope=mikrotik-bot" + networks: + - mikrotik-net + +networks: + mikrotik-net: + driver: bridge \ No newline at end of file diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml new file mode 100644 index 0000000..0b46134 --- /dev/null +++ b/docker-compose.registry.yml @@ -0,0 +1,36 @@ +version: '3.8' +services: + telegram-bot: + # image: ghcr.io/YOUR_USERNAME/mikrotik-telegram-bot:latest + # Замени на твой registry, пока используем build локально + build: + context: . + dockerfile: Dockerfile + container_name: mikrotik-telegram-bot + env_file: + - .env + restart: unless-stopped + volumes: + - ./data:/app/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - mikrotik-net + labels: + # Для Watchtower если используется + - "com.centurylinklabs.watchtower.enable=true" + # Для Portainer webhook + - "io.portainer.accesscontrol.teams.mikrotik-bot" + +networks: + mikrotik-net: + driver: bridge + +# Примечание: для полного CI/CD нужно: +# 1. Создать .github/workflows/docker.yml для автосборки +# 2. Настроить webhook в Portainer +# 3. Использовать image вместо build \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c446f2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' +services: + telegram-bot: + build: . + container_name: mikrotik-telegram-bot + env_file: + - .env + restart: unless-stopped + volumes: + - ./data:/app/data # Сохранение данных бота (например, SQLite база) + healthcheck: + # Используем curl, который установлен в Dockerfile + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s # Даем время на запуск + # Если боту нужно общаться с роутером по IP в локальной сети: + # network_mode: "host" + # Или настройте пользовательскую bridge-сеть Docker, если требуется + # networks: + # - my_local_network + +# Пример определения пользовательской сети (если не используется host) +# networks: +# my_local_network: +# driver: bridge +# ipam: +# config: +# - subnet: 172.20.0.0/16 # Пример подсети + + # Если нужно пробросить прокси/порт, добавь: + # ports: + # - "8080:8080" \ No newline at end of file diff --git a/infra/DEPLOYMENT_STATUS.md b/infra/DEPLOYMENT_STATUS.md new file mode 100644 index 0000000..df25ef9 --- /dev/null +++ b/infra/DEPLOYMENT_STATUS.md @@ -0,0 +1,87 @@ +# 🚀 DevOps Infrastructure Deployment Status + +## ✅ Deployed Services (Phase 1) + +| Service | Container ID | IP | Ports | Status | URL | Notes | +|---------|-------------|-----|-------|--------|-----|-------| +| **Traefik** | Auto | 10.10.30.18 | 8080 | ✅ Running | http://10.10.30.18:8080 | Gateway/Reverse Proxy | +| **Gitea** | 101 | 10.10.30.120 | 3000 | ✅ Running | http://10.10.30.120:3000 | Git + CI/CD + Packages (SQLite) | +| **Docker Registry** | b1e155e920e6 | 10.10.30.121 | 5000 | ✅ Running | http://10.10.30.121:5000 | Private Docker images | +| **Registry UI** | Auto | 10.10.30.121 | 8080 | ✅ Running | http://10.10.30.121:8080 | Registry web interface | +| **Portainer** | 065fd8cfa26b | 10.10.30.121 | 9000 | ✅ Running | http://10.10.30.121:9000 | Docker management | +| **Portainer Agent** | 3a2831b9a481 | 10.10.30.121 | 9001 | ✅ Running | - | Docker agent | +| **Vault** | Auto | 10.10.30.121 | 8200 | ✅ Running | http://10.10.30.121:8200 | Secrets management (token: myroot) | + +## 🔧 Working Docker Commands + +### Registry with CORS fix: +```bash +docker run -d \ + --name registry \ + --restart=always \ + -p 5000:5000 \ + -v /opt/registry-data:/var/lib/registry \ + -e REGISTRY_HTTP_HEADERS_ACCESS-CONTROL-ALLOW-ORIGIN='[http://10.10.30.121:8080]' \ + -e REGISTRY_HTTP_HEADERS_ACCESS-CONTROL-ALLOW-METHODS='[HEAD,GET,OPTIONS,DELETE]' \ + -e REGISTRY_HTTP_HEADERS_ACCESS-CONTROL-ALLOW-CREDENTIALS='[true]' \ + -e REGISTRY_HTTP_HEADERS_ACCESS-CONTROL-ALLOW-HEADERS='[Authorization,Accept,Cache-Control]' \ + registry:2.8 +``` + +### Registry UI: +```bash +docker run -d \ + --name registry-ui \ + --restart=always \ + -p 8080:80 \ + -e REGISTRY_TITLE="DevOps Docker Registry" \ + -e REGISTRY_URL="http://10.10.30.121:5000" \ + -e REGISTRY_SINGLE="true" \ + joxit/docker-registry-ui:latest +``` + +### Vault (dev mode): +```bash +docker run -d \ + --name vault \ + --restart=always \ + -p 8200:8200 \ + -e VAULT_DEV=1 \ + -e VAULT_DEV_ROOT_TOKEN_ID=myroot \ + hashicorp/vault:latest \ + vault server -dev -dev-listen-address="0.0.0.0:8200" +``` + +## ⏳ Next Phase Services (To Deploy) + +| Service | Planned IP | Ports | Purpose | +|---------|-----------|-------|---------| +| **Nexus** | 10.10.30.40 | 8081 | Artifacts, PyPI/npm proxy | +| **Prometheus** | 10.10.30.70 | 9090 | Metrics collection | +| **Grafana** | 10.10.30.80 | 3000 | Monitoring dashboards | + +## 🎯 Target Project: MikroTik Telegram Bot + +**Source:** `mikrotik_home/telegram_bot/` +**Goal:** Full CI/CD pipeline for auto-deployment + +## 📊 Infrastructure Resources + +**Proxmox Host:** +- RAM: 62GB (plenty available) +- CPU: 16 cores +- Network: 10.10.30.0/24 (ProxmoxLAN) +- Gateway: 10.10.30.1 + +**Current Usage:** ~7GB RAM, ~12 CPU cores + +## 🔑 Key Learnings + +1. **SQLite better than MySQL** for Gitea in LXC +2. **CORS headers required** for Registry UI +3. **Community-scripts work well** for base deployments +4. **Portainer Agent vs Server** distinction important +5. **Registry UI better than Portainer registries** (free) +6. **SonarQube requires PostgreSQL** - complex for MVP +7. **Vault dev mode perfect** for testing +8. **Gitea packages eliminate need** for separate Nexus \ No newline at end of file diff --git a/infra/ENTERPRISE_ARCHITECTURE.md b/infra/ENTERPRISE_ARCHITECTURE.md new file mode 100644 index 0000000..01fad4a --- /dev/null +++ b/infra/ENTERPRISE_ARCHITECTURE.md @@ -0,0 +1,172 @@ +# Enterprise DevOps Infrastructure +## Архитектура для 15+ проектов на Proxmox + +### 🏗️ Общая схема +``` +Internet + ↓ +[Traefik LB/SSL] ← git.domain.com, registry.domain.com, vault.domain.com + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Proxmox Infrastructure │ +├─────────────────────────────────────────────────────────────────┤ +│ Core Services Network: 10.10.0.0/24 │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Traefik │ │ Gitea │ │ Registry │ │ +│ │ 10.10.0.10 │ │ 10.10.0.20 │ │ 10.10.0.30 │ │ +│ │ (Gateway) │ │ (Git+CI/CD) │ │ (Docker Hub) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Nexus │ │ Vault │ │ SonarQube │ │ +│ │ 10.10.0.40 │ │ 10.10.0.50 │ │ 10.10.0.60 │ │ +│ │ (Artifacts) │ │ (Secrets) │ │ (Quality) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Prometheus │ │ Grafana │ │ +│ │ 10.10.0.70 │ │ 10.10.0.80 │ │ +│ │ (Metrics) │ │ (Dashboard) │ │ +│ └──────────────┘ └──────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ Production Apps Network: 10.10.1.0/24 │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ MikroTik Bot │ │ Project2 │ │ Project3 │ │ +│ │ 10.10.1.10 │ │ 10.10.1.20 │ │ 10.10.1.30 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ... до 15 проектов │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 📊 Спецификации LXC контейнеров + +| Сервис | IP | RAM | CPU | Disk | Описание | +|--------|-----|-----|-----|------|----------| +| **Traefik** | 10.10.0.10 | 1GB | 2 | 10GB | Reverse proxy, SSL, маршрутизация | +| **Gitea** | 10.10.0.20 | 4GB | 4 | 50GB | Git repos, CI/CD, PostgreSQL | +| **Registry** | 10.10.0.30 | 2GB | 2 | 100GB | Docker images storage | +| **Nexus** | 10.10.0.40 | 6GB | 4 | 200GB | Artifacts, proxy repos | +| **Vault** | 10.10.0.50 | 2GB | 2 | 20GB | Secrets management | +| **SonarQube** | 10.10.0.60 | 4GB | 4 | 30GB | Code quality, PostgreSQL | +| **Prometheus** | 10.10.0.70 | 4GB | 2 | 50GB | Metrics collection | +| **Grafana** | 10.10.0.80 | 2GB | 2 | 20GB | Monitoring dashboards | +| **Apps 1-15** | 10.10.1.x | 1-4GB | 1-2 | 10-30GB | Production applications | + +**Итого Core**: ~25GB RAM, 24 CPU, 480GB Disk +**Итого Apps**: ~30GB RAM, 22 CPU, 300GB Disk +**Общий бюджет**: ~55GB RAM, 46 CPU, 780GB Disk + +### 🔄 CI/CD Workflow + +```mermaid +graph LR + A[git push] --> B[Gitea Actions] + B --> C[SonarQube Scan] + C --> D[Docker Build] + D --> E[Nexus Dependencies] + E --> F[Registry Push] + F --> G[Vault Secrets] + G --> H[Deploy to Prod] + H --> I[Prometheus Metrics] +``` + +### 🌐 External Access (Traefik Routes) + +| Service | URL | Auth | +|---------|-----|------| +| Gitea | `git.yourdomain.com` | Gitea Auth | +| Registry UI | `registry.yourdomain.com` | Basic Auth | +| Nexus | `nexus.yourdomain.com` | Nexus Auth | +| Vault | `vault.yourdomain.com` | Vault Auth | +| SonarQube | `sonar.yourdomain.com` | Sonar Auth | +| Grafana | `monitoring.yourdomain.com` | Grafana Auth | +| Apps | `app1.yourdomain.com` | App-specific | + +### 🔧 Технологический стек + +**Core Infrastructure:** +- **Traefik 3.0**: HTTP router, SSL automation, load balancing +- **Gitea 1.21**: Git hosting, Actions CI/CD, issue tracking +- **Docker Registry 2.8**: Image storage с UI (registry-ui) +- **Nexus OSS 3.45**: PyPI/npm/Maven proxy, vulnerability scanning +- **Vault 1.15**: Secrets management, dynamic secrets +- **SonarQube CE 10.3**: Code quality, security analysis + +**Monitoring Stack:** +- **Prometheus 2.48**: Metrics collection +- **Grafana 10.2**: Visualization, alerting +- **Node Exporter**: System metrics +- **cAdvisor**: Container metrics + +**Security & Automation:** +- **Watchtower**: Auto-updates production containers +- **Let's Encrypt**: Automatic SSL certificates +- **Fail2ban**: Intrusion prevention +- **UFW**: Firewall management + +### 📁 Project Structure Template +``` +project-name/ +├── .gitea/ +│ └── workflows/ +│ ├── build.yml # Build & test +│ ├── quality.yml # SonarQube scan +│ └── deploy.yml # Deploy to production +├── docker/ +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── docker-compose.prod.yml +├── infra/ +│ ├── vault-secrets.yml # Vault integration +│ ├── monitoring.yml # Prometheus config +│ └── deployment.yml # Production deployment +├── src/ # Application code +├── tests/ # Test suite +├── requirements.txt # Dependencies (via Nexus) +├── sonar-project.properties # SonarQube config +└── README.md +``` + +### 🚀 Deployment Phases + +**Phase 1: Core Infrastructure** +1. Setup Traefik (gateway) +2. Deploy Gitea + PostgreSQL +3. Configure Docker Registry +4. Setup basic monitoring + +**Phase 2: DevOps Tools** +1. Deploy Nexus Repository +2. Setup Vault secrets +3. Configure SonarQube +4. Integrate CI/CD pipelines + +**Phase 3: Production** +1. Migrate MikroTik bot +2. Setup monitoring alerts +3. Configure auto-deployments +4. Documentation & training + +### 💡 Key Benefits + +**For Developers:** +- 🔄 Автоматический CI/CD из коробки +- 📦 Быстрые сборки через Nexus cache +- 🛡️ Безопасность через Vault + SonarQube +- 📊 Мониторинг production apps + +**For Operations:** +- 🔒 Централизованное управление секретами +- 📈 Полная observability +- 🚀 Zero-downtime deployments +- 💾 Backup & disaster recovery + +**For Business:** +- 💰 Экономия на внешних SaaS +- ⚡ Быстрая разработка новых проектов +- 🔐 Контроль над данными +- 📊 Метрики качества кода + +Готов начать с автоматизированного развертывания? \ No newline at end of file diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..ed42841 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,191 @@ +# CI/CD Infrastructure for MikroTik Telegram Bot + +Полноценная CI/CD инфраструктура на базе Proxmox для автоматического развертывания MikroTik Telegram Bot. + +## 🏗️ Архитектура + +``` +Developer → Gitea → Actions → Registry → Watchtower → Production +``` + +### Компоненты: +- **Git Server (10.10.0.10)**: Gitea + PostgreSQL + Actions Runner +- **Registry (10.10.0.20)**: Docker Registry + UI + Watchtower +- **Production (10.10.0.30)**: Portainer + MikroTik Bot + Watchtower + +## 🚀 Быстрый старт + +### 1. Развертывание инфраструктуры +```bash +cd infra/ +chmod +x *.sh +./deploy-all.sh +``` + +### 2. Первичная настройка + +#### Gitea (http://10.10.0.10:3000) +1. Создать админ аккаунт +2. Создать репозиторий `mikrotik-bot` +3. Получить Actions runner token: + - Site Administration → Actions → Runners + - Generate token +4. Обновить токен в контейнере: + ```bash + pct exec 100 -- bash -c "cd /opt/gitea && echo 'RUNNER_TOKEN=your_token' > .env" + pct exec 100 -- docker-compose restart runner + ``` + +#### Portainer (http://10.10.0.30:9000) +1. Создать админ аккаунт +2. Настроить webhook для автодеплоя +3. Обновить bot.env с реальными данными + +### 3. Настройка проекта +```bash +git clone http://10.10.0.10:3000/your-user/mikrotik-bot.git +cd mikrotik-bot +cp -r .gitea/ ./ # скопировать workflow файлы +git add . +git commit -m "Add CI/CD pipeline" +git push origin main +``` + +## 📁 Структура файлов + +``` +infra/ +├── setup-lxc.sh # Создание LXC контейнеров +├── deploy-all.sh # Полное развертывание +├── gitea-compose.yml # Git сервер +├── registry-compose.yml # Docker Registry +├── production-compose.yml # Production окружение +└── README.md # Эта инструкция +.gitea/workflows/ +└── deploy.yml # CI/CD pipeline +``` + +## 🔄 Workflow процесс + +1. **Push** в main ветку +2. **Gitea Actions** запускает pipeline +3. **Build** Docker образа +4. **Push** в private registry +5. **Watchtower** обнаруживает новый образ +6. **Auto-deploy** в production + +## 🛠️ Ручное управление + +### Просмотр логов +```bash +# Gitea logs +pct exec 100 -- docker logs gitea + +# Registry logs +pct exec 101 -- docker logs docker-registry + +# Bot logs +pct exec 102 -- docker logs mikrotik-telegram-bot +``` + +### Перезапуск сервисов +```bash +# Restart Git server +pct exec 100 -- bash -c "cd /opt/gitea && docker-compose restart" + +# Restart Registry +pct exec 101 -- bash -c "cd /opt/registry && docker-compose restart" + +# Restart Production +pct exec 102 -- bash -c "cd /opt/production && docker-compose restart" +``` + +### Обновление конфигурации бота +```bash +pct exec 102 -- nano /opt/production/bot.env +pct exec 102 -- bash -c "cd /opt/production && docker-compose restart mikrotik-bot" +``` + +## 🔍 Мониторинг + +### Проверка статуса +- Git Server: http://10.10.0.10:3000 +- Registry UI: http://10.10.0.20:5001 +- Portainer: http://10.10.0.30:9000 +- Bot Health: http://10.10.0.30:8000/health + +### Health checks +```bash +curl http://10.10.0.10:3000/api/v1/version +curl http://10.10.0.20:5000/v2/_catalog +curl http://10.10.0.30:9000/api/status +curl http://10.10.0.30:8000/health +``` + +## 🔧 Troubleshooting + +### Проблемы с registry +```bash +# Проверить конфигурацию insecure registry +pct exec 101 -- cat /etc/docker/daemon.json +pct exec 102 -- cat /etc/docker/daemon.json + +# Перезапуск Docker +pct exec 101 -- systemctl restart docker +pct exec 102 -- systemctl restart docker +``` + +### Проблемы с Actions Runner +```bash +# Проверить статус runner +pct exec 100 -- docker logs gitea-runner + +# Перегенерировать токен в Gitea UI +# Обновить .env файл +pct exec 100 -- bash -c "cd /opt/gitea && docker-compose restart runner" +``` + +### Проблемы с автодеплоем +```bash +# Проверить Watchtower logs +pct exec 102 -- docker logs watchtower-production + +# Ручной pull и restart +pct exec 102 -- bash -c "cd /opt/production && docker-compose pull mikrotik-bot && docker-compose up -d mikrotik-bot" +``` + +## 🔒 Безопасность + +- Все сервисы работают в изолированной сети 10.10.0.0/24 +- Registry настроен как insecure только для локальной сети +- Рекомендуется настроить VPN для внешнего доступа +- Регулярные снапшоты контейнеров в Proxmox + +## 📈 Расширения + +### Добавление HTTPS +1. Получить SSL сертификаты +2. Настроить Nginx reverse proxy +3. Обновить конфигурации сервисов + +### Мониторинг с Prometheus/Grafana +1. Добавить в registry-compose.yml: + ```yaml + prometheus: + image: prom/prometheus + grafana: + image: grafana/grafana + ``` + +### Backup стратегия +1. Автоматические снапшоты LXC +2. Backup Docker volumes +3. Export Gitea repositories + +## 🆘 Поддержка + +При проблемах: +1. Проверить статус всех контейнеров +2. Просмотреть логи соответствующих сервисов +3. Убедиться в корректности сетевых настроек +4. Проверить доступность портов и DNS записей \ No newline at end of file diff --git a/infra/deploy-all.sh b/infra/deploy-all.sh new file mode 100644 index 0000000..be9791e --- /dev/null +++ b/infra/deploy-all.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# Complete CI/CD Infrastructure Deployment Script + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}🚀 MikroTik Bot CI/CD Infrastructure Deployment${NC}" +echo -e "${BLUE}=================================================${NC}" + +# Configuration +GIT_SERVER="10.10.0.10" +REGISTRY_SERVER="10.10.0.20" +PRODUCTION_SERVER="10.10.0.30" + +# Check if running on Proxmox +check_proxmox() { + if ! command -v pct &> /dev/null; then + echo -e "${RED}❌ This script must be run on Proxmox host${NC}" + exit 1 + fi + echo -e "${GREEN}✅ Proxmox detected${NC}" +} + +# Deploy containers +deploy_containers() { + echo -e "${YELLOW}📦 Deploying LXC containers...${NC}" + bash setup-lxc.sh + + # Wait for containers to be ready + echo -e "${YELLOW}⏳ Waiting for containers to be ready...${NC}" + sleep 30 +} + +# Deploy Git Server +deploy_git_server() { + echo -e "${YELLOW}🔧 Deploying Git Server (Gitea)...${NC}" + + # Copy docker-compose to git server + pct exec 100 -- mkdir -p /opt/gitea + pct push 100 gitea-compose.yml /opt/gitea/docker-compose.yml + + # Create .env file + pct exec 100 -- bash -c "cat > /opt/gitea/.env << 'EOF' +RUNNER_TOKEN=placeholder-token-will-be-generated +EOF" + + # Deploy Gitea + pct exec 100 -- bash -c " + cd /opt/gitea + docker-compose up -d postgres gitea + echo 'Waiting for Gitea to start...' + sleep 30 + " + + echo -e "${GREEN}✅ Git Server deployed at http://$GIT_SERVER:3000${NC}" +} + +# Deploy Registry +deploy_registry() { + echo -e "${YELLOW}🔧 Deploying Docker Registry...${NC}" + + # Copy docker-compose to registry server + pct exec 101 -- mkdir -p /opt/registry + pct push 101 registry-compose.yml /opt/registry/docker-compose.yml + + # Configure insecure registry + pct exec 101 -- bash -c " + mkdir -p /etc/docker + cat > /etc/docker/daemon.json << 'EOF' +{ + \"insecure-registries\": [\"$REGISTRY_SERVER:5000\"] +} +EOF + systemctl restart docker + sleep 10 + " + + # Deploy registry + pct exec 101 -- bash -c " + cd /opt/registry + docker-compose up -d + " + + echo -e "${GREEN}✅ Docker Registry deployed at http://$REGISTRY_SERVER:5000${NC}" + echo -e "${GREEN}✅ Registry UI available at http://$REGISTRY_SERVER:5001${NC}" +} + +# Deploy Production +deploy_production() { + echo -e "${YELLOW}🔧 Deploying Production Environment...${NC}" + + # Configure insecure registry on production + pct exec 102 -- bash -c " + mkdir -p /etc/docker + cat > /etc/docker/daemon.json << 'EOF' +{ + \"insecure-registries\": [\"$REGISTRY_SERVER:5000\"] +} +EOF + systemctl restart docker + sleep 10 + " + + # Copy docker-compose to production server + pct exec 102 -- mkdir -p /opt/production + pct push 102 production-compose.yml /opt/production/docker-compose.yml + + # Create bot.env file + pct exec 102 -- bash -c "cat > /opt/production/bot.env << 'EOF' +TG_BOT_TOKEN=your_bot_token_here +MT_API_HOST=192.168.1.1 +MT_API_USER=admin +MT_API_PASS=your_password_here +ALLOWED_USER_IDS=123456789 +EOF" + + # Deploy production services + pct exec 102 -- bash -c " + cd /opt/production + docker-compose up -d portainer + echo 'Waiting for Portainer to start...' + sleep 20 + " + + echo -e "${GREEN}✅ Production Environment deployed${NC}" + echo -e "${GREEN}✅ Portainer available at http://$PRODUCTION_SERVER:9000${NC}" +} + +# Configure DNS (optional) +configure_dns() { + echo -e "${YELLOW}🌐 DNS Configuration (manual step)${NC}" + echo "Add these entries to your DNS or /etc/hosts:" + echo "$GIT_SERVER git.home.lab" + echo "$REGISTRY_SERVER registry.home.lab" + echo "$PRODUCTION_SERVER portainer.home.lab" +} + +# Setup Gitea Actions Runner +setup_runner() { + echo -e "${YELLOW}🏃 Setting up Gitea Actions Runner...${NC}" + echo "Manual steps required:" + echo "1. Go to http://$GIT_SERVER:3000" + echo "2. Create admin account" + echo "3. Go to Site Administration > Actions > Runners" + echo "4. Generate registration token" + echo "5. Update RUNNER_TOKEN in /opt/gitea/.env" + echo "6. Restart gitea runner: docker-compose restart runner" +} + +# Main deployment function +main() { + echo -e "${BLUE}Starting deployment...${NC}" + + check_proxmox + deploy_containers + deploy_git_server + deploy_registry + deploy_production + configure_dns + setup_runner + + echo -e "${GREEN}🎉 Deployment Complete!${NC}" + echo -e "${BLUE}=================================================${NC}" + echo -e "${GREEN}Services:${NC}" + echo -e "📁 Git Server: http://$GIT_SERVER:3000" + echo -e "📦 Registry: http://$REGISTRY_SERVER:5000" + echo -e "🎛️ Registry UI: http://$REGISTRY_SERVER:5001" + echo -e "🔧 Portainer: http://$PRODUCTION_SERVER:9000" + echo "" + echo -e "${YELLOW}Next Steps:${NC}" + echo "1. Configure Gitea admin account" + echo "2. Create mikrotik-bot repository" + echo "3. Setup Gitea Actions runner token" + echo "4. Configure bot.env with real credentials" + echo "5. Push your code to trigger first deployment" + echo "" + echo -e "${BLUE}Happy coding! 🚀${NC}" +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/infra/gitea-compose.yml b/infra/gitea-compose.yml new file mode 100644 index 0000000..99bc69b --- /dev/null +++ b/infra/gitea-compose.yml @@ -0,0 +1,74 @@ +version: '3.8' + +networks: + gitea: + external: false + +volumes: + gitea: + driver: local + postgres: + driver: local + +services: + postgres: + image: postgres:14 + container_name: gitea-postgres + environment: + - POSTGRES_USER=gitea + - POSTGRES_PASSWORD=giteapass + - POSTGRES_DB=gitea + volumes: + - postgres:/var/lib/postgresql/data + networks: + - gitea + restart: unless-stopped + + gitea: + image: gitea/gitea:latest + container_name: gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=postgres:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=gitea + - GITEA__database__PASSWD=giteapass + - GITEA__server__DOMAIN=git.home.lab + - GITEA__server__SSH_DOMAIN=git.home.lab + - GITEA__server__ROOT_URL=http://git.home.lab:3000 + - GITEA__server__SSH_PORT=2222 + - GITEA__service__DISABLE_REGISTRATION=true + - GITEA__service__REQUIRE_SIGNIN_VIEW=true + - GITEA__actions__ENABLED=true + - GITEA__actions__DEFAULT_ACTIONS_URL=https://github.com + volumes: + - gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "2222:22" + networks: + - gitea + depends_on: + - postgres + restart: unless-stopped + + # Gitea Actions Runner + runner: + image: gitea/act_runner:latest + container_name: gitea-runner + environment: + - GITEA_INSTANCE_URL=http://gitea:3000 + - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN} + - GITEA_RUNNER_NAME=docker-runner + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./runner-data:/data + networks: + - gitea + depends_on: + - gitea + restart: unless-stopped \ No newline at end of file diff --git a/infra/production-compose.yml b/infra/production-compose.yml new file mode 100644 index 0000000..5212b2e --- /dev/null +++ b/infra/production-compose.yml @@ -0,0 +1,86 @@ +version: '3.8' + +networks: + production: + external: false + +volumes: + portainer_data: + driver: local + bot_data: + driver: local + +services: + # Portainer для управления контейнерами + portainer: + image: portainer/portainer-ce:latest + container_name: portainer + command: -H unix:///var/run/docker.sock + environment: + - PORTAINER_HTTP_PORT=9000 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer_data:/data + ports: + - "9000:9000" + networks: + - production + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:9000"] + interval: 30s + timeout: 10s + retries: 3 + + # MikroTik Telegram Bot + mikrotik-bot: + image: 10.10.0.20:5000/mikrotik-bot:latest + container_name: mikrotik-telegram-bot + env_file: + - bot.env + volumes: + - bot_data:/app/data + networks: + - production + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + labels: + - "com.centurylinklabs.watchtower.enable=true" + - "io.portainer.accesscontrol.teams.mikrotik-bot" + + # Watchtower для автообновления бота + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower-production + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_POLL_INTERVAL=60 # каждую минуту для быстрого тестирования + - WATCHTOWER_INCLUDE_STOPPED=true + - WATCHTOWER_REVIVE_STOPPED=true + - WATCHTOWER_LABEL_ENABLE=true + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - production + restart: unless-stopped + + # Nginx для reverse proxy (опционально) + nginx: + image: nginx:alpine + container_name: nginx-proxy + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - "80:80" + - "443:443" + networks: + - production + depends_on: + - portainer + - mikrotik-bot + restart: unless-stopped \ No newline at end of file diff --git a/infra/registry-compose.yml b/infra/registry-compose.yml new file mode 100644 index 0000000..6b4a096 --- /dev/null +++ b/infra/registry-compose.yml @@ -0,0 +1,67 @@ +version: '3.8' + +networks: + registry: + external: false + +volumes: + registry_data: + driver: local + portainer_data: + driver: local + +services: + # Private Docker Registry + registry: + image: registry:2 + container_name: docker-registry + environment: + REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry + REGISTRY_HTTP_ADDR: 0.0.0.0:5000 + REGISTRY_STORAGE_DELETE_ENABLED: "true" + volumes: + - registry_data:/var/lib/registry + ports: + - "5000:5000" + networks: + - registry + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:5000/v2/"] + interval: 30s + timeout: 10s + retries: 3 + + # Registry UI (optional) + registry-ui: + image: joxit/docker-registry-ui:latest + container_name: registry-ui + environment: + - REGISTRY_TITLE=Private Docker Registry + - REGISTRY_URL=http://registry:5000 + - DELETE_IMAGES=true + - SHOW_CONTENT_DIGEST=true + ports: + - "5001:80" + networks: + - registry + depends_on: + - registry + restart: unless-stopped + + # Watchtower для автообновления + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower-global + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_POLL_INTERVAL=300 + - WATCHTOWER_INCLUDE_STOPPED=true + - WATCHTOWER_REVIVE_STOPPED=true + - WATCHTOWER_NOTIFICATIONS=slack + - WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL=${SLACK_WEBHOOK_URL} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - registry + restart: unless-stopped \ No newline at end of file diff --git a/infra/setup-lxc.sh b/infra/setup-lxc.sh new file mode 100644 index 0000000..2910a3d --- /dev/null +++ b/infra/setup-lxc.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Setup LXC containers for CI/CD infrastructure + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🚀 Setting up CI/CD Infrastructure on Proxmox${NC}" + +# Configuration +TEMPLATE_ID=9000 # Ubuntu 22.04 template ID +STORAGE="local-lvm" +BRIDGE="vmbr1" +GATEWAY="10.10.0.1" +DNS="8.8.8.8" + +# Container specifications +declare -A CONTAINERS=( + ["git-server"]="100:10.10.0.10:2048:20" + ["cicd-registry"]="101:10.10.0.20:4096:50" + ["production"]="102:10.10.0.30:2048:20" +) + +create_container() { + local name=$1 + local config=$2 + IFS=':' read -r id ip memory disk <<< "$config" + + echo -e "${YELLOW}Creating container: $name (ID: $id)${NC}" + + # Create container + pct create $id $TEMPLATE_ID \ + --hostname $name \ + --cores 2 \ + --memory $memory \ + --swap 512 \ + --storage $STORAGE \ + --rootfs $STORAGE:$disk \ + --net0 name=eth0,bridge=$BRIDGE,firewall=1,gw=$GATEWAY,ip=$ip/24,type=veth \ + --nameserver $DNS \ + --features nesting=1,keyctl=1 \ + --unprivileged 1 \ + --start 1 + + echo -e "${GREEN}✅ Container $name created and started${NC}" + + # Wait for container to boot + sleep 10 + + # Install Docker + echo -e "${YELLOW}Installing Docker on $name...${NC}" + pct exec $id -- bash -c " + apt update && apt upgrade -y + apt install -y ca-certificates curl gnupg lsb-release + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + echo 'deb [arch=\$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \$(lsb_release -cs) stable' | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt update + apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + systemctl enable docker + systemctl start docker + usermod -aG docker root + " + + # Install Docker Compose + pct exec $id -- bash -c " + curl -L https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + ln -sf /usr/local/bin/docker-compose /usr/bin/docker-compose + " + + echo -e "${GREEN}✅ Docker installed on $name${NC}" +} + +# Create containers +for container in "${!CONTAINERS[@]}"; do + create_container "$container" "${CONTAINERS[$container]}" +done + +echo -e "${GREEN}🎉 All containers created successfully!${NC}" +echo -e "${YELLOW}Next steps:${NC}" +echo "1. Configure DNS: git.home.lab → 10.10.0.10" +echo "2. Deploy services using docker-compose files" +echo "3. Configure webhooks and CI/CD" + +echo -e "${GREEN}Container Access:${NC}" +echo "Git Server: ssh root@10.10.0.10 or pct enter 100" +echo "CI/CD: ssh root@10.10.0.20 or pct enter 101" +echo "Production: ssh root@10.10.0.30 or pct enter 102" \ No newline at end of file diff --git a/instructions/README.md b/instructions/README.md new file mode 100644 index 0000000..10bf3a4 --- /dev/null +++ b/instructions/README.md @@ -0,0 +1,97 @@ +# Инструкция: Уведомления из MikroTik в Telegram-бот (локальная сеть) + +**Внимание!** Все примеры и инструкции ниже рассчитаны на работу внутри одной локальной сети (например, 10.10.10.0/24). Внешний доступ, проброс портов, Cloudflare Tunnel и HTTPS не требуются и не используются. + +## Оглавление +- [Вариант 1: Только через API (рекомендуется)](#вариант-1-только-через-api-рекомендуется) +- [Вариант 2: Через fetch-скрипт MikroTik (мгновенные уведомления)](#вариант-2-через-fetch-скрипт-mikrotik-для-мгновенных-событий) +- [Пошагово: fetch-скрипт MikroTik](./mt_fetch_script.md) +- [Пошагово: FastAPI-приёмник событий](./fastapi_receiver.md) +- [Внешний доступ (опционально)](#внешний-доступ-опционально) + +--- + +## Вариант 1: Только через API (рекомендуется) +Бот сам опрашивает MikroTik по внутреннему IP (например, 10.10.10.1) через API и отправляет уведомления в Telegram. + +### Преимущества: +- Не нужно ничего настраивать на MikroTik +- Всё централизовано в боте +- Безопасно (нет открытых портов) + +### Как работает: +1. В bot.py реализуется периодический опрос событий (например, новых Wi-Fi клиентов, статуса интернета) +2. Если найдено новое событие — бот отправляет уведомление в Telegram + +#### Пример кода (добавить в bot.py): +```python +import asyncio +from aiogram import Bot + +async def poll_new_clients(): + known_macs = set() + while True: + try: + api = get_mt_api() + clients = api.path('caps-man', 'registration-table').get() + new = [c for c in clients if c['mac-address'] not in known_macs] + for c in new: + await bot.send_message(<ВАШ_USER_ID>, f"Новый Wi-Fi клиент: {c['mac-address']} IP: {c.get('last-ip', '-')}") + known_macs.add(c['mac-address']) + except Exception as e: + print('Ошибка опроса MikroTik:', e) + await asyncio.sleep(30) # опрашивать каждые 30 секунд + +# В main: +# asyncio.create_task(poll_new_clients()) +``` + +--- + +## Вариант 2: Через fetch-скрипт MikroTik (для мгновенных событий) +MikroTik сам отправляет HTTP-запрос на сервер с ботом по внутреннему адресу (например, 10.10.10.2:8000). + +### Преимущества: +- Мгновенные уведомления (без задержки) +- Не нагружает API частыми запросами + +### Недостатки: +- Нужно настраивать скрипт на MikroTik +- Сервер с ботом должен быть доступен по внутреннему адресу + +### Как настроить: +1. В MikroTik создайте скрипт: + ``` + /tool fetch url="http://10.10.10.2:8000/event?type=wifi_connect&mac=$mac&ip=$ip" http-method=get + ``` +2. Повесьте этот скрипт на событие DHCP lease или Wi-Fi connect (через Scheduler или Lease Script) +3. В боте реализуйте обработку входящих HTTP-запросов (например, через FastAPI или Flask) +4. При получении запроса — отправляйте уведомление в Telegram + +#### Пример кода для Flask: +```python +from flask import Flask, request +from aiogram import Bot + +app = Flask(__name__) +bot = Bot(token=os.getenv("TG_BOT_TOKEN")) + +@app.route('/event') +def event(): + mac = request.args.get('mac') + ip = request.args.get('ip') + # ... другие параметры + asyncio.create_task(bot.send_message(<ВАШ_USER_ID>, f"MikroTik: новое подключение {mac} IP: {ip}")) + return 'ok' +``` + +--- + +## Какой способ выбрать? +- Если не хотите ничего настраивать на MikroTik — используйте только API (вариант 1) +- Если нужны мгновенные уведомления — используйте fetch-скрипт (вариант 2) или оба варианта вместе + +--- + +## Внешний доступ (опционально) +Если MikroTik и сервер с ботом находятся в разных сетях, используйте Cloudflare Tunnel или проброс портов. Подробнее — см. [Cloudflare Tunnel](./cloudflare_tunnel.md). В обычной локальной схеме это не требуется. \ No newline at end of file diff --git a/instructions/cloudflare_tunnel.md b/instructions/cloudflare_tunnel.md new file mode 100644 index 0000000..ad49212 --- /dev/null +++ b/instructions/cloudflare_tunnel.md @@ -0,0 +1,58 @@ +# Cloudflare Tunnel: безопасный доступ к вашему серверу для fetch-уведомлений MikroTik + +## 1. Регистрация и подготовка +- Зарегистрируйтесь на https://cloudflare.com (если ещё нет аккаунта) +- Добавьте свой домен в Cloudflare (следуйте инструкциям, смените NS-записи у регистратора) + +## 2. Установка cloudflared +- На сервере (где работает бот/FastAPI): + ``` + wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb + sudo dpkg -i cloudflared-linux-amd64.deb + # или через brew install cloudflared (macOS) + ``` + +## 3. Авторизация и запуск туннеля +- Введите: + ``` + cloudflared tunnel login + ``` +- Откроется браузер, авторизуйтесь через Cloudflare. +- Создайте туннель: + ``` + cloudflared tunnel create mikrotik-bot + cloudflared tunnel route dns mikrotik-bot bot.example.com + ``` +- Запустите туннель: + ``` + cloudflared tunnel run mikrotik-bot + ``` +- Теперь ваш сервер доступен по адресу https://bot.example.com (через Cloudflare, без проброса портов!) + +## 4. Настройка fetch-скрипта на MikroTik +- Используйте URL вида: + ``` + /tool fetch url="https://bot.example.com/event?type=wifi_connect&mac=$mac&ip=$ip" http-method=get + ``` + +## 5. Безопасность +- Cloudflare Tunnel скрывает реальный IP сервера +- Можно ограничить доступ по IP/стране через Cloudflare Firewall +- Рекомендуется добавить секрет в URL (?secret=...) и проверять его на сервере + +## 6. Автозапуск (systemd) +- Для автозапуска создайте сервис: + ``` + sudo cloudflared service install + ``` +- Проверьте статус: + ``` + sudo systemctl status cloudflared + ``` + +--- + +## Преимущества +- Не нужно открывать порты на роутере/сервере +- Бесплатно для большинства задач +- Простая интеграция с MikroTik и Telegram-ботом \ No newline at end of file diff --git a/instructions/fastapi_receiver.md b/instructions/fastapi_receiver.md new file mode 100644 index 0000000..d88eaed --- /dev/null +++ b/instructions/fastapi_receiver.md @@ -0,0 +1,57 @@ +# FastAPI: приём fetch-запросов от MikroTik и уведомления в Telegram (локальная сеть) + +**Внимание!** Все примеры рассчитаны на работу внутри одной локальной сети. Внешний доступ, HTTPS и туннели не требуются. + +## 1. Установка FastAPI и Uvicorn +В папке с ботом: +``` +pip install fastapi uvicorn +``` + +## 2. Пример кода (receiver.py) +```python +import os +import asyncio +from fastapi import FastAPI, Request +from aiogram import Bot + +bot = Bot(token=os.getenv("TG_BOT_TOKEN")) +app = FastAPI() + +@app.get("/event") +async def event(request: Request): + mac = request.query_params.get('mac') + ip = request.query_params.get('ip') + event_type = request.query_params.get('type') + # Можно добавить секрет для безопасности: + # if request.query_params.get('secret') != os.getenv('MT_SECRET'): return 'forbidden' + text = f"MikroTik: событие {event_type}\nMAC: {mac}\nIP: {ip}" + asyncio.create_task(bot.send_message(<ВАШ_USER_ID>, text)) + return {"status": "ok"} +``` + +- Замените <ВАШ_USER_ID> на свой Telegram user_id (или список). +- Можно вынести user_id в .env и подгружать через os.getenv. + +## 3. Запуск FastAPI +``` +uvicorn receiver:app --host 0.0.0.0 --port 8000 +``` +- Если бот и FastAPI в одном контейнере — добавьте запуск FastAPI в Dockerfile/compose. + +## 4. Безопасность +- Если сеть полностью изолирована — дополнительных мер не требуется. +- Для production можно добавить секрет в URL и проверку в коде. + +## 5. Проверка +- Вручную вызовите fetch-скрипт на MikroTik или curl: + ``` + curl "http://10.10.10.2:8000/event?type=test&mac=11:22:33:44:55:66&ip=192.168.88.100" + ``` +- Проверьте, что Telegram-бот прислал уведомление. + +--- + +## TODO +- Пример для запуска FastAPI и aiogram в одном процессе (через asyncio.gather) +- Пример для Docker Compose (несколько сервисов) \ No newline at end of file diff --git a/instructions/mt_fetch_script.md b/instructions/mt_fetch_script.md new file mode 100644 index 0000000..a00f3ec --- /dev/null +++ b/instructions/mt_fetch_script.md @@ -0,0 +1,40 @@ +# Настройка fetch-скрипта на MikroTik для уведомлений в Telegram-бот (локальная сеть) + +**Внимание!** Все примеры рассчитаны на работу внутри одной локальной сети. Внешний доступ, HTTPS и туннели не требуются. + +## 1. Создание скрипта на MikroTik +1. Откройте WinBox/WebFig или подключитесь по SSH к вашему MikroTik. +2. Перейдите в меню System → Scripts (или /system script в CLI). +3. Создайте новый скрипт, например: + - Name: notify-telegram + - Source: + ``` + /tool fetch url="http://10.10.10.2:8000/event?type=wifi_connect&mac=$mac&ip=$ip" http-method=get + ``` + - Замените 10.10.10.2 на внутренний адрес сервера с ботом. + +## 2. Привязка скрипта к событию +### DHCP lease (новое подключение по DHCP) +- В разделе IP → DHCP Server → Leases найдите поле "Lease Script" и добавьте: + ``` + :if ($leaseBound = true) do={/system script run notify-telegram} + ``` +- Это вызовет скрипт при каждом новом lease (подключении устройства). + +### Wi-Fi connect (CAPsMAN) +- Для CAPsMAN можно использовать Scheduler или Event Handler, если требуется. + +## 3. Проверка работы +- Подключите новое устройство к Wi-Fi. +- Проверьте логи MikroTik: появилось ли выполнение скрипта и fetch-запрос. +- Проверьте, что бот получил уведомление (или что сервер принял запрос — можно смотреть логи Flask/FastAPI). + +## 4. Безопасность +- Если сеть полностью изолирована — дополнительных мер не требуется. +- Для production можно добавить секрет в URL (например, ?secret=...) + +--- + +## Пример для нескольких событий +- Можно добавить параметры (например, тип события, имя устройства, VLAN и т.д.) +- Скрипт можно вызывать и вручную для теста: `/system script run notify-telegram` \ No newline at end of file diff --git a/instructions/plan_system.md b/instructions/plan_system.md new file mode 100644 index 0000000..9a8bc22 --- /dev/null +++ b/instructions/plan_system.md @@ -0,0 +1,62 @@ +# Системный план: мониторинг MikroTik через Telegram-бота (fetch + API) + +## Архитектура и цели +- **Telegram-бот** для мониторинга и управления MikroTik (работает в Docker/Portainer, Proxmox, локальная сеть) +- **Два источника событий:** + - fetch-скрипт MikroTik — для мгновенных алертов о новых подключениях + - API (polling registration-table) — для сбора полной статистики (rx/tx bytes, uptime, отключения) +- **История и статистика** хранятся в SQLite (sessions, clients) +- **Управление и просмотр** через команды и инлайн-кнопки в Telegram + +## Ограничения и особенности +- Всё работает внутри локальной сети (нет проброса портов, Cloudflare Tunnel не нужен) +- fetch-скрипт может не содержать rx/tx bytes — только алерт о событии +- API даёт полную картину, но с задержкой (polling) +- Возможны неучтённые данные (VPN, туннели, смена MAC, перезагрузка роутера) +- SQLite выбран как оптимальный вариант для домашней/офисной нагрузки + +## Структура данных (БД) +- **clients** (устройства) + - mac (PK) + - name (host-name/comment/vendor) + - custom_name (ручное имя) + - last_seen +- **sessions** (подключения) + - id (PK) + - mac (FK) + - ip + - start_time + - end_time + - rx_bytes + - tx_bytes + - source (fetch/api) + - last_update + +## Поведение +- fetch-скрипт: мгновенно пишет событие о новом подключении (если нет активной сессии — создаёт) +- API: периодически обновляет rx/tx bytes, uptime, фиксирует отключения (закрывает сессию) +- Если клиент исчез — сессия закрывается, статистика фиксируется +- Все события и статистика доступны для фильтрации и поиска + +## Команды и интерфейс +- [x] /history — история подключений (фильтры: день, неделя, месяц, постраничность) +- [x] /clients — список устройств (сортировка по last_seen, постраничность) +- [x] /stats — агрегированная статистика (топ по трафику, активности, постраничность) +- [x] Инлайн-кнопки для фильтрации, навигации, обновления +- [x] fetch+API: гибридная обработка событий, антидублирование, запись в SQLite +- [x] Асинхронная работа, интеграция с aiogram и FastAPI +- [x] Вся логика реализована для работы в локальной сети +- [ ] Возможность задать кастомное имя устройству через бота +- [ ] (Опционально) Экспорт истории/статистики в CSV/Excel +- [ ] (Опционально) Алерты по подозрительной активности (например, новый MAC, много трафика) + +--- + +**Всё основное реализовано!** + +**Оставшиеся задачи:** +- Возможность задавать кастомные имена устройствам через бота +- (Опционально) Экспорт истории/статистики +- (Опционально) Алерты по событиям + +**Этот документ — эталонный план для всей разработки и поддержки проекта.** \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..445314a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +aiogram>=3.0.0 +librouteros>=3.0.0 +python-dotenv>=1.0.0 +fastapi>=0.100.0 +uvicorn>=0.22.0 +aiosqlite>=0.19.0 \ No newline at end of file