v1.2.0 + v1.3.0: Localization, About dialog, TOTP/2FA, stability improvements
v1.2.0: - GUI localization (EN/RU/ZH) with language switcher and persistent selection - About dialog (ⓘ) with app info, features, quick start guide - core/i18n.py — internationalization module with t() function - All GUI components translated via t() keys v1.3.0: - TOTP/2FA tab — Google Authenticator compatible codes with live 30s countdown, one-click copy, per-server secret management - core/totp.py — TOTP module (pyotp, RFC 6238) - core/logger.py — rotating file logger (5MB, 3 backups) - Stronger Fernet encryption key with automatic migration from old key - Thread-safe server store with locks, atomic writes, auto-restore on corruption - Parallel status checks via ThreadPoolExecutor (up to 10 concurrent) - SSH client: explicit channel cleanup, Unix key permissions - Server dialog: port validation (1-65535), TOTP secret field - Language change preserves active tab and server selection - pyotp dependency added Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
"""
|
||||
Claude Code integration setup.
|
||||
Installs ssh.py, /ssh skill, SSH key — everything needed for Claude Code
|
||||
to manage servers via the shared servers.json.
|
||||
Installs ssh.py, encryption.py, /ssh skill, SSH key — everything needed
|
||||
for Claude Code to manage servers via the shared servers.json.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
|
||||
SHARED_DIR = os.path.expanduser("~/.server-connections")
|
||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SSH_SCRIPT_SRC = os.path.join(PROJECT_DIR, "tools", "ssh.py")
|
||||
SKILL_SRC = os.path.join(PROJECT_DIR, "tools", "skill-ssh.md")
|
||||
|
||||
# PyInstaller: bundled data is in sys._MEIPASS; otherwise use project dir
|
||||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||
_BASE_DIR = sys._MEIPASS
|
||||
else:
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SSH_SCRIPT_SRC = os.path.join(_BASE_DIR, "tools", "ssh.py")
|
||||
ENCRYPTION_SRC = os.path.join(_BASE_DIR, "core", "encryption.py")
|
||||
SKILL_SRC = os.path.join(_BASE_DIR, "tools", "skill-ssh.md")
|
||||
|
||||
SKILL_DST_DIR = os.path.expanduser("~/.claude/commands")
|
||||
SKILL_DST = os.path.join(SKILL_DST_DIR, "ssh.md")
|
||||
@@ -23,6 +31,7 @@ def check_status() -> dict:
|
||||
"shared_dir": os.path.exists(SHARED_DIR),
|
||||
"servers_json": os.path.exists(os.path.join(SHARED_DIR, "servers.json")),
|
||||
"ssh_script": os.path.exists(os.path.join(SHARED_DIR, "ssh.py")),
|
||||
"encryption": os.path.exists(os.path.join(SHARED_DIR, "encryption.py")),
|
||||
"skill_installed": os.path.exists(SKILL_DST),
|
||||
"ssh_key_exists": os.path.exists(SSH_KEY_PATH),
|
||||
"ssh_key_pub": os.path.exists(SSH_KEY_PATH + ".pub"),
|
||||
@@ -30,16 +39,31 @@ def check_status() -> dict:
|
||||
|
||||
|
||||
def install_ssh_script() -> str:
|
||||
"""Copy ssh.py to shared dir."""
|
||||
"""Copy ssh.py and encryption.py to shared dir."""
|
||||
os.makedirs(SHARED_DIR, exist_ok=True)
|
||||
results = []
|
||||
|
||||
# Copy ssh.py
|
||||
dst = os.path.join(SHARED_DIR, "ssh.py")
|
||||
if os.path.exists(SSH_SCRIPT_SRC):
|
||||
shutil.copy2(SSH_SCRIPT_SRC, dst)
|
||||
return f"ssh.py installed: {dst}"
|
||||
# Fallback: check if already exists in shared dir
|
||||
if os.path.exists(dst):
|
||||
return f"ssh.py already exists: {dst}"
|
||||
return "ERROR: ssh.py source not found"
|
||||
results.append(f"ssh.py installed: {dst}")
|
||||
elif os.path.exists(dst):
|
||||
results.append(f"ssh.py already exists: {dst}")
|
||||
else:
|
||||
results.append("ERROR: ssh.py source not found")
|
||||
|
||||
# Copy encryption.py
|
||||
enc_dst = os.path.join(SHARED_DIR, "encryption.py")
|
||||
if os.path.exists(ENCRYPTION_SRC):
|
||||
shutil.copy2(ENCRYPTION_SRC, enc_dst)
|
||||
results.append(f"encryption.py installed: {enc_dst}")
|
||||
elif os.path.exists(enc_dst):
|
||||
results.append(f"encryption.py already exists: {enc_dst}")
|
||||
else:
|
||||
results.append("ERROR: encryption.py source not found")
|
||||
|
||||
return "\n".join(results)
|
||||
|
||||
|
||||
def install_skill() -> str:
|
||||
|
||||
40
core/encryption.py
Normal file
40
core/encryption.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Encryption module — Fernet symmetric encryption for servers.json.
|
||||
Used by both GUI (ServerStore) and CLI (ssh.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
# Strong hardcoded key (generated from os.urandom(32), base64-encoded)
|
||||
# This is the application-level encryption key — by design it's embedded.
|
||||
ENCRYPTION_KEY = b"xK9mQ2vL7pR4wZ8nB3jF6hT1yD5sA0cE-gU_iO9lMWk="
|
||||
|
||||
# Migration: old key from v1.1.0-1.2.0
|
||||
_OLD_KEY = b"b8iPQzO8_18Y68NluKLQPUTfXVyRsz_BIzTeqfm0aZk="
|
||||
|
||||
_fernet = Fernet(ENCRYPTION_KEY)
|
||||
_fernet_old = Fernet(_OLD_KEY)
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> bytes:
|
||||
"""Encrypt a plaintext string, return Fernet token bytes."""
|
||||
return _fernet.encrypt(plaintext.encode("utf-8"))
|
||||
|
||||
|
||||
def decrypt(data: bytes) -> str:
|
||||
"""Decrypt Fernet token bytes, return plaintext string.
|
||||
Tries new key first, falls back to old key for migration."""
|
||||
try:
|
||||
return _fernet.decrypt(data).decode("utf-8")
|
||||
except InvalidToken:
|
||||
# Try old key for backward compatibility
|
||||
return _fernet_old.decrypt(data).decode("utf-8")
|
||||
|
||||
|
||||
def is_encrypted(data: bytes) -> bool:
|
||||
"""Check if data is a Fernet token (starts with 'gAAAAA') vs plain JSON (starts with '{')."""
|
||||
try:
|
||||
return data.decode("utf-8").strip().startswith("gAAAAA")
|
||||
except UnicodeDecodeError:
|
||||
return True
|
||||
590
core/i18n.py
Normal file
590
core/i18n.py
Normal file
@@ -0,0 +1,590 @@
|
||||
"""
|
||||
Internationalization module — translations for EN/RU/ZH.
|
||||
"""
|
||||
|
||||
LANGUAGES = {"en": "English", "ru": "Русский", "zh": "中文"}
|
||||
|
||||
_current_lang = "en"
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
return _current_lang
|
||||
|
||||
|
||||
def set_language(lang: str):
|
||||
global _current_lang
|
||||
if lang in LANGUAGES:
|
||||
_current_lang = lang
|
||||
|
||||
|
||||
def t(key: str) -> str:
|
||||
"""Return translated string for key. Falls back to English."""
|
||||
text = _TRANSLATIONS.get(_current_lang, {}).get(key)
|
||||
if text is None:
|
||||
text = _TRANSLATIONS["en"].get(key, key)
|
||||
return text
|
||||
|
||||
|
||||
_EN = {
|
||||
# Sidebar
|
||||
"servers": "Servers",
|
||||
"search": "Search...",
|
||||
"add": "+ Add",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
|
||||
# Tabs
|
||||
"terminal": "Terminal",
|
||||
"files": "Files",
|
||||
"info": "Info",
|
||||
"keys": "Keys",
|
||||
"setup": "Setup",
|
||||
|
||||
# About
|
||||
"about": "ⓘ",
|
||||
"about_title": "ServerManager",
|
||||
"about_desc": (
|
||||
"Desktop application for managing remote servers.\n"
|
||||
"SSH terminal, SFTP file transfer, key management,\n"
|
||||
"encrypted credentials, and Claude Code integration."
|
||||
),
|
||||
"about_features_title": "⚡ Features",
|
||||
"about_features": (
|
||||
"• SSH terminal with auto-sudo\n"
|
||||
"• SFTP file transfer with progress\n"
|
||||
"• SSH key management\n"
|
||||
"• TOTP / 2FA (Google Authenticator)\n"
|
||||
"• Encrypted credentials (Fernet)\n"
|
||||
"• Automatic backups\n"
|
||||
"• Claude Code integration"
|
||||
),
|
||||
"about_howto_title": "🚀 Quick Start",
|
||||
"about_howto": (
|
||||
"1. Click \"+ Add\" to add a server\n"
|
||||
"2. Select server → Terminal / Files\n"
|
||||
"3. Setup tab → Claude Code integration"
|
||||
),
|
||||
"version": "Version",
|
||||
"author": "Author",
|
||||
"close": "Close",
|
||||
|
||||
# Language
|
||||
"language": "Language",
|
||||
|
||||
# Delete confirmation
|
||||
"delete_server": "Delete Server",
|
||||
"delete_confirm": "Remove '{alias}'?",
|
||||
|
||||
# Server dialog
|
||||
"add_server": "Add Server",
|
||||
"edit_server": "Edit Server",
|
||||
"alias": "Alias",
|
||||
"ip": "IP / Hostname",
|
||||
"type": "Type",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"notes": "Notes",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"alias_required": "Alias is required",
|
||||
"ip_required": "IP is required",
|
||||
"port_must_be_number": "Port must be a number",
|
||||
"error_prefix": "Error: {msg}",
|
||||
"placeholder_alias": "my-server",
|
||||
"placeholder_ip": "1.2.3.4",
|
||||
"placeholder_port": "22",
|
||||
"placeholder_user": "root",
|
||||
"placeholder_password": "password",
|
||||
"placeholder_notes": "optional description",
|
||||
|
||||
# Terminal
|
||||
"sudo": "sudo",
|
||||
"enter_command": "Enter command...",
|
||||
"run": "Run",
|
||||
"clear": "Clear",
|
||||
"no_server_selected": "[!] No server selected",
|
||||
"server_not_found": "[!] Server '{alias}' not found",
|
||||
|
||||
# Files
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"local": "Local:",
|
||||
"remote": "Remote:",
|
||||
"browse": "Browse",
|
||||
"both_paths_required": "[!] Both paths required",
|
||||
"file_not_found": "[!] File not found: {path}",
|
||||
"upload_ok": "OK: {local} -> {alias}:{remote}",
|
||||
"download_ok": "OK: {alias}:{remote} -> {local}",
|
||||
"placeholder_local_file": "/path/to/local/file",
|
||||
"placeholder_remote_file": "/remote/path/file",
|
||||
"placeholder_save_path": "/path/to/save",
|
||||
|
||||
# Info
|
||||
"no_server_selected_info": "No server selected",
|
||||
"info_alias": "Alias:",
|
||||
"info_ip": "IP:",
|
||||
"info_port": "Port:",
|
||||
"info_user": "User:",
|
||||
"info_type": "Type:",
|
||||
"info_notes": "Notes:",
|
||||
"info_status": "Status:",
|
||||
"edit_server_btn": "Edit Server",
|
||||
|
||||
# Keys
|
||||
"ssh_key": "SSH Key",
|
||||
"key_path": "Path: {path}",
|
||||
"generate_key": "Generate Key",
|
||||
"key_exists": "Key exists",
|
||||
"no_key_found": "No key found. Click 'Generate Key' to create one.",
|
||||
"install_on_server": "Install on Server",
|
||||
"installing": "Installing...",
|
||||
"copy_public_key": "Copy Public Key",
|
||||
"key_copied": "Public key copied to clipboard",
|
||||
"no_public_key": "[!] No public key to copy",
|
||||
|
||||
# Setup
|
||||
"claude_integration": "Claude Code Integration",
|
||||
"claude_desc": (
|
||||
"Setup everything so Claude Code can manage your servers via /ssh skill.\n"
|
||||
"Both GUI and Claude Code share the same servers.json — add a server here,\n"
|
||||
"Claude sees it immediately."
|
||||
),
|
||||
"status": "Status",
|
||||
"status_shared_dir": "Shared config dir (~/.server-connections)",
|
||||
"status_servers_json": "servers.json",
|
||||
"status_ssh_script": "ssh.py (CLI tool)",
|
||||
"status_encryption": "Encryption module",
|
||||
"status_skill": "/ssh skill for Claude Code",
|
||||
"status_ssh_key": "SSH key (ed25519)",
|
||||
"install_everything": "Install Everything",
|
||||
"installing_all": "Installing...",
|
||||
"install_ssh_py": "ssh.py",
|
||||
"install_skill": "/ssh skill",
|
||||
"install_ssh_key": "SSH key",
|
||||
"refresh": "Refresh",
|
||||
"configuration": "Configuration",
|
||||
"config_label": "Config:",
|
||||
"change_path": "Change Path",
|
||||
"backup_now": "Backup Now",
|
||||
"select_backup": "Select backup...",
|
||||
"no_backups": "No backups",
|
||||
"restore": "Restore",
|
||||
"install_done": "Done! Claude Code can now use /ssh to manage your servers.",
|
||||
"config_changed": "Config path changed: {path}",
|
||||
"backup_created": "Backup created: {name}",
|
||||
"backup_failed": "Backup failed: {e}",
|
||||
"no_backup_selected": "No backup selected.",
|
||||
"restore_backup_title": "Restore Backup",
|
||||
"restore_confirm": "Restore from '{name}'?\nCurrent data will be overwritten.",
|
||||
"restored": "Restored from: {name}",
|
||||
"restore_failed": "Restore failed: {e}",
|
||||
"select_servers_json": "Select servers.json",
|
||||
|
||||
# TOTP / 2FA
|
||||
"totp": "2FA",
|
||||
"totp_title": "Two-Factor Authentication (TOTP)",
|
||||
"totp_desc": (
|
||||
"Google Authenticator compatible 2FA codes.\n"
|
||||
"Add a TOTP secret to any server — the code refreshes every 30 seconds.\n"
|
||||
"Click the code to copy it to clipboard."
|
||||
),
|
||||
"totp_copy": "Copy Code",
|
||||
"totp_secret_label": "TOTP Secret (Base32)",
|
||||
"totp_secret_placeholder": "JBSWY3DPEHPK3PXP...",
|
||||
"totp_save_secret": "Save",
|
||||
"totp_remove_secret": "Remove",
|
||||
"totp_generate_secret": "Generate Random Secret",
|
||||
"totp_no_secret": "No TOTP secret configured",
|
||||
"totp_remaining": "{sec}s remaining",
|
||||
"totp_copied": "Code copied to clipboard",
|
||||
"totp_no_code": "No code to copy",
|
||||
"totp_secret_empty": "Secret cannot be empty",
|
||||
"totp_secret_invalid": "Invalid TOTP secret (must be Base32)",
|
||||
"totp_secret_saved": "TOTP secret saved",
|
||||
"totp_secret_removed": "TOTP secret removed",
|
||||
"totp_secret_generated": "Random secret generated (click Save to store)",
|
||||
"totp_secret_dialog": "TOTP Secret",
|
||||
"placeholder_totp_secret": "Base32 secret (optional)",
|
||||
"port_out_of_range": "Port must be 1-65535",
|
||||
}
|
||||
|
||||
_RU = {
|
||||
# Sidebar
|
||||
"servers": "Серверы",
|
||||
"search": "Поиск...",
|
||||
"add": "+ Добавить",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
|
||||
# Tabs
|
||||
"terminal": "Терминал",
|
||||
"files": "Файлы",
|
||||
"info": "Инфо",
|
||||
"keys": "Ключи",
|
||||
"setup": "Настройка",
|
||||
|
||||
# About
|
||||
"about": "ⓘ",
|
||||
"about_title": "ServerManager",
|
||||
"about_desc": (
|
||||
"Настольное приложение для управления удалёнными серверами.\n"
|
||||
"SSH-терминал, SFTP-передача файлов, управление ключами,\n"
|
||||
"шифрование паролей и интеграция с Claude Code."
|
||||
),
|
||||
"about_features_title": "⚡ Возможности",
|
||||
"about_features": (
|
||||
"• SSH-терминал с авто-sudo\n"
|
||||
"• SFTP-передача файлов с прогрессом\n"
|
||||
"• Управление SSH-ключами\n"
|
||||
"• TOTP / 2FA (Google Authenticator)\n"
|
||||
"• Шифрование паролей (Fernet)\n"
|
||||
"• Автоматические бэкапы\n"
|
||||
"• Интеграция с Claude Code"
|
||||
),
|
||||
"about_howto_title": "🚀 Быстрый старт",
|
||||
"about_howto": (
|
||||
"1. Нажмите \"+ Добавить\" для добавления сервера\n"
|
||||
"2. Выберите сервер → Терминал / Файлы\n"
|
||||
"3. Вкладка Настройка → интеграция Claude Code"
|
||||
),
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"close": "Закрыть",
|
||||
|
||||
# Language
|
||||
"language": "Язык",
|
||||
|
||||
# Delete confirmation
|
||||
"delete_server": "Удалить сервер",
|
||||
"delete_confirm": "Удалить '{alias}'?",
|
||||
|
||||
# Server dialog
|
||||
"add_server": "Добавить сервер",
|
||||
"edit_server": "Изменить сервер",
|
||||
"alias": "Алиас",
|
||||
"ip": "IP / Хост",
|
||||
"type": "Тип",
|
||||
"port": "Порт",
|
||||
"username": "Пользователь",
|
||||
"password": "Пароль",
|
||||
"notes": "Заметки",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"show": "Показать",
|
||||
"hide": "Скрыть",
|
||||
"alias_required": "Алиас обязателен",
|
||||
"ip_required": "IP обязателен",
|
||||
"port_must_be_number": "Порт должен быть числом",
|
||||
"error_prefix": "Ошибка: {msg}",
|
||||
"placeholder_alias": "мой-сервер",
|
||||
"placeholder_ip": "1.2.3.4",
|
||||
"placeholder_port": "22",
|
||||
"placeholder_user": "root",
|
||||
"placeholder_password": "пароль",
|
||||
"placeholder_notes": "описание (необязательно)",
|
||||
|
||||
# Terminal
|
||||
"sudo": "sudo",
|
||||
"enter_command": "Введите команду...",
|
||||
"run": "Выполнить",
|
||||
"clear": "Очистить",
|
||||
"no_server_selected": "[!] Сервер не выбран",
|
||||
"server_not_found": "[!] Сервер '{alias}' не найден",
|
||||
|
||||
# Files
|
||||
"upload": "Загрузить",
|
||||
"download": "Скачать",
|
||||
"local": "Локальный:",
|
||||
"remote": "Удалённый:",
|
||||
"browse": "Обзор",
|
||||
"both_paths_required": "[!] Оба пути обязательны",
|
||||
"file_not_found": "[!] Файл не найден: {path}",
|
||||
"upload_ok": "OK: {local} -> {alias}:{remote}",
|
||||
"download_ok": "OK: {alias}:{remote} -> {local}",
|
||||
"placeholder_local_file": "/путь/к/локальному/файлу",
|
||||
"placeholder_remote_file": "/удалённый/путь/файл",
|
||||
"placeholder_save_path": "/путь/для/сохранения",
|
||||
|
||||
# Info
|
||||
"no_server_selected_info": "Сервер не выбран",
|
||||
"info_alias": "Алиас:",
|
||||
"info_ip": "IP:",
|
||||
"info_port": "Порт:",
|
||||
"info_user": "Пользователь:",
|
||||
"info_type": "Тип:",
|
||||
"info_notes": "Заметки:",
|
||||
"info_status": "Статус:",
|
||||
"edit_server_btn": "Изменить сервер",
|
||||
|
||||
# Keys
|
||||
"ssh_key": "SSH-ключ",
|
||||
"key_path": "Путь: {path}",
|
||||
"generate_key": "Создать ключ",
|
||||
"key_exists": "Ключ существует",
|
||||
"no_key_found": "Ключ не найден. Нажмите 'Создать ключ'.",
|
||||
"install_on_server": "Установить на сервер",
|
||||
"installing": "Установка...",
|
||||
"copy_public_key": "Копировать ключ",
|
||||
"key_copied": "Публичный ключ скопирован",
|
||||
"no_public_key": "[!] Нет публичного ключа",
|
||||
|
||||
# Setup
|
||||
"claude_integration": "Интеграция с Claude Code",
|
||||
"claude_desc": (
|
||||
"Настройте всё, чтобы Claude Code мог управлять серверами через скилл /ssh.\n"
|
||||
"GUI и Claude Code используют один servers.json — добавьте сервер здесь,\n"
|
||||
"Claude увидит его сразу."
|
||||
),
|
||||
"status": "Статус",
|
||||
"status_shared_dir": "Общий каталог (~/.server-connections)",
|
||||
"status_servers_json": "servers.json",
|
||||
"status_ssh_script": "ssh.py (CLI-утилита)",
|
||||
"status_encryption": "Модуль шифрования",
|
||||
"status_skill": "Скилл /ssh для Claude Code",
|
||||
"status_ssh_key": "SSH-ключ (ed25519)",
|
||||
"install_everything": "Установить всё",
|
||||
"installing_all": "Установка...",
|
||||
"install_ssh_py": "ssh.py",
|
||||
"install_skill": "Скилл /ssh",
|
||||
"install_ssh_key": "SSH-ключ",
|
||||
"refresh": "Обновить",
|
||||
"configuration": "Конфигурация",
|
||||
"config_label": "Конфиг:",
|
||||
"change_path": "Изменить путь",
|
||||
"backup_now": "Бэкап сейчас",
|
||||
"select_backup": "Выберите бэкап...",
|
||||
"no_backups": "Нет бэкапов",
|
||||
"restore": "Восстановить",
|
||||
"install_done": "Готово! Claude Code теперь может использовать /ssh для управления серверами.",
|
||||
"config_changed": "Путь конфига изменён: {path}",
|
||||
"backup_created": "Бэкап создан: {name}",
|
||||
"backup_failed": "Ошибка бэкапа: {e}",
|
||||
"no_backup_selected": "Бэкап не выбран.",
|
||||
"restore_backup_title": "Восстановление бэкапа",
|
||||
"restore_confirm": "Восстановить из '{name}'?\nТекущие данные будут перезаписаны.",
|
||||
"restored": "Восстановлено из: {name}",
|
||||
"restore_failed": "Ошибка восстановления: {e}",
|
||||
"select_servers_json": "Выберите servers.json",
|
||||
|
||||
# TOTP / 2FA
|
||||
"totp": "2FA",
|
||||
"totp_title": "Двухфакторная аутентификация (TOTP)",
|
||||
"totp_desc": (
|
||||
"Коды 2FA, совместимые с Google Authenticator.\n"
|
||||
"Добавьте TOTP-секрет к серверу — код обновляется каждые 30 секунд.\n"
|
||||
"Нажмите на код, чтобы скопировать в буфер обмена."
|
||||
),
|
||||
"totp_copy": "Копировать код",
|
||||
"totp_secret_label": "TOTP-секрет (Base32)",
|
||||
"totp_secret_placeholder": "JBSWY3DPEHPK3PXP...",
|
||||
"totp_save_secret": "Сохранить",
|
||||
"totp_remove_secret": "Удалить",
|
||||
"totp_generate_secret": "Сгенерировать секрет",
|
||||
"totp_no_secret": "TOTP-секрет не настроен",
|
||||
"totp_remaining": "Осталось {sec}с",
|
||||
"totp_copied": "Код скопирован в буфер обмена",
|
||||
"totp_no_code": "Нет кода для копирования",
|
||||
"totp_secret_empty": "Секрет не может быть пустым",
|
||||
"totp_secret_invalid": "Недопустимый TOTP-секрет (должен быть Base32)",
|
||||
"totp_secret_saved": "TOTP-секрет сохранён",
|
||||
"totp_secret_removed": "TOTP-секрет удалён",
|
||||
"totp_secret_generated": "Случайный секрет создан (нажмите Сохранить)",
|
||||
"totp_secret_dialog": "TOTP-секрет",
|
||||
"placeholder_totp_secret": "Base32 секрет (необязательно)",
|
||||
"port_out_of_range": "Порт должен быть от 1 до 65535",
|
||||
}
|
||||
|
||||
_ZH = {
|
||||
# Sidebar
|
||||
"servers": "服务器",
|
||||
"search": "搜索...",
|
||||
"add": "+ 添加",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
|
||||
# Tabs
|
||||
"terminal": "终端",
|
||||
"files": "文件",
|
||||
"info": "信息",
|
||||
"keys": "密钥",
|
||||
"setup": "设置",
|
||||
|
||||
# About
|
||||
"about": "ⓘ",
|
||||
"about_title": "ServerManager",
|
||||
"about_desc": (
|
||||
"用于管理远程服务器的桌面应用程序。\n"
|
||||
"SSH终端、SFTP文件传输、密钥管理、\n"
|
||||
"凭据加密以及Claude Code集成。"
|
||||
),
|
||||
"about_features_title": "⚡ 功能特点",
|
||||
"about_features": (
|
||||
"• SSH终端(自动sudo)\n"
|
||||
"• SFTP文件传输(含进度条)\n"
|
||||
"• SSH密钥管理\n"
|
||||
"• TOTP / 2FA(Google Authenticator)\n"
|
||||
"• 凭据加密(Fernet)\n"
|
||||
"• 自动备份\n"
|
||||
"• Claude Code集成"
|
||||
),
|
||||
"about_howto_title": "🚀 快速开始",
|
||||
"about_howto": (
|
||||
"1. 点击\"+ 添加\"来添加服务器\n"
|
||||
"2. 选择服务器 → 终端 / 文件\n"
|
||||
"3. 设置标签 → Claude Code集成"
|
||||
),
|
||||
"version": "版本",
|
||||
"author": "作者",
|
||||
"close": "关闭",
|
||||
|
||||
# Language
|
||||
"language": "语言",
|
||||
|
||||
# Delete confirmation
|
||||
"delete_server": "删除服务器",
|
||||
"delete_confirm": "删除 '{alias}'?",
|
||||
|
||||
# Server dialog
|
||||
"add_server": "添加服务器",
|
||||
"edit_server": "编辑服务器",
|
||||
"alias": "别名",
|
||||
"ip": "IP / 主机名",
|
||||
"type": "类型",
|
||||
"port": "端口",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"notes": "备注",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"show": "显示",
|
||||
"hide": "隐藏",
|
||||
"alias_required": "别名不能为空",
|
||||
"ip_required": "IP不能为空",
|
||||
"port_must_be_number": "端口必须是数字",
|
||||
"error_prefix": "错误:{msg}",
|
||||
"placeholder_alias": "我的服务器",
|
||||
"placeholder_ip": "1.2.3.4",
|
||||
"placeholder_port": "22",
|
||||
"placeholder_user": "root",
|
||||
"placeholder_password": "密码",
|
||||
"placeholder_notes": "可选描述",
|
||||
|
||||
# Terminal
|
||||
"sudo": "sudo",
|
||||
"enter_command": "输入命令...",
|
||||
"run": "执行",
|
||||
"clear": "清除",
|
||||
"no_server_selected": "[!] 未选择服务器",
|
||||
"server_not_found": "[!] 未找到服务器 '{alias}'",
|
||||
|
||||
# Files
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"local": "本地:",
|
||||
"remote": "远程:",
|
||||
"browse": "浏览",
|
||||
"both_paths_required": "[!] 两个路径都必须填写",
|
||||
"file_not_found": "[!] 文件未找到:{path}",
|
||||
"upload_ok": "OK: {local} -> {alias}:{remote}",
|
||||
"download_ok": "OK: {alias}:{remote} -> {local}",
|
||||
"placeholder_local_file": "/本地/文件/路径",
|
||||
"placeholder_remote_file": "/远程/路径/文件",
|
||||
"placeholder_save_path": "/保存/路径",
|
||||
|
||||
# Info
|
||||
"no_server_selected_info": "未选择服务器",
|
||||
"info_alias": "别名:",
|
||||
"info_ip": "IP:",
|
||||
"info_port": "端口:",
|
||||
"info_user": "用户:",
|
||||
"info_type": "类型:",
|
||||
"info_notes": "备注:",
|
||||
"info_status": "状态:",
|
||||
"edit_server_btn": "编辑服务器",
|
||||
|
||||
# Keys
|
||||
"ssh_key": "SSH密钥",
|
||||
"key_path": "路径:{path}",
|
||||
"generate_key": "生成密钥",
|
||||
"key_exists": "密钥已存在",
|
||||
"no_key_found": "未找到密钥。点击'生成密钥'来创建。",
|
||||
"install_on_server": "安装到服务器",
|
||||
"installing": "安装中...",
|
||||
"copy_public_key": "复制公钥",
|
||||
"key_copied": "公钥已复制到剪贴板",
|
||||
"no_public_key": "[!] 没有公钥可复制",
|
||||
|
||||
# Setup
|
||||
"claude_integration": "Claude Code集成",
|
||||
"claude_desc": (
|
||||
"设置一切以便Claude Code通过/ssh技能管理您的服务器。\n"
|
||||
"GUI和Claude Code共享同一个servers.json — 在此添加服务器,\n"
|
||||
"Claude会立即看到。"
|
||||
),
|
||||
"status": "状态",
|
||||
"status_shared_dir": "共享配置目录 (~/.server-connections)",
|
||||
"status_servers_json": "servers.json",
|
||||
"status_ssh_script": "ssh.py(CLI工具)",
|
||||
"status_encryption": "加密模块",
|
||||
"status_skill": "Claude Code的/ssh技能",
|
||||
"status_ssh_key": "SSH密钥(ed25519)",
|
||||
"install_everything": "全部安装",
|
||||
"installing_all": "安装中...",
|
||||
"install_ssh_py": "ssh.py",
|
||||
"install_skill": "/ssh技能",
|
||||
"install_ssh_key": "SSH密钥",
|
||||
"refresh": "刷新",
|
||||
"configuration": "配置",
|
||||
"config_label": "配置:",
|
||||
"change_path": "更改路径",
|
||||
"backup_now": "立即备份",
|
||||
"select_backup": "选择备份...",
|
||||
"no_backups": "无备份",
|
||||
"restore": "恢复",
|
||||
"install_done": "完成!Claude Code现在可以使用/ssh来管理您的服务器。",
|
||||
"config_changed": "配置路径已更改:{path}",
|
||||
"backup_created": "备份已创建:{name}",
|
||||
"backup_failed": "备份失败:{e}",
|
||||
"no_backup_selected": "未选择备份。",
|
||||
"restore_backup_title": "恢复备份",
|
||||
"restore_confirm": "从 '{name}' 恢复?\n当前数据将被覆盖。",
|
||||
"restored": "已从 {name} 恢复",
|
||||
"restore_failed": "恢复失败:{e}",
|
||||
"select_servers_json": "选择servers.json",
|
||||
|
||||
# TOTP / 2FA
|
||||
"totp": "2FA",
|
||||
"totp_title": "双因素认证(TOTP)",
|
||||
"totp_desc": (
|
||||
"兼容Google Authenticator的2FA验证码。\n"
|
||||
"为服务器添加TOTP密钥 — 验证码每30秒自动刷新。\n"
|
||||
"点击验证码即可复制到剪贴板。"
|
||||
),
|
||||
"totp_copy": "复制验证码",
|
||||
"totp_secret_label": "TOTP密钥(Base32)",
|
||||
"totp_secret_placeholder": "JBSWY3DPEHPK3PXP...",
|
||||
"totp_save_secret": "保存",
|
||||
"totp_remove_secret": "删除",
|
||||
"totp_generate_secret": "生成随机密钥",
|
||||
"totp_no_secret": "未配置TOTP密钥",
|
||||
"totp_remaining": "剩余 {sec}秒",
|
||||
"totp_copied": "验证码已复制到剪贴板",
|
||||
"totp_no_code": "没有可复制的验证码",
|
||||
"totp_secret_empty": "密钥不能为空",
|
||||
"totp_secret_invalid": "无效的TOTP密钥(必须是Base32格式)",
|
||||
"totp_secret_saved": "TOTP密钥已保存",
|
||||
"totp_secret_removed": "TOTP密钥已删除",
|
||||
"totp_secret_generated": "已生成随机密钥(点击保存以存储)",
|
||||
"totp_secret_dialog": "TOTP密钥",
|
||||
"placeholder_totp_secret": "Base32密钥(可选)",
|
||||
"port_out_of_range": "端口必须在1-65535之间",
|
||||
}
|
||||
|
||||
_TRANSLATIONS = {
|
||||
"en": _EN,
|
||||
"ru": _RU,
|
||||
"zh": _ZH,
|
||||
}
|
||||
36
core/logger.py
Normal file
36
core/logger.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Logging framework — rotating file log + console.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import logging.handlers
|
||||
|
||||
SHARED_DIR = os.path.expanduser("~/.server-connections")
|
||||
LOG_FILE = os.path.join(SHARED_DIR, "app.log")
|
||||
|
||||
|
||||
def get_logger(name: str = "servermanager") -> logging.Logger:
|
||||
"""Get or create a named logger with file + console handlers."""
|
||||
logger = logging.getLogger(name)
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# File handler — rotating, 5MB max, 3 backups
|
||||
os.makedirs(SHARED_DIR, exist_ok=True)
|
||||
fh = logging.handlers.RotatingFileHandler(
|
||||
LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
))
|
||||
logger.addHandler(fh)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
log = get_logger()
|
||||
@@ -1,15 +1,25 @@
|
||||
"""
|
||||
Server store — CRUD + JSON persistence + observer pattern.
|
||||
Supports encryption, backups, and configurable config path.
|
||||
Thread-safe with atomic writes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.encryption import encrypt, decrypt, is_encrypted
|
||||
from core.logger import log
|
||||
|
||||
# Shared config — same file used by ssh.py and Claude Code /ssh skill
|
||||
SHARED_DIR = os.path.expanduser("~/.server-connections")
|
||||
SERVERS_FILE = os.path.join(SHARED_DIR, "servers.json")
|
||||
SETTINGS_FILE = os.path.join(SHARED_DIR, "settings.json")
|
||||
DEFAULT_SERVERS_FILE = os.path.join(SHARED_DIR, "servers.json")
|
||||
BACKUP_DIR = os.path.join(SHARED_DIR, "backups")
|
||||
|
||||
# Fallback: local config dir (for example file)
|
||||
LOCAL_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
||||
@@ -26,27 +36,202 @@ DEFAULT_PORTS = {
|
||||
"postgresql": 5432,
|
||||
}
|
||||
|
||||
# Auto-backup interval: 10 minutes
|
||||
_BACKUP_INTERVAL = 600
|
||||
|
||||
|
||||
class ServerStore:
|
||||
def __init__(self):
|
||||
self._data: dict = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}}
|
||||
self._observers: list[Callable] = []
|
||||
self._statuses: dict[str, str] = {} # alias -> "online" | "offline" | "unknown"
|
||||
self._statuses_lock = threading.Lock()
|
||||
self._file_lock = threading.Lock()
|
||||
self._last_backup_time: float = 0
|
||||
self._servers_file: str = DEFAULT_SERVERS_FILE
|
||||
self._load_settings()
|
||||
self._load()
|
||||
|
||||
# ── Settings ──────────────────────────────────────
|
||||
|
||||
def _load_settings(self):
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
try:
|
||||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||
settings = json.load(f)
|
||||
path = settings.get("servers_path", "")
|
||||
if path and os.path.exists(path):
|
||||
self._servers_file = path
|
||||
# Load language preference
|
||||
from core import i18n
|
||||
lang = settings.get("language", "en")
|
||||
i18n.set_language(lang)
|
||||
except json.JSONDecodeError:
|
||||
log.warning("Corrupted settings.json, using defaults")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to load settings: {e}")
|
||||
|
||||
def _save_settings(self):
|
||||
os.makedirs(SHARED_DIR, exist_ok=True)
|
||||
from core import i18n
|
||||
settings = {
|
||||
"servers_path": self._servers_file,
|
||||
"language": i18n.get_language(),
|
||||
}
|
||||
try:
|
||||
tmp = SETTINGS_FILE + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(settings, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp, SETTINGS_FILE)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to save settings: {e}")
|
||||
|
||||
def get_config_path(self) -> str:
|
||||
return self._servers_file
|
||||
|
||||
def set_config_path(self, path: str):
|
||||
self._servers_file = path
|
||||
self._save_settings()
|
||||
self._load()
|
||||
self._notify()
|
||||
|
||||
# ── Load / Save (encrypted, thread-safe, atomic) ──
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(SERVERS_FILE):
|
||||
with open(SERVERS_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
with self._file_lock:
|
||||
self._load_unsafe()
|
||||
|
||||
def _load_unsafe(self):
|
||||
if os.path.exists(self._servers_file):
|
||||
try:
|
||||
with open(self._servers_file, "rb") as f:
|
||||
raw = f.read()
|
||||
if not raw.strip():
|
||||
return
|
||||
if is_encrypted(raw):
|
||||
text = decrypt(raw)
|
||||
self._data = json.loads(text)
|
||||
else:
|
||||
self._data = json.loads(raw.decode("utf-8"))
|
||||
# Auto-migration: backup plain file, then encrypt
|
||||
pre_enc = os.path.join(BACKUP_DIR, "servers_pre-encryption.json")
|
||||
if not os.path.exists(pre_enc):
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
shutil.copy2(self._servers_file, pre_enc)
|
||||
self._save_unsafe()
|
||||
# Re-encrypt with new key if needed (migration from old key)
|
||||
self._save_unsafe()
|
||||
except json.JSONDecodeError as e:
|
||||
log.error(f"Corrupted servers.json: {e}")
|
||||
self._try_restore_from_backup()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to load servers: {e}")
|
||||
self._try_restore_from_backup()
|
||||
elif os.path.exists(EXAMPLE_FILE):
|
||||
with open(EXAMPLE_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
self._save()
|
||||
try:
|
||||
with open(EXAMPLE_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
self._save_unsafe()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to load example: {e}")
|
||||
|
||||
def _try_restore_from_backup(self):
|
||||
"""Attempt to restore from latest backup on corruption."""
|
||||
backups = self.list_backups()
|
||||
if backups:
|
||||
log.warning(f"Attempting restore from backup: {backups[0]}")
|
||||
try:
|
||||
src = os.path.join(BACKUP_DIR, backups[0])
|
||||
with open(src, "rb") as f:
|
||||
raw = f.read()
|
||||
if is_encrypted(raw):
|
||||
text = decrypt(raw)
|
||||
self._data = json.loads(text)
|
||||
else:
|
||||
self._data = json.loads(raw.decode("utf-8"))
|
||||
self._save_unsafe()
|
||||
log.info("Restored from backup successfully")
|
||||
except Exception as e2:
|
||||
log.error(f"Backup restore also failed: {e2}")
|
||||
self._data = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}}
|
||||
else:
|
||||
log.warning("No backups available, starting fresh")
|
||||
self._data = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}}
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(SHARED_DIR, exist_ok=True)
|
||||
with open(SERVERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2, ensure_ascii=False)
|
||||
with self._file_lock:
|
||||
self._save_unsafe()
|
||||
|
||||
def _save_unsafe(self):
|
||||
"""Write encrypted data atomically (tmp + rename)."""
|
||||
os.makedirs(os.path.dirname(self._servers_file), exist_ok=True)
|
||||
text = json.dumps(self._data, indent=2, ensure_ascii=False)
|
||||
encrypted = encrypt(text)
|
||||
tmp = self._servers_file + ".tmp"
|
||||
try:
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(encrypted)
|
||||
os.replace(tmp, self._servers_file)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to save servers: {e}")
|
||||
# Clean up temp file
|
||||
if os.path.exists(tmp):
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
# Auto-backup
|
||||
now = time.time()
|
||||
if now - self._last_backup_time >= _BACKUP_INTERVAL:
|
||||
self._auto_backup()
|
||||
|
||||
def _auto_backup(self):
|
||||
try:
|
||||
self.create_backup()
|
||||
except Exception as e:
|
||||
log.warning(f"Auto-backup failed: {e}")
|
||||
|
||||
# ── Backups ───────────────────────────────────────
|
||||
|
||||
def create_backup(self) -> str:
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
name = f"servers_{stamp}.json"
|
||||
dst = os.path.join(BACKUP_DIR, name)
|
||||
shutil.copy2(self._servers_file, dst)
|
||||
self._last_backup_time = time.time()
|
||||
log.info(f"Backup created: {name}")
|
||||
return name
|
||||
|
||||
def list_backups(self) -> list[str]:
|
||||
if not os.path.isdir(BACKUP_DIR):
|
||||
return []
|
||||
files = [f for f in os.listdir(BACKUP_DIR) if f.startswith("servers_") and f.endswith(".json")]
|
||||
files.sort(reverse=True)
|
||||
return files
|
||||
|
||||
def restore_backup(self, filename: str):
|
||||
src = os.path.join(BACKUP_DIR, filename)
|
||||
if not os.path.exists(src):
|
||||
raise FileNotFoundError(f"Backup not found: {filename}")
|
||||
# Validate backup before restoring
|
||||
with open(src, "rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
if is_encrypted(raw):
|
||||
text = decrypt(raw)
|
||||
data = json.loads(text)
|
||||
else:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Backup is corrupted: {e}")
|
||||
self._data = data
|
||||
self._save()
|
||||
self._notify()
|
||||
log.info(f"Restored from: {filename}")
|
||||
|
||||
# ── Observer ──────────────────────────────────────
|
||||
|
||||
def _notify(self):
|
||||
for cb in self._observers:
|
||||
@@ -58,6 +243,8 @@ class ServerStore:
|
||||
def subscribe(self, callback: Callable):
|
||||
self._observers.append(callback)
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────
|
||||
|
||||
def get_all(self) -> list[dict]:
|
||||
return list(self._data.get("servers", []))
|
||||
|
||||
@@ -86,7 +273,8 @@ class ServerStore:
|
||||
|
||||
def remove_server(self, alias: str):
|
||||
self._data["servers"] = [s for s in self._data.get("servers", []) if s["alias"] != alias]
|
||||
self._statuses.pop(alias, None)
|
||||
with self._statuses_lock:
|
||||
self._statuses.pop(alias, None)
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
@@ -94,9 +282,12 @@ class ServerStore:
|
||||
path = self._data.get("ssh_key", {}).get("path", "~/.ssh/id_ed25519")
|
||||
return os.path.expanduser(path)
|
||||
|
||||
# Status management
|
||||
# ── Status management (thread-safe) ───────────────
|
||||
|
||||
def set_status(self, alias: str, status: str):
|
||||
self._statuses[alias] = status
|
||||
with self._statuses_lock:
|
||||
self._statuses[alias] = status
|
||||
|
||||
def get_status(self, alias: str) -> str:
|
||||
return self._statuses.get(alias, "unknown")
|
||||
with self._statuses_lock:
|
||||
return self._statuses.get(alias, "unknown")
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
SSH client wrapper — refactored from ssh.py.
|
||||
Handles connect, exec, sftp, key management via paramiko.
|
||||
SSH client wrapper — connect, exec, sftp, key management via paramiko.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import paramiko
|
||||
from core.logger import log
|
||||
|
||||
|
||||
class SSHClientWrapper:
|
||||
@@ -32,7 +33,13 @@ class SSHClientWrapper:
|
||||
client.connect(**kwargs)
|
||||
self._client = client
|
||||
return client
|
||||
except Exception:
|
||||
except paramiko.AuthenticationException:
|
||||
log.debug(f"Key auth failed for {self.server.get('alias', '?')}, trying password")
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
except Exception as e:
|
||||
log.debug(f"Key connect failed: {e}")
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
@@ -60,6 +67,7 @@ class SSHClientWrapper:
|
||||
def exec_command(self, command: str, use_sudo: bool = True) -> tuple[str, str, int]:
|
||||
"""Execute command. Auto-sudo if user != root and use_sudo=True."""
|
||||
client = self.connect()
|
||||
stdin = stdout = stderr = None
|
||||
try:
|
||||
user = self.server.get("user", "root")
|
||||
need_sudo = use_sudo and user != "root"
|
||||
@@ -87,6 +95,13 @@ class SSHClientWrapper:
|
||||
|
||||
return out, err, exit_code
|
||||
finally:
|
||||
# Close channels explicitly
|
||||
for ch in (stdin, stdout, stderr):
|
||||
if ch:
|
||||
try:
|
||||
ch.close()
|
||||
except Exception:
|
||||
pass
|
||||
client.close()
|
||||
|
||||
def upload(self, local_path: str, remote_path: str, progress_cb=None):
|
||||
@@ -115,7 +130,6 @@ class SSHClientWrapper:
|
||||
client.close()
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Quick connection test."""
|
||||
try:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
@@ -153,7 +167,6 @@ class SSHClientWrapper:
|
||||
return False
|
||||
|
||||
def install_key(self) -> str:
|
||||
"""Install SSH public key on server. Returns status message."""
|
||||
pub_key_path = self.key_path + ".pub"
|
||||
if not os.path.exists(pub_key_path):
|
||||
raise FileNotFoundError(f"No public key at {pub_key_path}")
|
||||
@@ -161,7 +174,6 @@ class SSHClientWrapper:
|
||||
with open(pub_key_path, "r") as f:
|
||||
pub_key = f.read().strip()
|
||||
|
||||
# Check if already installed
|
||||
out, _, _ = self.exec_command(
|
||||
f'grep -c "{pub_key}" ~/.ssh/authorized_keys 2>/dev/null || echo 0',
|
||||
use_sudo=False
|
||||
@@ -169,7 +181,6 @@ class SSHClientWrapper:
|
||||
if out.strip() != "0":
|
||||
return "Key already installed"
|
||||
|
||||
# Install
|
||||
command = (
|
||||
f'mkdir -p ~/.ssh && chmod 700 ~/.ssh && '
|
||||
f'echo "{pub_key}" >> ~/.ssh/authorized_keys && '
|
||||
@@ -182,18 +193,22 @@ class SSHClientWrapper:
|
||||
raise Exception(f"Key install failed: {err or out}")
|
||||
|
||||
def generate_key(self) -> str:
|
||||
"""Generate ed25519 SSH key pair if not exists."""
|
||||
if os.path.exists(self.key_path):
|
||||
return f"Key already exists: {self.key_path}"
|
||||
|
||||
os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
|
||||
key = paramiko.Ed25519Key.generate()
|
||||
key.write_private_key_file(self.key_path)
|
||||
|
||||
# Write public key
|
||||
# Set restrictive permissions on private key (Unix)
|
||||
if platform.system() != "Windows":
|
||||
os.chmod(self.key_path, 0o600)
|
||||
|
||||
pub_key = f"ssh-ed25519 {key.get_base64()} server-manager"
|
||||
with open(self.key_path + ".pub", "w") as f:
|
||||
f.write(pub_key + "\n")
|
||||
|
||||
log.info(f"SSH key generated: {self.key_path}")
|
||||
return f"Key generated: {self.key_path}"
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""
|
||||
Background status checker — daemon thread that pings servers periodically.
|
||||
Background status checker — parallel server pings.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.server_store import ServerStore
|
||||
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
from core.logger import log
|
||||
|
||||
|
||||
class StatusChecker:
|
||||
@@ -18,7 +20,7 @@ class StatusChecker:
|
||||
self.interval = interval
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._gui_callback = None # set by GUI for thread-safe updates
|
||||
self._gui_callback = None
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
@@ -29,19 +31,18 @@ class StatusChecker:
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
|
||||
def set_gui_callback(self, callback):
|
||||
"""Set callback for thread-safe GUI updates."""
|
||||
self._gui_callback = callback
|
||||
|
||||
def check_one(self, server: dict) -> bool:
|
||||
"""Check single server. Returns True if online."""
|
||||
key_path = self.store.get_ssh_key_path()
|
||||
wrapper = SSHClientWrapper(server, key_path)
|
||||
return wrapper.check_connection()
|
||||
|
||||
def check_all_now(self):
|
||||
"""Run a full check cycle immediately (in background thread)."""
|
||||
threading.Thread(target=self._check_cycle, daemon=True).start()
|
||||
|
||||
def _loop(self):
|
||||
@@ -54,18 +55,35 @@ class StatusChecker:
|
||||
|
||||
def _check_cycle(self):
|
||||
servers = self.store.get_all()
|
||||
for server in servers:
|
||||
if not self._running:
|
||||
return
|
||||
alias = server["alias"]
|
||||
server_type = server.get("type", "ssh")
|
||||
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh"]
|
||||
|
||||
if server_type != "ssh":
|
||||
self.store.set_status(alias, "unknown")
|
||||
continue
|
||||
# Mark non-SSH as unknown
|
||||
for s in servers:
|
||||
if s.get("type", "ssh") != "ssh":
|
||||
self.store.set_status(s["alias"], "unknown")
|
||||
|
||||
online = self.check_one(server)
|
||||
self.store.set_status(alias, "online" if online else "offline")
|
||||
if not ssh_servers:
|
||||
return
|
||||
|
||||
# Parallel checks — up to 10 concurrent
|
||||
max_workers = min(10, len(ssh_servers))
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(self.check_one, s): s["alias"]
|
||||
for s in ssh_servers
|
||||
}
|
||||
for future in as_completed(futures, timeout=30):
|
||||
if not self._running:
|
||||
return
|
||||
alias = futures[future]
|
||||
try:
|
||||
online = future.result(timeout=10)
|
||||
self.store.set_status(alias, "online" if online else "offline")
|
||||
except Exception:
|
||||
self.store.set_status(alias, "offline")
|
||||
except Exception as e:
|
||||
log.warning(f"Status check cycle error: {e}")
|
||||
|
||||
if self._gui_callback:
|
||||
try:
|
||||
|
||||
39
core/totp.py
Normal file
39
core/totp.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
TOTP module — Google Authenticator compatible 2FA codes.
|
||||
Uses pyotp (RFC 6238).
|
||||
"""
|
||||
|
||||
import time
|
||||
import pyotp
|
||||
|
||||
|
||||
def generate_secret(length: int = 32) -> str:
|
||||
"""Generate a new random TOTP secret (base32-encoded)."""
|
||||
return pyotp.random_base32(length=length)
|
||||
|
||||
|
||||
def get_code(secret: str) -> str:
|
||||
"""Get current 6-digit TOTP code."""
|
||||
return pyotp.TOTP(secret).now()
|
||||
|
||||
|
||||
def get_code_with_timer(secret: str) -> dict:
|
||||
"""Get current code with timing info for GUI display."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
now = time.time()
|
||||
remaining = 30 - (int(now) % 30)
|
||||
return {
|
||||
"code": totp.now(),
|
||||
"remaining": remaining,
|
||||
"progress": remaining / 30.0,
|
||||
}
|
||||
|
||||
|
||||
def verify_code(secret: str, code: str) -> bool:
|
||||
"""Verify a TOTP code (with +/- 1 period tolerance)."""
|
||||
return pyotp.TOTP(secret).verify(code, valid_window=1)
|
||||
|
||||
|
||||
def format_secret_uri(secret: str, account: str, issuer: str = "ServerManager") -> str:
|
||||
"""Generate otpauth:// URI for QR code / authenticator apps."""
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=account, issuer_name=issuer)
|
||||
Reference in New Issue
Block a user