Initial commit: ServerManager GUI application
CustomTkinter desktop app for managing remote servers. Features: SSH terminal, SFTP file transfer, key management, background status monitoring, server CRUD with dark theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
25
core/connection_factory.py
Normal file
25
core/connection_factory.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Connection factory — stubs for non-SSH connection types.
|
||||
SSH is fully implemented via SSHClientWrapper.
|
||||
Other types are placeholders for future implementation.
|
||||
"""
|
||||
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
|
||||
|
||||
def create_connection(server: dict, key_path: str = ""):
|
||||
"""Create a connection wrapper based on server type."""
|
||||
server_type = server.get("type", "ssh")
|
||||
|
||||
if server_type == "ssh":
|
||||
return SSHClientWrapper(server, key_path)
|
||||
|
||||
# Stubs for future types
|
||||
if server_type == "rdp":
|
||||
raise NotImplementedError("RDP connections — use mstsc.exe or rdesktop")
|
||||
if server_type == "telnet":
|
||||
raise NotImplementedError("Telnet connections — planned")
|
||||
if server_type in ("mariadb", "mssql", "postgresql"):
|
||||
raise NotImplementedError(f"{server_type.upper()} connections — planned")
|
||||
|
||||
raise ValueError(f"Unknown server type: {server_type}")
|
||||
97
core/server_store.py
Normal file
97
core/server_store.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Server store — CRUD + JSON persistence + observer pattern.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
||||
SERVERS_FILE = os.path.join(CONFIG_DIR, "servers.json")
|
||||
EXAMPLE_FILE = os.path.join(CONFIG_DIR, "servers.example.json")
|
||||
|
||||
SERVER_TYPES = ["ssh", "telnet", "rdp", "mariadb", "mssql", "postgresql"]
|
||||
|
||||
DEFAULT_PORTS = {
|
||||
"ssh": 22,
|
||||
"telnet": 23,
|
||||
"rdp": 3389,
|
||||
"mariadb": 3306,
|
||||
"mssql": 1433,
|
||||
"postgresql": 5432,
|
||||
}
|
||||
|
||||
|
||||
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._load()
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(SERVERS_FILE):
|
||||
with open(SERVERS_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
elif os.path.exists(EXAMPLE_FILE):
|
||||
with open(EXAMPLE_FILE, "r", encoding="utf-8") as f:
|
||||
self._data = json.load(f)
|
||||
self._save()
|
||||
|
||||
def _save(self):
|
||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||
with open(SERVERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _notify(self):
|
||||
for cb in self._observers:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def subscribe(self, callback: Callable):
|
||||
self._observers.append(callback)
|
||||
|
||||
def get_all(self) -> list[dict]:
|
||||
return list(self._data.get("servers", []))
|
||||
|
||||
def get_server(self, alias: str) -> Optional[dict]:
|
||||
for s in self._data.get("servers", []):
|
||||
if s["alias"] == alias:
|
||||
return dict(s)
|
||||
return None
|
||||
|
||||
def add_server(self, server: dict):
|
||||
if self.get_server(server["alias"]):
|
||||
raise ValueError(f"Server '{server['alias']}' already exists")
|
||||
self._data.setdefault("servers", []).append(server)
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def update_server(self, alias: str, updated: dict):
|
||||
servers = self._data.get("servers", [])
|
||||
for i, s in enumerate(servers):
|
||||
if s["alias"] == alias:
|
||||
servers[i] = updated
|
||||
self._save()
|
||||
self._notify()
|
||||
return
|
||||
raise ValueError(f"Server '{alias}' not found")
|
||||
|
||||
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)
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def get_ssh_key_path(self) -> str:
|
||||
path = self._data.get("ssh_key", {}).get("path", "~/.ssh/id_ed25519")
|
||||
return os.path.expanduser(path)
|
||||
|
||||
# Status management
|
||||
def set_status(self, alias: str, status: str):
|
||||
self._statuses[alias] = status
|
||||
|
||||
def get_status(self, alias: str) -> str:
|
||||
return self._statuses.get(alias, "unknown")
|
||||
201
core/ssh_client.py
Normal file
201
core/ssh_client.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
SSH client wrapper — refactored from ssh.py.
|
||||
Handles connect, exec, sftp, key management via paramiko.
|
||||
"""
|
||||
|
||||
import os
|
||||
import paramiko
|
||||
|
||||
|
||||
class SSHClientWrapper:
|
||||
def __init__(self, server: dict, key_path: str = ""):
|
||||
self.server = server
|
||||
self.key_path = key_path or os.path.expanduser("~/.ssh/id_ed25519")
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
|
||||
def connect(self) -> paramiko.SSHClient:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
kwargs = {
|
||||
"hostname": self.server["ip"],
|
||||
"port": self.server.get("port", 22),
|
||||
"username": self.server.get("user", "root"),
|
||||
"timeout": 15,
|
||||
"banner_timeout": 15,
|
||||
}
|
||||
|
||||
# Try key first
|
||||
if os.path.exists(self.key_path):
|
||||
try:
|
||||
kwargs["key_filename"] = self.key_path
|
||||
client.connect(**kwargs)
|
||||
self._client = client
|
||||
return client
|
||||
except Exception:
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
# Fallback to password
|
||||
password = self.server.get("password", "")
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
kwargs["look_for_keys"] = False
|
||||
kwargs["allow_agent"] = False
|
||||
client.connect(**kwargs)
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
raise Exception(f"No auth method for {self.server.get('alias', 'unknown')}")
|
||||
|
||||
def disconnect(self):
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
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()
|
||||
try:
|
||||
user = self.server.get("user", "root")
|
||||
need_sudo = use_sudo and user != "root"
|
||||
|
||||
if need_sudo:
|
||||
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}"
|
||||
else:
|
||||
full_cmd = command
|
||||
|
||||
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120)
|
||||
|
||||
if need_sudo:
|
||||
password = self.server.get("password", "")
|
||||
stdin.write(password + "\n")
|
||||
stdin.flush()
|
||||
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
|
||||
# Strip sudo noise
|
||||
err_lines = [l for l in err.splitlines()
|
||||
if not l.startswith("[sudo]") and "password for" not in l.lower()]
|
||||
err = "\n".join(err_lines).strip()
|
||||
|
||||
return out, err, exit_code
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def upload(self, local_path: str, remote_path: str, progress_cb=None):
|
||||
client = self.connect()
|
||||
try:
|
||||
sftp = client.open_sftp()
|
||||
if progress_cb:
|
||||
sftp.put(local_path, remote_path, callback=progress_cb)
|
||||
else:
|
||||
sftp.put(local_path, remote_path)
|
||||
sftp.chmod(remote_path, 0o664)
|
||||
sftp.close()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def download(self, remote_path: str, local_path: str, progress_cb=None):
|
||||
client = self.connect()
|
||||
try:
|
||||
sftp = client.open_sftp()
|
||||
if progress_cb:
|
||||
sftp.get(remote_path, local_path, callback=progress_cb)
|
||||
else:
|
||||
sftp.get(remote_path, local_path)
|
||||
sftp.close()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Quick connection test."""
|
||||
try:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
kwargs = {
|
||||
"hostname": self.server["ip"],
|
||||
"port": self.server.get("port", 22),
|
||||
"username": self.server.get("user", "root"),
|
||||
"timeout": 5,
|
||||
"banner_timeout": 5,
|
||||
}
|
||||
|
||||
if os.path.exists(self.key_path):
|
||||
try:
|
||||
kwargs["key_filename"] = self.key_path
|
||||
client.connect(**kwargs)
|
||||
client.close()
|
||||
return True
|
||||
except Exception:
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
password = self.server.get("password", "")
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
kwargs["look_for_keys"] = False
|
||||
kwargs["allow_agent"] = False
|
||||
client.connect(**kwargs)
|
||||
client.close()
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
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}")
|
||||
|
||||
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
|
||||
)
|
||||
if out.strip() != "0":
|
||||
return "Key already installed"
|
||||
|
||||
# Install
|
||||
command = (
|
||||
f'mkdir -p ~/.ssh && chmod 700 ~/.ssh && '
|
||||
f'echo "{pub_key}" >> ~/.ssh/authorized_keys && '
|
||||
f'chmod 600 ~/.ssh/authorized_keys && '
|
||||
f'echo "KEY_OK"'
|
||||
)
|
||||
out, err, code = self.exec_command(command, use_sudo=False)
|
||||
if "KEY_OK" in out:
|
||||
return "Key installed successfully"
|
||||
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}"
|
||||
|
||||
key = paramiko.Ed25519Key.generate()
|
||||
key.write_private_key_file(self.key_path)
|
||||
|
||||
# Write public key
|
||||
pub_key = f"ssh-ed25519 {key.get_base64()} server-manager"
|
||||
with open(self.key_path + ".pub", "w") as f:
|
||||
f.write(pub_key + "\n")
|
||||
|
||||
return f"Key generated: {self.key_path}"
|
||||
|
||||
|
||||
def _shell_quote(s: str) -> str:
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
74
core/status_checker.py
Normal file
74
core/status_checker.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Background status checker — daemon thread that pings servers periodically.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.server_store import ServerStore
|
||||
|
||||
from core.ssh_client import SSHClientWrapper
|
||||
|
||||
|
||||
class StatusChecker:
|
||||
def __init__(self, store: "ServerStore", interval: int = 60):
|
||||
self.store = store
|
||||
self.interval = interval
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._gui_callback = None # set by GUI for thread-safe updates
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
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):
|
||||
while self._running:
|
||||
self._check_cycle()
|
||||
for _ in range(self.interval * 10):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
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")
|
||||
|
||||
if server_type != "ssh":
|
||||
self.store.set_status(alias, "unknown")
|
||||
continue
|
||||
|
||||
online = self.check_one(server)
|
||||
self.store.set_status(alias, "online" if online else "offline")
|
||||
|
||||
if self._gui_callback:
|
||||
try:
|
||||
self._gui_callback()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user