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