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,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}"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user