From 466239a879e98efde7c94627b2702589e862c562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Wed, 20 May 2026 19:04:07 +0300 Subject: [PATCH] feat: PCA SSH Manager v1.1 Portable SSH client with auto-save sessions. - Session groups (drag-and-drop between groups) - 100+ quick commands for Linux and Keenetic routers - Built-in command suggestion agent - Dark theme (Catppuccin), PCA branding - Builds to .exe (Windows) or .app (macOS) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 + build.bat | 23 + full_install.ps1 | 94 +++ gen_icon.py | 100 ++++ install.bat | 17 + pca_ssh.icns | Bin 0 -> 100028 bytes pca_ssh.ico | Bin 0 -> 646 bytes pca_ssh.png | Bin 0 -> 11515 bytes requirements.txt | 2 + ssh_manager.py | 1469 ++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1711 insertions(+) create mode 100644 .gitignore create mode 100644 build.bat create mode 100644 full_install.ps1 create mode 100644 gen_icon.py create mode 100644 install.bat create mode 100644 pca_ssh.icns create mode 100644 pca_ssh.ico create mode 100644 pca_ssh.png create mode 100644 requirements.txt create mode 100644 ssh_manager.py 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 0000000000000000000000000000000000000000..76b4672846e2723ff61b5537b4d60f8a16242dd4 GIT binary patch literal 100028 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 literal 0 HcmV?d00001 diff --git a/pca_ssh.png b/pca_ssh.png new file mode 100644 index 0000000000000000000000000000000000000000..62244dba69742b80cfafcdabe20defa149c26605 GIT binary patch literal 11515 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 literal 0 HcmV?d00001 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()