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