diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3a09f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +dist/ +__pycache__/ +*.spec +*.pyc +sessions.json diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..1b8bbf0 --- /dev/null +++ b/build.bat @@ -0,0 +1,23 @@ +@echo off +echo === KU SSH Manager — Build === + +where python >nul 2>&1 +if errorlevel 1 ( + echo Python not found! Install from https://python.org/downloads/ + pause + exit /b 1 +) + +echo Installing dependencies... +pip install paramiko pyinstaller + +echo Building .exe... +pyinstaller --onefile --noconsole --name "KU SSH Manager" ^ + --add-data "sessions.json;." ^ + --icon NONE ^ + ssh_manager.py + +echo. +echo Done! EXE: dist\KU SSH Manager.exe +echo Copy it anywhere — sessions.json saves next to .exe +pause diff --git a/full_install.ps1 b/full_install.ps1 new file mode 100644 index 0000000..56917a6 --- /dev/null +++ b/full_install.ps1 @@ -0,0 +1,94 @@ +Write-Host "=== PCA SSH — Private Control Administration ===" -ForegroundColor Cyan + +# Find Python — check common install paths too +$python = $null +$tryPaths = @( + "python", + "python3", + "py", + "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe", + "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe", + "C:\Python312\python.exe", + "C:\Python311\python.exe", + "$env:APPDATA\Python\Python312\Scripts\..\python.exe" +) +foreach ($cmd in $tryPaths) { + try { + $ver = & $cmd --version 2>$null + if ($ver -match "Python 3") { + $python = $cmd + Write-Host "Python: $ver" -ForegroundColor Green + break + } + } catch {} +} + +if (-not $python) { + Write-Host "Python not found. Downloading..." -ForegroundColor Yellow + $pyUrl = "https://www.python.org/ftp/python/3.12.7/python-3.12.7-amd64.exe" + $pyInst = Join-Path $env:TEMP "python-installer.exe" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $pyUrl -OutFile $pyInst -UseBasicParsing + Write-Host "Installing Python..." -ForegroundColor Yellow + Start-Process -FilePath $pyInst -ArgumentList "/quiet","InstallAllUsers=0","PrependPath=1","Include_pip=1" -Wait + Remove-Item $pyInst -ErrorAction SilentlyContinue + + $searchPaths = @( + "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe", + "C:\Python312\python.exe", + "C:\Program Files\Python312\python.exe" + ) + foreach ($p in $searchPaths) { + if (Test-Path $p) { + $python = $p + Write-Host "Python at: $p" -ForegroundColor Green + break + } + } + if (-not $python) { + Write-Host "Cannot find python after install!" -ForegroundColor Red + Read-Host "Press Enter" + exit 1 + } +} + +Write-Host "Installing deps..." -ForegroundColor Yellow +& $python -m pip install --upgrade pip 2>&1 | ForEach-Object { if ($_ -notmatch "WARNING") { $_ } } +& $python -m pip install paramiko pillow pyinstaller 2>&1 | ForEach-Object { if ($_ -notmatch "WARNING") { $_ } } + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not $scriptDir) { $scriptDir = Get-Location } + +$localDir = Join-Path $env:TEMP "pca-ssh-build" +if (Test-Path $localDir) { Remove-Item $localDir -Recurse -Force } +New-Item -ItemType Directory -Path $localDir | Out-Null +Copy-Item (Join-Path $scriptDir "ssh_manager.py") $localDir +Copy-Item (Join-Path $scriptDir "gen_icon.py") $localDir + +Set-Location $localDir + +# Generate .ico +Write-Host "Generating icon..." -ForegroundColor Yellow +& $python gen_icon.py 2>&1 | ForEach-Object { Write-Host $_ } + +$icoFile = Join-Path $localDir "pca_ssh.ico" +if (Test-Path $icoFile) { + $iconArg = "--icon=pca_ssh.ico" +} else { + $iconArg = "" +} + +Write-Host "Building PCA_SSH.exe..." -ForegroundColor Yellow +& $python -m PyInstaller --onefile --noconsole --name "PCA_SSH" $iconArg ssh_manager.py 2>&1 | ForEach-Object { Write-Host $_ } + +$exe = Join-Path $localDir "dist\PCA_SSH.exe" +if (Test-Path $exe) { + $desktop = [Environment]::GetFolderPath("Desktop") + Copy-Item $exe (Join-Path $desktop "PCA_SSH.exe") -Force + Write-Host "" + Write-Host "DONE! PCA_SSH.exe on Desktop" -ForegroundColor Green +} else { + Write-Host "Build FAILED" -ForegroundColor Red +} + +Read-Host "Press Enter" diff --git a/gen_icon.py b/gen_icon.py new file mode 100644 index 0000000..6430188 --- /dev/null +++ b/gen_icon.py @@ -0,0 +1,100 @@ +"""Generate PCA SSH icon in .ico (Windows) and .icns (macOS) formats.""" +from PIL import Image, ImageDraw, ImageFont +import struct, os, sys + +SIZE = 512 + +def draw_icon(size=SIZE): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Dark rounded-rect background + r = size // 6 + draw.rounded_rectangle([4, 4, size - 4, size - 4], radius=r, fill="#1e1e2e", outline="#3b3b5c", width=3) + + # "PCA" text + font_size = size // 4 + try: + font = ImageFont.truetype("arial.ttf", font_size) + except OSError: + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size) + except OSError: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), "PCA", font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + tx = (size - tw) // 2 + ty = size // 4 - th // 4 + draw.text((tx, ty), "PCA", fill="#6c8fff", font=font) + + # Blue dots pattern (like water drops) + cx = size // 2 + cy = int(size * 0.62) + dot_r = size // 30 + + dot_positions = [ + (0, 0), + (-3 * dot_r, -2 * dot_r), + (3 * dot_r, -2 * dot_r), + (-1.5 * dot_r, 2 * dot_r), + (1.5 * dot_r, 2 * dot_r), + (0, 4 * dot_r), + (-4.5 * dot_r, 1 * dot_r), + (4.5 * dot_r, 1 * dot_r), + ] + + for dx, dy in dot_positions: + x = cx + dx + y = cy + dy + draw.ellipse([x - dot_r, y - dot_r, x + dot_r, y + dot_r], fill="#6c8fff") + + # Subtle connecting lines between dots + line_color = "#3d5abf" + connections = [(0, 1), (0, 2), (1, 3), (2, 4), (3, 5), (4, 5), (1, 6), (2, 7)] + abs_positions = [(cx + dx, cy + dy) for dx, dy in dot_positions] + for a, b in connections: + draw.line([abs_positions[a], abs_positions[b]], fill=line_color, width=max(1, size // 200)) + + return img + + +def save_ico(img, path): + sizes = [16, 24, 32, 48, 64, 128, 256] + icons = [] + for s in sizes: + icons.append(img.resize((s, s), Image.LANCZOS)) + icons[0].save(path, format="ICO", sizes=[(s, s) for s in sizes], append_images=icons[1:]) + + +def save_icns(img, path): + # macOS .icns via iconutil + import tempfile, subprocess + iconset = tempfile.mkdtemp(suffix=".iconset") + for size in [16, 32, 64, 128, 256, 512]: + resized = img.resize((size, size), Image.LANCZOS) + resized.save(os.path.join(iconset, f"icon_{size}x{size}.png")) + if size <= 256: + resized2x = img.resize((size * 2, size * 2), Image.LANCZOS) + resized2x.save(os.path.join(iconset, f"icon_{size}x{size}@2x.png")) + subprocess.run(["iconutil", "-c", "icns", iconset, "-o", path], check=True) + import shutil + shutil.rmtree(iconset) + + +if __name__ == "__main__": + out_dir = os.path.dirname(os.path.abspath(__file__)) + img = draw_icon(512) + + ico_path = os.path.join(out_dir, "pca_ssh.ico") + save_ico(img, ico_path) + print(f"ICO: {ico_path}") + + if sys.platform == "darwin": + icns_path = os.path.join(out_dir, "pca_ssh.icns") + save_icns(img, icns_path) + print(f"ICNS: {icns_path}") + + png_path = os.path.join(out_dir, "pca_ssh.png") + img.save(png_path) + print(f"PNG: {png_path}") diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..10d95d3 --- /dev/null +++ b/install.bat @@ -0,0 +1,17 @@ +@echo off +echo === KU SSH Manager — Install and Run === + +where python >nul 2>&1 +if errorlevel 1 ( + echo Python not found! + echo Download: https://python.org/downloads/ + echo Check "Add Python to PATH" during install! + pause + exit /b 1 +) + +echo Installing dependencies... +pip install paramiko + +echo Starting SSH Manager... +python ssh_manager.py diff --git a/pca_ssh.icns b/pca_ssh.icns new file mode 100644 index 0000000..76b4672 Binary files /dev/null and b/pca_ssh.icns differ diff --git a/pca_ssh.ico b/pca_ssh.ico new file mode 100644 index 0000000..ce50bfa Binary files /dev/null and b/pca_ssh.ico differ diff --git a/pca_ssh.png b/pca_ssh.png new file mode 100644 index 0000000..62244db Binary files /dev/null and b/pca_ssh.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d1b1a9b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +paramiko>=3.0 +pyinstaller>=6.0 diff --git a/ssh_manager.py b/ssh_manager.py new file mode 100644 index 0000000..1346ca3 --- /dev/null +++ b/ssh_manager.py @@ -0,0 +1,1469 @@ +""" +KU SSH Manager — Portable SSH client with auto-save sessions. +Tkinter GUI + paramiko SSH + JSON session store. +""" + +import tkinter as tk +from tkinter import ttk, messagebox, font as tkfont +import paramiko +import threading +import json +import os +import sys +import re +import base64 +import socket +from datetime import datetime +from pathlib import Path + +APP_NAME = "PCA SSH" +APP_SUBTITLE = "Private Control Administration" +VERSION = "1.1" + +# ── Quick Commands ───────────────────────────────────────────── + +COMMANDS = { + "Ubuntu / Linux": { + "Система": [ + ("uname -a", "Ядро и архитектура"), + ("cat /etc/os-release", "Версия ОС"), + ("uptime -p", "Аптайм"), + ("hostnamectl", "Имя хоста и ОС"), + ("timedatectl", "Время и часовой пояс"), + ("last reboot | head -5", "Последние перезагрузки"), + ("who", "Кто залогинен"), + ("w", "Активные сессии и нагрузка"), + ], + "Ресурсы": [ + ("htop", "Мониторинг процессов (интерактивный)"), + ("top -bn1 | head -20", "Топ процессов (снимок)"), + ("free -h", "Оперативная память"), + ("df -hT", "Диски и файловые системы"), + ("du -sh /* 2>/dev/null | sort -rh | head -10", "Топ-10 тяжёлых директорий"), + ("lsblk", "Блочные устройства"), + ("iostat -x 1 3", "Нагрузка на диски"), + ("vmstat 1 5", "CPU/память/swap за 5 сек"), + ("cat /proc/cpuinfo | grep 'model name' | head -1", "Модель процессора"), + ("nproc", "Количество ядер CPU"), + ], + "Сеть": [ + ("ip addr", "IP-адреса интерфейсов"), + ("ip route", "Таблица маршрутов"), + ("ss -tulpn", "Открытые порты и процессы"), + ("netstat -tulpn", "Порты (классический)"), + ("curl -s ifconfig.me", "Внешний IP"), + ("ping -c 4 8.8.8.8", "Пинг Google DNS"), + ("ping -c 4 1.1.1.1", "Пинг Cloudflare"), + ("traceroute 8.8.8.8", "Трассировка до Google"), + ("dig google.com +short", "DNS-запрос"), + ("nslookup google.com", "DNS (nslookup)"), + ("iptables -L -n --line-numbers", "Правила файрвола"), + ("ufw status verbose", "Статус UFW"), + ("cat /etc/resolv.conf", "DNS-серверы"), + ("arp -a", "ARP-таблица"), + ("ethtool eth0", "Состояние сетевого адаптера"), + ], + "Сервисы": [ + ("systemctl list-units --type=service --state=running", "Запущенные сервисы"), + ("systemctl status", "Общий статус systemd"), + ("systemctl --failed", "Упавшие сервисы"), + ("journalctl -xe --no-pager | tail -50", "Последние логи"), + ("journalctl -u nginx --since '1 hour ago'", "Логи nginx за час"), + ("crontab -l", "Задачи cron"), + ("systemctl list-timers", "Systemd-таймеры"), + ], + "Docker": [ + ("docker ps", "Запущенные контейнеры"), + ("docker ps -a", "Все контейнеры"), + ("docker images", "Образы"), + ("docker stats --no-stream", "Ресурсы контейнеров"), + ("docker logs --tail 50 $(docker ps -q | head -1)", "Логи последнего контейнера"), + ("docker system df", "Занятое место Docker"), + ("docker system prune -f", "Очистка мусора Docker"), + ("docker-compose ps", "Compose-сервисы"), + ], + "Безопасность": [ + ("last -10", "Последние 10 логинов"), + ("lastb -10 2>/dev/null", "Неудачные логины"), + ("cat /var/log/auth.log | tail -20", "Лог авторизации"), + ("find / -perm -4000 -type f 2>/dev/null", "SUID-файлы"), + ("cat /etc/passwd | grep -v nologin | grep -v false", "Пользователи с шеллом"), + ("ss -tulpn | grep LISTEN", "Слушающие порты"), + ("fail2ban-client status 2>/dev/null", "Статус Fail2Ban"), + ("openssl x509 -in /etc/ssl/certs/*.pem -noout -dates 2>/dev/null | head -4", "Сроки SSL-сертов"), + ], + "Пользователи и пароли": [ + ("cat /etc/passwd", "Все пользователи системы"), + ("cat /etc/passwd | grep -v nologin | grep -v false", "Пользователи с шеллом"), + ("getent passwd | awk -F: '$3 >= 1000 {print $1}'", "Обычные пользователи (UID >= 1000)"), + ("groups", "Группы текущего пользователя"), + ("id", "UID/GID текущего пользователя"), + ("id username", "Инфо о пользователе username"), + ("passwd", "Сменить свой пароль"), + ("passwd username", "Сменить пароль пользователю"), + ("chage -l username", "Срок действия пароля"), + ("chage -M 90 username", "Пароль истекает через 90 дней"), + ("useradd -m -s /bin/bash username", "Создать пользователя с домашней папкой"), + ("useradd -m -s /bin/bash -G sudo username", "Создать пользователя с sudo"), + ("useradd -r -s /usr/sbin/nologin svcname", "Создать сервисного пользователя"), + ("userdel username", "Удалить пользователя (без домашней)"), + ("userdel -r username", "Удалить пользователя + домашнюю папку"), + ("usermod -aG sudo username", "Добавить в группу sudo"), + ("usermod -aG docker username", "Добавить в группу docker"), + ("usermod -L username", "Заблокировать пользователя"), + ("usermod -U username", "Разблокировать пользователя"), + ("usermod -s /usr/sbin/nologin username", "Запретить логин"), + ("usermod -s /bin/bash username", "Разрешить логин"), + ("groupadd groupname", "Создать группу"), + ("groupdel groupname", "Удалить группу"), + ("gpasswd -d username groupname", "Убрать из группы"), + ("cat /etc/shadow | grep username", "Хеш пароля (root)"), + ("passwd -S username", "Статус пароля"), + ("echo 'username:newpassword' | chpasswd", "Задать пароль одной командой"), + ], + "SSH-ключи": [ + ("ssh-keygen -t ed25519 -C 'comment'", "Генерировать SSH-ключ ed25519"), + ("ssh-keygen -t rsa -b 4096", "Генерировать SSH-ключ RSA 4096"), + ("cat ~/.ssh/id_ed25519.pub", "Показать публичный ключ"), + ("cat ~/.ssh/authorized_keys", "Авторизованные ключи"), + ("ssh-copy-id user@host", "Скопировать ключ на сервер"), + ("chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys", "Права на SSH-директорию"), + ], + "Файлы и пакеты": [ + ("ls -lahS / | head -20", "Большие файлы в корне"), + ("find /var/log -name '*.log' -mtime -1", "Свежие логи"), + ("apt list --upgradable 2>/dev/null", "Доступные обновления (apt)"), + ("yum check-update 2>/dev/null", "Доступные обновления (yum)"), + ("dpkg -l | wc -l", "Количество пакетов"), + ("tail -f /var/log/syslog", "Сислог в реальном времени"), + ], + }, + "Keenetic (NDMS CLI)": { + "Информация": [ + ("show version", "Прошивка и модель"), + ("show system", "Загрузка CPU/RAM"), + ("show clock", "Время роутера"), + ("show uptime", "Аптайм"), + ("show log", "Системный лог"), + ("show running-config", "Текущая конфигурация"), + ("show startup-config", "Стартовая конфигурация"), + ("show components", "Установленные компоненты"), + ], + "Сеть": [ + ("show interface", "Все интерфейсы"), + ("show interface WifiMaster0", "Wi-Fi 2.4 ГГц"), + ("show interface WifiMaster1", "Wi-Fi 5 ГГц"), + ("show interface PPPoE0", "PPPoE-подключение"), + ("show ip route", "Таблица маршрутов"), + ("show ip arp", "ARP-таблица"), + ("show ip dhcp bindings", "DHCP-аренды"), + ("show ip dhcp pool", "DHCP-пулы"), + ("show ip hotspot", "Подключённые устройства"), + ("show ip hotspot host", "Список хостов"), + ("show ndns", "Статус KeenDNS"), + ("show internet status", "Статус интернета"), + ], + "VPN": [ + ("show crypto ipsec sa", "IPsec-туннели"), + ("show vpn-server", "VPN-сервер"), + ("show interface Wireguard0", "WireGuard статус"), + ("show interface OpenVPN0", "OpenVPN статус"), + ("show interface PPTP0", "PPTP статус"), + ("show interface L2TP0", "L2TP статус"), + ("show interface SSTP0", "SSTP статус"), + ], + "Wi-Fi": [ + ("show associations", "Подключённые клиенты Wi-Fi"), + ("show interface WifiMaster0 wps", "WPS статус 2.4"), + ("show interface WifiMaster1 wps", "WPS статус 5"), + ("interface WifiMaster0 down", "Выключить Wi-Fi 2.4"), + ("interface WifiMaster0 up", "Включить Wi-Fi 2.4"), + ("interface WifiMaster1 down", "Выключить Wi-Fi 5"), + ("interface WifiMaster1 up", "Включить Wi-Fi 5"), + ], + "USB и накопители": [ + ("show usb", "USB-устройства"), + ("show media", "Накопители"), + ("show printer", "USB-принтер"), + ("show interface UsbModem0", "USB-модем"), + ], + "Диагностика": [ + ("tools ping 8.8.8.8", "Пинг Google DNS"), + ("tools ping 1.1.1.1", "Пинг Cloudflare"), + ("tools traceroute 8.8.8.8", "Трассировка"), + ("tools dns-lookup google.com", "DNS-запрос"), + ("tools ntp-sync", "Синхронизация времени"), + ("show speed-test", "Speedtest (если доступен)"), + ], + "Пользователи и пароли": [ + ("show user", "Список пользователей роутера"), + ("user admin", "Войти в настройки пользователя admin"), + ("user admin password newpass", "Сменить пароль admin"), + ("user add username", "Создать пользователя"), + ("user username password newpass", "Задать пароль пользователю"), + ("user username tag http", "Разрешить доступ к web-интерфейсу"), + ("user username tag cli", "Разрешить доступ к CLI"), + ("user username tag http,cli,ftp", "Доступ: web + CLI + FTP"), + ("no user username", "Удалить пользователя"), + ("user username permit http 192.168.1.0/24", "Доступ только из подсети"), + ("ip http security-level private", "Web-интерфейс только из LAN"), + ("ip http security-level public", "Web-интерфейс из WAN (осторожно!)"), + ("ip telnet security-level private", "Telnet только из LAN"), + ("ip ssh security-level private", "SSH только из LAN"), + ], + "Управление": [ + ("system configuration save", "Сохранить конфигурацию"), + ("system reboot", "Перезагрузка роутера"), + ("system log clear", "Очистить лог"), + ("tools firmware check", "Проверить обновления прошивки"), + ("tools firmware upgrade", "Обновить прошивку"), + ("copy running-config startup-config", "Применить конфигурацию"), + ], + }, + "Быстрые скрипты": { + "Мониторинг": [ + ("watch -n 2 'free -h && echo --- && df -h /'", "Память+диск каждые 2 сек"), + ("while true; do date; ss -s; sleep 5; done", "Счётчик соединений каждые 5 сек"), + ("tail -f /var/log/syslog | grep -i error", "Ошибки в сислоге (live)"), + ("sar -u 1 10", "CPU-нагрузка 10 замеров"), + ], + "Сеть (скрипты)": [ + ("for h in 8.8.8.8 1.1.1.1 ya.ru; do echo -n \"$h: \"; ping -c1 -W2 $h | grep time= || echo FAIL; done", "Пинг 3 хостов"), + ("curl -so /dev/null -w '%{time_total}s' https://google.com && echo", "Время ответа Google"), + ("ss -s", "Сводка по соединениям"), + ], + "Очистка": [ + ("apt autoremove -y && apt clean", "Очистка apt (Debian/Ubuntu)"), + ("journalctl --vacuum-size=100M", "Обрезать логи до 100MB"), + ("find /tmp -mtime +7 -delete", "Удалить файлы /tmp старше 7 дней"), + ("find /var/log -name '*.gz' -delete", "Удалить сжатые логи"), + ], + }, +} + +# ── Command Agent Knowledge Base ─────────────────────────────── + +AGENT_KB = [ + (["обновить", "update", "upgrade", "обновление", "апдейт"], + "linux", [ + ("sudo apt update && sudo apt upgrade -y", "Обновить все пакеты (Debian/Ubuntu)"), + ("sudo apt dist-upgrade -y", "Полное обновление с зависимостями"), + ("sudo yum update -y", "Обновить все пакеты (CentOS/RHEL)"), + ("sudo dnf upgrade -y", "Обновить все пакеты (Fedora)"), + ("sudo apt list --upgradable", "Показать доступные обновления"), + ]), + (["перезагрузить", "reboot", "restart", "рестарт", "ребут"], + "linux", [ + ("sudo reboot", "Перезагрузить сервер"), + ("sudo shutdown -r +1 'Reboot in 1 min'", "Перезагрузка через 1 минуту"), + ("sudo systemctl restart nginx", "Перестартовать nginx"), + ("sudo systemctl restart sshd", "Перестартовать SSH"), + ]), + (["перезагрузить", "reboot", "restart"], + "keenetic", [ + ("system reboot", "Перезагрузить роутер"), + ]), + (["место", "disk", "диск", "свободно", "занято", "очистить диск", "нет места"], + "linux", [ + ("df -hT", "Показать свободное место"), + ("du -sh /* 2>/dev/null | sort -rh | head -10", "Топ-10 тяжёлых папок"), + ("sudo apt autoremove -y && sudo apt clean", "Очистить кэш apt"), + ("sudo journalctl --vacuum-size=100M", "Обрезать логи до 100MB"), + ("find /var/log -name '*.gz' -delete", "Удалить сжатые логи"), + ("docker system prune -af", "Очистить Docker (образы, контейнеры)"), + ]), + (["память", "ram", "memory", "swap", "oom"], + "linux", [ + ("free -h", "Показать RAM и swap"), + ("top -bn1 | head -20", "Топ процессов по CPU/RAM"), + ("ps aux --sort=-%mem | head -15", "Процессы по потреблению RAM"), + ("sudo swapon --show", "Активные swap-разделы"), + ("sudo sync && echo 3 | sudo tee /proc/sys/vm/drop_caches", "Очистить кэш памяти"), + ]), + (["пользователь", "user", "юзер", "создать пользователя", "добавить пользователя"], + "linux", [ + ("sudo useradd -m -s /bin/bash username", "Создать пользователя"), + ("sudo useradd -m -s /bin/bash -G sudo username", "Создать с sudo"), + ("sudo passwd username", "Задать пароль"), + ("sudo userdel -r username", "Удалить пользователя"), + ("cat /etc/passwd | grep -v nologin", "Пользователи с шеллом"), + ]), + (["пользователь", "user", "юзер", "пароль"], + "keenetic", [ + ("show user", "Список пользователей роутера"), + ("user add username", "Создать пользователя"), + ("user admin password newpass", "Сменить пароль admin"), + ("no user username", "Удалить пользователя"), + ]), + (["пароль", "password", "сменить пароль"], + "linux", [ + ("passwd", "Сменить свой пароль"), + ("sudo passwd username", "Сменить пароль другому"), + ("sudo chage -l username", "Срок действия пароля"), + ("echo 'user:pass' | sudo chpasswd", "Задать пароль одной командой"), + ]), + (["порт", "port", "слушает", "listen", "открытые порты"], + "linux", [ + ("ss -tulpn", "Все слушающие порты"), + ("ss -tulpn | grep :80", "Кто слушает порт 80"), + ("sudo lsof -i :443", "Процесс на порту 443"), + ("sudo ufw allow 8080/tcp", "Открыть порт 8080"), + ("sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT", "Открыть порт iptables"), + ]), + (["файрвол", "firewall", "ufw", "iptables", "заблокировать"], + "linux", [ + ("sudo ufw status verbose", "Статус UFW"), + ("sudo ufw enable", "Включить UFW"), + ("sudo ufw allow ssh", "Разрешить SSH"), + ("sudo ufw allow 80,443/tcp", "Разрешить HTTP/HTTPS"), + ("sudo ufw deny from 1.2.3.4", "Заблокировать IP"), + ("sudo iptables -L -n --line-numbers", "Правила iptables"), + ]), + (["сервис", "service", "демон", "systemctl", "запустить", "остановить"], + "linux", [ + ("sudo systemctl status servicename", "Статус сервиса"), + ("sudo systemctl start servicename", "Запустить"), + ("sudo systemctl stop servicename", "Остановить"), + ("sudo systemctl restart servicename", "Перезапустить"), + ("sudo systemctl enable servicename", "Автозапуск"), + ("sudo systemctl disable servicename", "Убрать из автозапуска"), + ("journalctl -u servicename -f", "Логи сервиса (live)"), + ]), + (["лог", "log", "логи", "журнал", "ошибки"], + "linux", [ + ("journalctl -xe --no-pager | tail -50", "Последние системные логи"), + ("tail -f /var/log/syslog", "Сислог (live)"), + ("tail -f /var/log/auth.log", "Логи авторизации (live)"), + ("journalctl --since '1 hour ago'", "Логи за последний час"), + ("dmesg | tail -30", "Сообщения ядра"), + ("grep -i error /var/log/syslog | tail -20", "Ошибки в сислоге"), + ]), + (["лог", "log"], + "keenetic", [ + ("show log", "Системный лог роутера"), + ("system log clear", "Очистить лог"), + ]), + (["ssh", "ключ", "key", "ssh-ключ", "авторизация"], + "linux", [ + ("ssh-keygen -t ed25519 -C 'my key'", "Генерировать ключ ed25519"), + ("cat ~/.ssh/id_ed25519.pub", "Показать публичный ключ"), + ("ssh-copy-id user@host", "Скопировать ключ на сервер"), + ("chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys", "Права SSH"), + ]), + (["docker", "контейнер", "container"], + "linux", [ + ("docker ps", "Запущенные контейнеры"), + ("docker ps -a", "Все контейнеры"), + ("docker logs --tail 50 container_name", "Логи контейнера"), + ("docker restart container_name", "Перезапустить контейнер"), + ("docker system prune -f", "Очистить мусор"), + ("docker stats --no-stream", "Ресурсы контейнеров"), + ]), + (["ip", "адрес", "сеть", "network", "интерфейс"], + "linux", [ + ("ip addr", "IP-адреса"), + ("ip route", "Маршруты"), + ("curl -s ifconfig.me", "Внешний IP"), + ("cat /etc/resolv.conf", "DNS-серверы"), + ("ping -c 4 8.8.8.8", "Пинг Google"), + ]), + (["ip", "адрес", "сеть", "интерфейс"], + "keenetic", [ + ("show interface", "Все интерфейсы"), + ("show ip route", "Маршруты"), + ("show ip hotspot", "Подключённые устройства"), + ("show internet status", "Статус интернета"), + ]), + (["vpn", "wireguard", "openvpn", "туннель"], + "keenetic", [ + ("show interface Wireguard0", "WireGuard статус"), + ("show interface OpenVPN0", "OpenVPN статус"), + ("show vpn-server", "VPN-сервер"), + ("show crypto ipsec sa", "IPsec-туннели"), + ]), + (["wifi", "wi-fi", "вайфай", "беспроводн"], + "keenetic", [ + ("show associations", "Клиенты Wi-Fi"), + ("show interface WifiMaster0", "Wi-Fi 2.4 ГГц"), + ("show interface WifiMaster1", "Wi-Fi 5 ГГц"), + ("interface WifiMaster0 down", "Выключить 2.4"), + ("interface WifiMaster0 up", "Включить 2.4"), + ]), + (["dhcp", "аренда", "lease"], + "keenetic", [ + ("show ip dhcp bindings", "DHCP-аренды"), + ("show ip dhcp pool", "DHCP-пулы"), + ]), + (["dns", "днс", "resolve"], + "linux", [ + ("dig google.com +short", "DNS-запрос"), + ("nslookup google.com", "nslookup"), + ("cat /etc/resolv.conf", "DNS-серверы"), + ("systemd-resolve --status | head -20", "systemd-resolved статус"), + ]), + (["прошивка", "firmware", "обновить роутер"], + "keenetic", [ + ("show version", "Текущая прошивка"), + ("tools firmware check", "Проверить обновления"), + ("tools firmware upgrade", "Обновить прошивку"), + ("show components", "Компоненты"), + ]), + (["сохранить", "save", "конфиг", "config"], + "keenetic", [ + ("system configuration save", "Сохранить конфигурацию"), + ("show running-config", "Текущая конфигурация"), + ("copy running-config startup-config", "Применить конфиг"), + ]), + (["cron", "расписание", "schedule", "автоматически"], + "linux", [ + ("crontab -l", "Показать задачи cron"), + ("crontab -e", "Редактировать cron"), + ("systemctl list-timers", "Systemd-таймеры"), + ("echo '0 3 * * * /path/script.sh' | crontab -", "Запуск каждый день в 3:00"), + ]), + (["nginx", "веб-сервер", "apache", "сайт"], + "linux", [ + ("sudo systemctl status nginx", "Статус nginx"), + ("sudo nginx -t", "Проверить конфиг nginx"), + ("sudo systemctl reload nginx", "Перечитать конфиг"), + ("tail -f /var/log/nginx/error.log", "Ошибки nginx (live)"), + ("tail -f /var/log/nginx/access.log", "Доступ nginx (live)"), + ]), + (["ssl", "сертификат", "certbot", "https", "let's encrypt"], + "linux", [ + ("sudo certbot --nginx -d domain.com", "Получить SSL (nginx)"), + ("sudo certbot renew --dry-run", "Тест обновления сертов"), + ("sudo certbot certificates", "Список сертификатов"), + ("openssl x509 -in cert.pem -noout -dates", "Срок действия серта"), + ]), + (["процесс", "process", "kill", "убить", "завис"], + "linux", [ + ("ps aux | grep process_name", "Найти процесс"), + ("kill -9 PID", "Убить по PID"), + ("pkill -f process_name", "Убить по имени"), + ("top -bn1 | head -20", "Топ процессов"), + ("htop", "Интерактивный мониторинг"), + ]), +] + +def agent_search(query): + query_lower = query.lower() + results = [] + seen = set() + for keywords, platform, cmds in AGENT_KB: + score = sum(1 for kw in keywords if kw in query_lower) + if score > 0: + for cmd, desc in cmds: + if cmd not in seen: + results.append((score, platform, cmd, desc)) + seen.add(cmd) + results.sort(key=lambda x: -x[0]) + return results[:12] + + +def app_dir(): + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).parent + +SESSIONS_FILE = app_dir() / "sessions.json" + +# ── Session Store ────────────────────────────────────────────── + +class SessionStore: + def __init__(self, path=SESSIONS_FILE): + self.path = Path(path) + self.data = {"groups": [], "sessions": []} + self.load() + + @property + def sessions(self): + return self.data["sessions"] + + @property + def groups(self): + return self.data["groups"] + + def load(self): + if self.path.exists(): + try: + raw = json.loads(self.path.read_text("utf-8")) + if isinstance(raw, list): + self.data = {"groups": ["Без группы"], "sessions": []} + for s in raw: + s.setdefault("group", "Без группы") + self.data["sessions"].append(s) + self.save() + else: + self.data = raw + self.data.setdefault("groups", []) + self.data.setdefault("sessions", []) + except Exception: + self.data = {"groups": [], "sessions": []} + + def save(self): + self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), "utf-8") + + def add_group(self, name): + if name and name not in self.groups: + self.groups.append(name) + self.save() + + def rename_group(self, old_name, new_name): + if old_name in self.groups and new_name: + idx = self.groups.index(old_name) + self.groups[idx] = new_name + for s in self.sessions: + if s.get("group") == old_name: + s["group"] = new_name + self.save() + + def remove_group(self, name): + if name in self.groups: + self.groups.remove(name) + for s in self.sessions: + if s.get("group") == name: + s["group"] = "" + self.save() + + def add(self, host, port, user, password, name=None, group=""): + for s in self.sessions: + if s["host"] == host and s["port"] == port and s["user"] == user: + s["password"] = self._encode(password) + s["last_used"] = datetime.now().isoformat() + if name: + s["name"] = name + if group: + s["group"] = group + self.save() + return s + entry = { + "name": name or f"{user}@{host}", + "host": host, + "port": port, + "user": user, + "password": self._encode(password), + "group": group, + "created": datetime.now().isoformat(), + "last_used": datetime.now().isoformat(), + } + self.sessions.append(entry) + if group and group not in self.groups: + self.groups.append(group) + self.save() + return entry + + def remove(self, index): + if 0 <= index < len(self.sessions): + self.sessions.pop(index) + self.save() + + def update(self, index, **kwargs): + if 0 <= index < len(self.sessions): + if "password" in kwargs: + kwargs["password"] = self._encode(kwargs["password"]) + self.sessions[index].update(kwargs) + self.save() + + def move_to_group(self, index, group): + if 0 <= index < len(self.sessions): + self.sessions[index]["group"] = group + self.save() + + def get_password(self, session): + return self._decode(session.get("password", "")) + + @staticmethod + def _encode(pw): + if not pw: + return "" + return base64.b64encode(pw.encode()).decode() + + @staticmethod + def _decode(encoded): + if not encoded: + return "" + try: + return base64.b64decode(encoded.encode()).decode() + except Exception: + return encoded + + +# ── ANSI Parser ──────────────────────────────────────────────── + +ANSI_COLORS = { + "30": "#1a1a1a", "31": "#cc4444", "32": "#44cc44", "33": "#cccc44", + "34": "#4488cc", "35": "#cc44cc", "36": "#44cccc", "37": "#cccccc", + "90": "#666666", "91": "#ff6666", "92": "#66ff66", "93": "#ffff66", + "94": "#6699ff", "95": "#ff66ff", "96": "#66ffff", "97": "#ffffff", +} + +ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[[\?]?[0-9;]*[hlmJKHfGr]") + + +def strip_ansi(text): + return ANSI_RE.sub("", text) + + +# ── SSH Terminal Widget ──────────────────────────────────────── + +class TerminalWidget(tk.Frame): + def __init__(self, master, on_close=None, **kw): + super().__init__(master, **kw) + self.on_close = on_close + self.channel = None + self.ssh = None + self.read_thread = None + self.running = False + + self.text = tk.Text( + self, + bg="#1e1e2e", + fg="#cdd6f4", + insertbackground="#cdd6f4", + selectbackground="#45475a", + font=("Consolas", 11), + wrap=tk.CHAR, + padx=6, + pady=6, + borderwidth=0, + highlightthickness=0, + ) + scrollbar = ttk.Scrollbar(self, command=self.text.yview) + self.text.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.text.pack(fill=tk.BOTH, expand=True) + + self.text.bind("", self._on_key) + self.text.bind("", lambda e: self._send("\r")) + self.text.bind("", lambda e: self._send("\x7f")) + self.text.bind("", lambda e: self._send("\t")) + self.text.bind("", lambda e: self._send("\x1b[A")) + self.text.bind("", lambda e: self._send("\x1b[B")) + self.text.bind("", lambda e: self._send("\x1b[C")) + self.text.bind("", lambda e: self._send("\x1b[D")) + self.text.bind("", lambda e: self._send("\x1b[H")) + self.text.bind("", lambda e: self._send("\x1b[F")) + self.text.bind("", lambda e: self._send("\x1b[3~")) + self.text.bind("", lambda e: self._send("\x03")) + self.text.bind("", lambda e: self._send("\x04")) + self.text.bind("", lambda e: self._send("\x0c")) + self.text.bind("", lambda e: self._send("\x1a")) + + self.text.tag_configure("error", foreground="#f38ba8") + self.text.tag_configure("info", foreground="#89b4fa") + for code, color in ANSI_COLORS.items(): + self.text.tag_configure(f"fg{code}", foreground=color) + + def connect(self, host, port, user, password): + self.running = True + self._write(f"Подключение к {user}@{host}:{port}...\n", "info") + t = threading.Thread(target=self._connect_thread, args=(host, port, user, password), daemon=True) + t.start() + + def _connect_thread(self, host, port, user, password): + try: + self.ssh = paramiko.SSHClient() + self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.ssh.connect( + host, port=port, username=user, password=password, + timeout=10, look_for_keys=False, allow_agent=False, + ) + cols = max(80, self.text.winfo_width() // 8) + rows = max(24, self.text.winfo_height() // 16) + self.channel = self.ssh.invoke_shell( + term="xterm-256color", width=cols, height=rows + ) + self.channel.settimeout(0.1) + self._write(f"Подключено!\n", "info") + self._read_loop() + except Exception as ex: + self._write(f"\nОшибка: {ex}\n", "error") + self.running = False + if self.on_close: + self.after(100, self.on_close) + + def _read_loop(self): + buf = b"" + while self.running and self.channel and not self.channel.closed: + try: + data = self.channel.recv(4096) + if not data: + break + text = data.decode("utf-8", errors="replace") + clean = strip_ansi(text) + self._write(clean) + except socket.timeout: + continue + except Exception: + break + self._write("\n--- Сессия завершена ---\n", "error") + self.running = False + if self.on_close: + self.after(100, self.on_close) + + def _write(self, text, tag=None): + def _do(): + self.text.configure(state=tk.NORMAL) + if tag: + self.text.insert(tk.END, text, tag) + else: + self.text.insert(tk.END, text) + self.text.see(tk.END) + self.after(0, _do) + + def _send(self, data): + if self.channel and not self.channel.closed: + try: + self.channel.send(data.encode() if isinstance(data, str) else data) + except Exception: + pass + return "break" + + def _on_key(self, event): + if event.state & 4: # Ctrl + return + if event.char and ord(event.char) >= 32: + self._send(event.char) + return "break" + return "break" + + def disconnect(self): + self.running = False + if self.channel: + try: + self.channel.close() + except Exception: + pass + if self.ssh: + try: + self.ssh.close() + except Exception: + pass + + +# ── Session Edit Dialog ──────────────────────────────────────── + +class SessionDialog(tk.Toplevel): + def __init__(self, parent, title="Сессия", session=None, groups=None): + super().__init__(parent) + self.title(title) + self.result = None + self.resizable(False, False) + self.grab_set() + + frame = ttk.Frame(self, padding=15) + frame.pack(fill=tk.BOTH, expand=True) + + fields = [ + ("Название:", "name", session.get("name", "") if session else ""), + ("Описание:", "description", session.get("description", "") if session else ""), + ("Хост:", "host", session.get("host", "") if session else ""), + ("Порт:", "port", str(session.get("port", 22)) if session else "22"), + ("Пользователь:", "user", session.get("user", "") if session else "root"), + ("Пароль:", "password", ""), + ] + + self.entries = {} + for i, (label, key, default) in enumerate(fields): + ttk.Label(frame, text=label).grid(row=i, column=0, sticky="w", pady=3) + entry = ttk.Entry(frame, width=35) + if key == "password": + entry.configure(show="*") + entry.insert(0, default) + entry.grid(row=i, column=1, sticky="ew", pady=3, padx=(8, 0)) + self.entries[key] = entry + + # Group selector + row_group = len(fields) + ttk.Label(frame, text="Группа:").grid(row=row_group, column=0, sticky="w", pady=3) + group_frame = ttk.Frame(frame) + group_frame.grid(row=row_group, column=1, sticky="ew", pady=3, padx=(8, 0)) + + group_values = groups or [] + current_group = session.get("group", "") if session else "" + self.group_var = tk.StringVar(value=current_group) + self.group_combo = ttk.Combobox(group_frame, textvariable=self.group_var, + values=group_values, width=20) + self.group_combo.pack(side=tk.LEFT, fill=tk.X, expand=True) + + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=row_group + 1, column=0, columnspan=2, pady=(12, 0)) + ttk.Button(btn_frame, text="OK", command=self._ok).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_frame, text="Отмена", command=self.destroy).pack(side=tk.LEFT, padx=4) + + self.entries["host"].focus_set() + self.bind("", lambda e: self._ok()) + self.bind("", lambda e: self.destroy()) + + self.wait_window() + + def _ok(self): + host = self.entries["host"].get().strip() + if not host: + messagebox.showwarning("Ошибка", "Хост обязателен", parent=self) + return + try: + port = int(self.entries["port"].get().strip() or "22") + except ValueError: + messagebox.showwarning("Ошибка", "Порт — число", parent=self) + return + self.result = { + "name": self.entries["name"].get().strip() or f"{self.entries['user'].get()}@{host}", + "description": self.entries["description"].get().strip(), + "host": host, + "port": port, + "user": self.entries["user"].get().strip() or "root", + "password": self.entries["password"].get(), + "group": self.group_var.get().strip(), + } + self.destroy() + + +# ── Main Application ─────────────────────────────────────────── + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title(f"{APP_NAME} — {APP_SUBTITLE} v{VERSION}") + self.geometry("1100x700") + self.minsize(800, 500) + self.configure(bg="#1e1e2e") + + self.store = SessionStore() + self.terminals = {} # tab_id -> TerminalWidget + + self._apply_theme() + self._build_ui() + self._refresh_session_list() + + def _apply_theme(self): + style = ttk.Style() + style.theme_use("clam") + bg = "#1e1e2e" + fg = "#cdd6f4" + sel = "#45475a" + + style.configure(".", background=bg, foreground=fg, fieldbackground="#313244", borderwidth=0) + style.configure("TFrame", background=bg) + style.configure("TLabel", background=bg, foreground=fg) + style.configure("TButton", background="#45475a", foreground=fg, padding=(10, 5)) + style.map("TButton", background=[("active", "#585b70")]) + style.configure("Treeview", background="#313244", foreground=fg, fieldbackground="#313244", + rowheight=28, borderwidth=0) + style.configure("Treeview.Heading", background="#45475a", foreground=fg) + style.map("Treeview", background=[("selected", sel)]) + style.configure("TEntry", fieldbackground="#313244", foreground=fg) + style.configure("TNotebook", background=bg, borderwidth=0) + style.configure("TNotebook.Tab", background="#45475a", foreground=fg, padding=(12, 6)) + style.map("TNotebook.Tab", background=[("selected", "#585b70")]) + + def _open_url(self, url): + import webbrowser + webbrowser.open(url) + + def _draw_pca_logo(self, canvas, cx, cy, size): + r = size // 2 + canvas.create_oval(cx - r, cy - r, cx + r, cy + r, fill="#2a2a3d", outline="#3b3b5c", width=2) + label_font = ("Arial", size // 3, "bold") + canvas.create_text(cx, cy - 2, text="PCA", fill="#6c8fff", font=label_font) + dots = [(cx - 4, cy + r // 2), (cx, cy + r // 2 + 4), (cx + 4, cy + r // 2), + (cx - 6, cy + r // 2 + 6), (cx, cy + r // 2 + 10), (cx + 6, cy + r // 2 + 6)] + for dx, dy in dots: + canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="") + + def _build_ui(self): + # ── Top: PCA brand bar ── + top_bar = tk.Frame(self, bg="#181825", height=48) + top_bar.pack(fill=tk.X, side=tk.TOP) + top_bar.pack_propagate(False) + + logo_canvas = tk.Canvas(top_bar, width=36, height=36, bg="#181825", highlightthickness=0) + logo_canvas.pack(side=tk.LEFT, padx=(10, 6), pady=6) + self._draw_pca_logo(logo_canvas, 18, 16, 30) + + brand_frame = tk.Frame(top_bar, bg="#181825") + brand_frame.pack(side=tk.LEFT, pady=4) + tk.Label( + brand_frame, text=APP_NAME, bg="#181825", fg="#cdd6f4", + font=("Consolas", 13, "bold"), + ).pack(anchor="w") + tk.Label( + brand_frame, text=APP_SUBTITLE, bg="#181825", fg="#6c7086", + font=("Consolas", 9), + ).pack(anchor="w") + + sep_v = tk.Frame(top_bar, bg="#45475a", width=1) + sep_v.pack(side=tk.LEFT, fill=tk.Y, padx=12, pady=8) + + links = [ + ("GitHub", "https://github.com/nickolay-frolov"), + ("Boosty", "https://boosty.to/lot_andrey"), + ("Telegram", "https://t.me/lot_andrey"), + ("Поддержать (СБП)", "https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798"), + ] + for i, (text, url) in enumerate(links): + if i > 0: + tk.Label(top_bar, text="·", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT) + lnk = tk.Label( + top_bar, text=text, bg="#181825", fg="#89b4fa", + font=("Consolas", 9, "underline"), cursor="hand2", + ) + lnk.pack(side=tk.LEFT, padx=6) + lnk.bind("", lambda e, u=url: self._open_url(u)) + + tk.Label( + top_bar, text="@lot_andrey", bg="#181825", fg="#6c7086", + font=("Consolas", 9), + ).pack(side=tk.RIGHT, padx=10) + + paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL) + paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) + + # ── Left: session list with groups ── + left = ttk.Frame(paned, width=300) + paned.add(left, weight=0) + + toolbar = ttk.Frame(left) + toolbar.pack(fill=tk.X, pady=(0, 2)) + ttk.Button(toolbar, text="+ Сессия", command=self._new_session).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="✎", command=self._edit_session, width=3).pack(side=tk.LEFT, padx=1) + ttk.Button(toolbar, text="✕", command=self._delete_session, width=3).pack(side=tk.LEFT, padx=1) + + toolbar2 = ttk.Frame(left) + toolbar2.pack(fill=tk.X, pady=(0, 4)) + ttk.Button(toolbar2, text="+ Группа", command=self._new_group).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar2, text="✎ Группа", command=self._rename_group).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar2, text="✕ Группа", command=self._delete_group).pack(side=tk.LEFT, padx=2) + + self.tree = ttk.Treeview(left, columns=("desc", "addr"), show="tree headings", selectmode="browse") + self.tree.heading("#0", text="Сессия") + self.tree.heading("desc", text="Описание") + self.tree.heading("addr", text="Адрес") + self.tree.column("#0", width=130, minwidth=80) + self.tree.column("desc", width=100, minwidth=60) + self.tree.column("addr", width=120, minwidth=70) + tree_scroll = ttk.Scrollbar(left, command=self.tree.yview) + self.tree.configure(yscrollcommand=tree_scroll.set) + + tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) + self.tree.pack(fill=tk.BOTH, expand=True) + self.tree.bind("", self._on_tree_double_click) + + # Drag & drop state + self._drag_item = None + self._drag_after_id = None + self.tree.bind("", self._drag_start) + self.tree.bind("", self._drag_motion) + self.tree.bind("", self._drag_end) + + # Right-click context menu + self._ctx_menu = tk.Menu(self, tearoff=0) + self._ctx_menu.configure(bg="#313244", fg="#cdd6f4", activebackground="#45475a") + self.tree.bind("", self._show_context_menu) + if sys.platform == "darwin": + self.tree.bind("", self._show_context_menu) + self.tree.bind("", self._show_context_menu) + + # Quick connect bar + qf = ttk.LabelFrame(left, text="Быстрое подключение", padding=8) + qf.pack(fill=tk.X, pady=(6, 0)) + + row1 = ttk.Frame(qf) + row1.pack(fill=tk.X, pady=2) + ttk.Label(row1, text="Хост:").pack(side=tk.LEFT) + self.q_host = ttk.Entry(row1, width=16) + self.q_host.pack(side=tk.LEFT, padx=(4, 8)) + ttk.Label(row1, text="Порт:").pack(side=tk.LEFT) + self.q_port = ttk.Entry(row1, width=6) + self.q_port.insert(0, "22") + self.q_port.pack(side=tk.LEFT, padx=4) + + row2 = ttk.Frame(qf) + row2.pack(fill=tk.X, pady=2) + ttk.Label(row2, text="Юзер:").pack(side=tk.LEFT) + self.q_user = ttk.Entry(row2, width=12) + self.q_user.insert(0, "root") + self.q_user.pack(side=tk.LEFT, padx=(4, 8)) + ttk.Label(row2, text="Пароль:").pack(side=tk.LEFT) + self.q_pass = ttk.Entry(row2, width=12, show="*") + self.q_pass.pack(side=tk.LEFT, padx=4) + + ttk.Button(qf, text="Подключиться", command=self._quick_connect).pack(fill=tk.X, pady=(6, 0)) + self.q_host.bind("", lambda e: self._quick_connect()) + self.q_pass.bind("", lambda e: self._quick_connect()) + + # ── Center: terminal tabs ── + right_paned = ttk.PanedWindow(paned, orient=tk.HORIZONTAL) + paned.add(right_paned, weight=1) + + self.notebook = ttk.Notebook(right_paned) + right_paned.add(self.notebook, weight=1) + + # ── Right: commands panel ── + cmd_frame = ttk.Frame(right_paned, width=320) + right_paned.add(cmd_frame, weight=0) + + cmd_header = tk.Label( + cmd_frame, text="Команды", bg="#181825", fg="#cdd6f4", + font=("Consolas", 11, "bold"), pady=6, + ) + cmd_header.pack(fill=tk.X) + + # Search bar + search_frame = ttk.Frame(cmd_frame) + search_frame.pack(fill=tk.X, padx=4, pady=4) + ttk.Label(search_frame, text="Поиск:").pack(side=tk.LEFT) + self.cmd_search = ttk.Entry(search_frame, width=20) + self.cmd_search.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 0)) + self.cmd_search.bind("", self._filter_commands) + + # Commands tree + cmd_tree_frame = ttk.Frame(cmd_frame) + cmd_tree_frame.pack(fill=tk.BOTH, expand=True) + + self.cmd_tree = ttk.Treeview( + cmd_tree_frame, columns=("desc",), show="tree headings", + selectmode="browse", + ) + self.cmd_tree.heading("#0", text="Команда") + self.cmd_tree.heading("desc", text="Описание") + self.cmd_tree.column("#0", width=200, minwidth=120) + self.cmd_tree.column("desc", width=180, minwidth=100) + + cmd_scroll = ttk.Scrollbar(cmd_tree_frame, command=self.cmd_tree.yview) + self.cmd_tree.configure(yscrollcommand=cmd_scroll.set) + cmd_scroll.pack(side=tk.RIGHT, fill=tk.Y) + self.cmd_tree.pack(fill=tk.BOTH, expand=True) + + self.cmd_tree.bind("", self._send_command) + + # Hint label + hint = tk.Label( + cmd_frame, text="Двойной клик = отправить в терминал", + bg="#1e1e2e", fg="#6c7086", font=("Consolas", 8), + ) + hint.pack(fill=tk.X, pady=2) + + self._populate_commands() + + # ── Agent tab (under commands) ── + agent_frame = ttk.LabelFrame(cmd_frame, text="Агент-подсказчик", padding=6) + agent_frame.pack(fill=tk.X, padx=4, pady=(4, 2)) + + agent_input_frame = ttk.Frame(agent_frame) + agent_input_frame.pack(fill=tk.X) + self.agent_entry = ttk.Entry(agent_input_frame, width=22) + self.agent_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4)) + self.agent_entry.insert(0, "как обновить ubuntu?") + ttk.Button(agent_input_frame, text="?", command=self._agent_ask, width=3).pack(side=tk.RIGHT) + self.agent_entry.bind("", lambda e: self._agent_ask()) + + self.agent_result = tk.Text( + agent_frame, height=8, bg="#181825", fg="#cdd6f4", + font=("Consolas", 9), wrap=tk.WORD, borderwidth=0, + insertbackground="#cdd6f4", highlightthickness=0, + ) + self.agent_result.pack(fill=tk.X, pady=(4, 0)) + self.agent_result.tag_configure("cmd", foreground="#a6e3a1", font=("Consolas", 9, "bold")) + self.agent_result.tag_configure("desc", foreground="#6c7086") + self.agent_result.tag_configure("platform", foreground="#89b4fa") + self.agent_result.tag_configure("hint", foreground="#f9e2af") + self.agent_result.configure(state=tk.DISABLED) + self.agent_result.bind("", self._agent_send_cmd) + + # Welcome tab + welcome = ttk.Frame(self.notebook) + self.notebook.add(welcome, text="Добро пожаловать") + msg = ttk.Label( + welcome, + text=f"{APP_NAME} v{VERSION}\n\n" + "• Двойной клик по сессии = подключение\n" + "• Быстрое подключение внизу слева\n" + "• Все сессии сохраняются автоматически\n" + "• Ctrl+C/D/L работают в терминале\n" + "• Панель команд справа — клик = отправка\n" + "• Агент внизу справа — спроси что угодно", + font=("Consolas", 12), + justify=tk.CENTER, + ) + msg.pack(expand=True) + + # ── Drag & Drop ── + + def _drag_start(self, event): + item = self.tree.identify_row(event.y) + if item and self.tree.parent(item): + self._drag_item = item + else: + self._drag_item = None + + def _drag_motion(self, event): + if not self._drag_item: + return + target = self.tree.identify_row(event.y) + if target: + self.tree.selection_set(target) + + def _drag_end(self, event): + if not self._drag_item: + return + target = self.tree.identify_row(event.y) + if not target or target == self._drag_item: + self._drag_item = None + return + + src_iid = self._drag_item + src_tags = self.tree.item(src_iid, "tags") + if not src_tags or not src_tags[0].startswith("s_"): + self._drag_item = None + return + src_idx = int(src_tags[0].split("_")[1]) + + # Determine target group + target_tags = self.tree.item(target, "tags") + if target_tags and target_tags[0].startswith("g_"): + new_group = target_tags[0][2:] + elif target_tags and target_tags[0].startswith("s_"): + parent = self.tree.parent(target) + if parent: + ptags = self.tree.item(parent, "tags") + new_group = ptags[0][2:] if ptags and ptags[0].startswith("g_") else "" + else: + new_group = "" + else: + new_group = "" + + if new_group == "__ungrouped__": + new_group = "" + + self.store.move_to_group(src_idx, new_group) + self._refresh_session_list() + self._drag_item = None + + # ── Context Menu ── + + def _show_context_menu(self, event): + item = self.tree.identify_row(event.y) + if not item: + return + self.tree.selection_set(item) + self._ctx_menu.delete(0, tk.END) + + tags = self.tree.item(item, "tags") + if tags and tags[0].startswith("s_"): + idx = int(tags[0].split("_")[1]) + self._ctx_menu.add_command(label="Подключиться", command=lambda: self._connect_by_idx(idx)) + self._ctx_menu.add_separator() + + # Move to group submenu + move_menu = tk.Menu(self._ctx_menu, tearoff=0, bg="#313244", fg="#cdd6f4") + move_menu.add_command(label="Без группы", command=lambda: self._move_session(idx, "")) + for g in self.store.groups: + move_menu.add_command(label=g, command=lambda g=g: self._move_session(idx, g)) + self._ctx_menu.add_cascade(label="Переместить в...", menu=move_menu) + self._ctx_menu.add_separator() + self._ctx_menu.add_command(label="Изменить", command=self._edit_session) + self._ctx_menu.add_command(label="Удалить", command=self._delete_session) + + elif tags and tags[0].startswith("g_"): + gname = tags[0][2:] + if gname != "__ungrouped__": + self._ctx_menu.add_command(label="Переименовать", command=self._rename_group) + self._ctx_menu.add_command(label="Удалить группу", command=self._delete_group) + + self._ctx_menu.tk_popup(event.x_root, event.y_root) + + def _move_session(self, idx, group): + self.store.move_to_group(idx, group) + self._refresh_session_list() + + def _connect_by_idx(self, idx): + s = self.store.sessions[idx] + pw = self.store.get_password(s) + self._open_terminal(s["host"], s["port"], s["user"], pw, s["name"]) + + # ── Agent ── + + def _agent_ask(self): + query = self.agent_entry.get().strip() + if not query: + return + results = agent_search(query) + self.agent_result.configure(state=tk.NORMAL) + self.agent_result.delete("1.0", tk.END) + if not results: + self.agent_result.insert(tk.END, "Не нашёл команд. Попробуй другие слова:\n", "desc") + self.agent_result.insert(tk.END, "обновить, диск, память, порт, пароль,\n", "hint") + self.agent_result.insert(tk.END, "docker, nginx, ssl, vpn, wifi, dhcp", "hint") + else: + self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc") + for _, platform, cmd, desc in results: + tag = "linux" if platform == "linux" else "keenetic" + self.agent_result.insert(tk.END, f"[{platform}] ", "platform") + self.agent_result.insert(tk.END, f"{cmd}\n", "cmd") + self.agent_result.insert(tk.END, f" {desc}\n", "desc") + self.agent_result.configure(state=tk.DISABLED) + + def _agent_send_cmd(self, event): + idx = self.agent_result.index(f"@{event.x},{event.y}") + line = self.agent_result.get(f"{idx} linestart", f"{idx} lineend").strip() + if line.startswith("["): + line = line.split("] ", 1)[-1] if "] " in line else line + if not line or line.startswith("Двойной") or line.startswith("Не нашёл"): + return + current_tab = self.notebook.select() + if not current_tab: + return + widget = self.nametowidget(current_tab) + if isinstance(widget, TerminalWidget) and widget.channel and not widget.channel.closed: + widget._send(line + "\r") + else: + messagebox.showinfo("Нет терминала", "Сначала подключитесь к серверу") + + def _populate_commands(self, filter_text=""): + self.cmd_tree.delete(*self.cmd_tree.get_children()) + ft = filter_text.lower() + for platform, categories in COMMANDS.items(): + platform_id = self.cmd_tree.insert("", tk.END, text=platform, values=("",), open=not ft) + has_children = False + for category, cmds in categories.items(): + cat_id = self.cmd_tree.insert(platform_id, tk.END, text=category, values=("",), open=bool(ft)) + cat_has = False + for cmd, desc in cmds: + if ft and ft not in cmd.lower() and ft not in desc.lower() and ft not in category.lower(): + continue + self.cmd_tree.insert(cat_id, tk.END, text=cmd, values=(desc,)) + cat_has = True + has_children = True + if not cat_has: + self.cmd_tree.delete(cat_id) + if not has_children: + self.cmd_tree.delete(platform_id) + + def _filter_commands(self, event=None): + text = self.cmd_search.get().strip() + self._populate_commands(text) + + def _send_command(self, event=None): + sel = self.cmd_tree.selection() + if not sel: + return + item = sel[0] + cmd = self.cmd_tree.item(item, "text") + children = self.cmd_tree.get_children(item) + if children: + return + current_tab = self.notebook.select() + if not current_tab: + return + widget = self.nametowidget(current_tab) + if isinstance(widget, TerminalWidget) and widget.channel and not widget.channel.closed: + widget._send(cmd + "\r") + else: + messagebox.showinfo("Нет терминала", "Сначала подключитесь к серверу") + + def _refresh_session_list(self): + self.tree.delete(*self.tree.get_children()) + + # Group sessions + grouped = {} + ungrouped = [] + for i, s in enumerate(self.store.sessions): + g = s.get("group", "") + if g: + grouped.setdefault(g, []).append((i, s)) + else: + ungrouped.append((i, s)) + + # Show groups from store order + for g in self.store.groups: + gid = self.tree.insert("", tk.END, text=f" {g}", values=("", ""), + open=True, tags=(f"g_{g}",)) + for i, s in grouped.get(g, []): + desc = s.get("description", "") + self.tree.insert(gid, tk.END, text=s["name"], + values=(desc, f"{s['host']}:{s['port']}"), + tags=(f"s_{i}",)) + + # Groups that exist in sessions but not in store.groups + for g, items in grouped.items(): + if g not in self.store.groups: + self.store.add_group(g) + gid = self.tree.insert("", tk.END, text=f" {g}", values=("", ""), + open=True, tags=(f"g_{g}",)) + for i, s in items: + desc = s.get("description", "") + self.tree.insert(gid, tk.END, text=s["name"], + values=(desc, f"{s['host']}:{s['port']}"), + tags=(f"s_{i}",)) + + # Ungrouped sessions + if ungrouped: + uid = self.tree.insert("", tk.END, text=" Без группы", values=("", ""), + open=True, tags=("g___ungrouped__",)) + for i, s in ungrouped: + desc = s.get("description", "") + self.tree.insert(uid, tk.END, text=s["name"], + values=(desc, f"{s['host']}:{s['port']}"), + tags=(f"s_{i}",)) + + def _get_selected_session_index(self): + sel = self.tree.selection() + if not sel: + return None + tags = self.tree.item(sel[0], "tags") + if tags and tags[0].startswith("s_"): + return int(tags[0].split("_")[1]) + return None + + def _get_selected_group_name(self): + sel = self.tree.selection() + if not sel: + return None + tags = self.tree.item(sel[0], "tags") + if tags and tags[0].startswith("g_"): + name = tags[0][2:] + return name if name != "__ungrouped__" else None + return None + + def _on_tree_double_click(self, event): + idx = self._get_selected_session_index() + if idx is not None: + self._connect_by_idx(idx) + + # ── Group CRUD ── + + def _new_group(self): + from tkinter import simpledialog + name = simpledialog.askstring("Новая группа", "Название группы:", parent=self) + if name and name.strip(): + self.store.add_group(name.strip()) + self._refresh_session_list() + + def _rename_group(self): + old = self._get_selected_group_name() + if not old: + sel = self.tree.selection() + if sel: + tags = self.tree.item(sel[0], "tags") + if tags and tags[0].startswith("s_"): + parent = self.tree.parent(sel[0]) + if parent: + ptags = self.tree.item(parent, "tags") + if ptags and ptags[0].startswith("g_") and ptags[0][2:] != "__ungrouped__": + old = ptags[0][2:] + if not old: + return + from tkinter import simpledialog + new = simpledialog.askstring("Переименовать", f"Новое имя для «{old}»:", parent=self, + initialvalue=old) + if new and new.strip() and new.strip() != old: + self.store.rename_group(old, new.strip()) + self._refresh_session_list() + + def _delete_group(self): + name = self._get_selected_group_name() + if not name: + return + if messagebox.askyesno("Удалить группу?", + f"Удалить группу «{name}»?\nСессии переместятся в «Без группы»."): + self.store.remove_group(name) + self._refresh_session_list() + + # ── Session CRUD ── + + def _new_session(self): + # Pre-select group from tree selection + pregroup = self._get_selected_group_name() or "" + if not pregroup: + idx = self._get_selected_session_index() + if idx is not None: + pregroup = self.store.sessions[idx].get("group", "") + + session_defaults = {"group": pregroup} if pregroup else None + dlg = SessionDialog(self, "Новая сессия", session=session_defaults, + groups=self.store.groups) + if dlg.result: + r = dlg.result + self.store.add(r["host"], r["port"], r["user"], r["password"], + r["name"], r.get("group", "")) + self._refresh_session_list() + + def _edit_session(self): + idx = self._get_selected_session_index() + if idx is None: + return + s = self.store.sessions[idx].copy() + s["password"] = "" + dlg = SessionDialog(self, "Изменить сессию", s, groups=self.store.groups) + if dlg.result: + r = dlg.result + update = {"name": r["name"], "description": r.get("description", ""), + "host": r["host"], "port": r["port"], + "user": r["user"], "group": r.get("group", "")} + if r["password"]: + update["password"] = r["password"] + self.store.update(idx, **update) + self._refresh_session_list() + + def _delete_session(self): + idx = self._get_selected_session_index() + if idx is None: + return + s = self.store.sessions[idx] + if messagebox.askyesno("Удалить?", f"Удалить сессию «{s['name']}»?"): + self.store.remove(idx) + self._refresh_session_list() + + def _quick_connect(self): + host = self.q_host.get().strip() + if not host: + return + try: + port = int(self.q_port.get().strip() or "22") + except ValueError: + port = 22 + user = self.q_user.get().strip() or "root" + pw = self.q_pass.get() + group = self._get_selected_group_name() or "" + + self.store.add(host, port, user, pw, group=group) + self._refresh_session_list() + self._open_terminal(host, port, user, pw) + self.q_pass.delete(0, tk.END) + + def _open_terminal(self, host, port, user, password, name=None): + tab_name = name or f"{user}@{host}" + + def on_close(): + pass + + term = TerminalWidget(self.notebook, on_close=on_close) + self.notebook.add(term, text=f"● {tab_name}") + self.notebook.select(term) + term.connect(host, port, user, password) + + close_btn_frame = ttk.Frame(term) + close_btn = ttk.Button( + close_btn_frame, text="✕ Закрыть вкладку", + command=lambda: self._close_tab(term), + ) + close_btn.pack(side=tk.RIGHT, padx=4, pady=2) + close_btn_frame.pack(fill=tk.X, side=tk.BOTTOM) + + def _close_tab(self, term): + term.disconnect() + self.notebook.forget(term) + term.destroy() + + def destroy(self): + for child in self.notebook.winfo_children(): + if isinstance(child, TerminalWidget): + child.disconnect() + super().destroy() + + +if __name__ == "__main__": + app = App() + app.mainloop()