commit 6179ded86263aa603e53c9b97d2876211a70e7f0 Author: chrome-storm-c442 Date: Mon Feb 23 07:49:13 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..805071a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Credentials - NEVER commit +config/servers.json + +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +*.spec + +# IDE +.vscode/ +.idea/ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..e05ad03 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# ServerManager + +Desktop GUI-приложение для управления удалёнными серверами. CustomTkinter + Paramiko. + +## Возможности + +- CRUD серверов (SSH, Telnet, RDP, MariaDB, MSSQL, PostgreSQL) +- Терминал — выполнение команд через SSH с auto-sudo +- SFTP — загрузка и скачивание файлов с прогресс-баром +- SSH-ключи — генерация, установка, копирование +- Мониторинг — фоновая проверка online/offline +- Тёмная тема + +## Установка + +```bash +pip install -r requirements.txt +``` + +## Запуск + +```bash +python main.py +``` + +## Конфигурация + +При первом запуске создаётся `config/servers.json` из шаблона. +Добавляйте серверы через GUI (кнопка "+ Add"). + +## Безопасность + +- `config/servers.json` в `.gitignore` — никогда не коммитится +- Пароли хранятся только локально +- SSH-ключи (ed25519) — рекомендуемый метод аутентификации +- sudo пароль передаётся через stdin (не виден в `ps aux`) diff --git a/config/servers.example.json b/config/servers.example.json new file mode 100644 index 0000000..e926839 --- /dev/null +++ b/config/servers.example.json @@ -0,0 +1,17 @@ +{ + "servers": [ + { + "alias": "my-server", + "ip": "1.2.3.4", + "port": 22, + "user": "root", + "password": "YOUR_PASSWORD_HERE", + "type": "ssh", + "notes": "Example server" + } + ], + "ssh_key": { + "type": "ed25519", + "path": "~/.ssh/id_ed25519" + } +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/connection_factory.py b/core/connection_factory.py new file mode 100644 index 0000000..615f9a9 --- /dev/null +++ b/core/connection_factory.py @@ -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}") diff --git a/core/server_store.py b/core/server_store.py new file mode 100644 index 0000000..aa0d023 --- /dev/null +++ b/core/server_store.py @@ -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") diff --git a/core/ssh_client.py b/core/ssh_client.py new file mode 100644 index 0000000..035c3f9 --- /dev/null +++ b/core/ssh_client.py @@ -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("'", "'\\''") + "'" diff --git a/core/status_checker.py b/core/status_checker.py new file mode 100644 index 0000000..c7f7771 --- /dev/null +++ b/core/status_checker.py @@ -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 diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..2668f72 --- /dev/null +++ b/gui/app.py @@ -0,0 +1,107 @@ +""" +Main application window — sidebar + tabview layout. +""" + +import customtkinter as ctk +from tkinter import messagebox + +from core.server_store import ServerStore +from core.status_checker import StatusChecker +from gui.sidebar import Sidebar +from gui.server_dialog import ServerDialog +from gui.tabs.terminal_tab import TerminalTab +from gui.tabs.files_tab import FilesTab +from gui.tabs.info_tab import InfoTab +from gui.tabs.keys_tab import KeysTab + + +class App(ctk.CTk): + def __init__(self): + super().__init__() + + # Window config + self.title("ServerManager") + self.geometry("1100x700") + self.minsize(900, 500) + + ctk.set_appearance_mode("dark") + ctk.set_default_color_theme("blue") + + # Core + self.store = ServerStore() + self.checker = StatusChecker(self.store, interval=60) + + # Layout + self._build_layout() + + # Status checker + self.checker.set_gui_callback(lambda: self.after(0, self._on_status_update)) + self.checker.start() + self.checker.check_all_now() + + # Cleanup on close + self.protocol("WM_DELETE_WINDOW", self._on_close) + + def _build_layout(self): + # Sidebar + self.sidebar = Sidebar(self, self.store, on_select=self._on_server_select) + self.sidebar.pack(side="left", fill="y") + self.sidebar.add_callback = self._add_server + self.sidebar.edit_callback = self._edit_server + self.sidebar.delete_callback = self._delete_server + + # Main area + main = ctk.CTkFrame(self, fg_color="transparent") + main.pack(side="right", fill="both", expand=True) + + # Tabview + self.tabview = ctk.CTkTabview(main) + self.tabview.pack(fill="both", expand=True, padx=10, pady=10) + + # Tabs + self.tabview.add("Terminal") + self.tabview.add("Files") + self.tabview.add("Info") + self.tabview.add("Keys") + + self.terminal_tab = TerminalTab(self.tabview.tab("Terminal"), self.store) + self.terminal_tab.pack(fill="both", expand=True) + + self.files_tab = FilesTab(self.tabview.tab("Files"), self.store) + self.files_tab.pack(fill="both", expand=True) + + self.info_tab = InfoTab(self.tabview.tab("Info"), self.store, edit_callback=self._edit_server) + self.info_tab.pack(fill="both", expand=True) + + self.keys_tab = KeysTab(self.tabview.tab("Keys"), self.store) + self.keys_tab.pack(fill="both", expand=True) + + def _on_server_select(self, alias: str): + self.terminal_tab.set_server(alias) + self.files_tab.set_server(alias) + self.info_tab.set_server(alias) + self.keys_tab.set_server(alias) + + def _add_server(self): + dialog = ServerDialog(self, self.store) + self.wait_window(dialog) + + def _edit_server(self, alias: str): + server = self.store.get_server(alias) + if server: + dialog = ServerDialog(self, self.store, server=server) + self.wait_window(dialog) + self.info_tab.refresh() + + def _delete_server(self, alias: str): + if messagebox.askyesno("Delete Server", f"Remove '{alias}'?"): + self.store.remove_server(alias) + self._on_server_select(None) + + def _on_status_update(self): + self.sidebar.update_statuses() + self.info_tab.refresh() + + def _on_close(self): + self.checker.stop() + self.destroy() diff --git a/gui/server_dialog.py b/gui/server_dialog.py new file mode 100644 index 0000000..35b2861 --- /dev/null +++ b/gui/server_dialog.py @@ -0,0 +1,152 @@ +""" +Server add/edit dialog — modal window with all server fields. +""" + +import customtkinter as ctk +from core.server_store import SERVER_TYPES, DEFAULT_PORTS + + +class ServerDialog(ctk.CTkToplevel): + def __init__(self, master, store, server: dict | None = None): + super().__init__(master) + self.store = store + self.editing = server + self.result = None + + self.title("Edit Server" if server else "Add Server") + self.geometry("450x520") + self.resizable(False, False) + self.grab_set() + + # Center on parent + self.transient(master) + + self._build_ui(server) + + def _build_ui(self, server: dict | None): + pad = {"padx": 20, "pady": (5, 0)} + entry_pad = {"padx": 20, "pady": (2, 5)} + + # Alias + ctk.CTkLabel(self, text="Alias", anchor="w").pack(fill="x", **pad) + self.alias_entry = ctk.CTkEntry(self, placeholder_text="my-server") + self.alias_entry.pack(fill="x", **entry_pad) + + # IP + ctk.CTkLabel(self, text="IP / Hostname", anchor="w").pack(fill="x", **pad) + self.ip_entry = ctk.CTkEntry(self, placeholder_text="1.2.3.4") + self.ip_entry.pack(fill="x", **entry_pad) + + # Type + Port row + row = ctk.CTkFrame(self, fg_color="transparent") + row.pack(fill="x", padx=20, pady=(5, 5)) + + type_frame = ctk.CTkFrame(row, fg_color="transparent") + type_frame.pack(side="left", fill="x", expand=True, padx=(0, 5)) + ctk.CTkLabel(type_frame, text="Type", anchor="w").pack(fill="x") + self.type_var = ctk.StringVar(value="ssh") + self.type_menu = ctk.CTkOptionMenu( + type_frame, values=SERVER_TYPES, variable=self.type_var, + command=self._on_type_change + ) + self.type_menu.pack(fill="x") + + port_frame = ctk.CTkFrame(row, fg_color="transparent") + port_frame.pack(side="left", fill="x", expand=True, padx=(5, 0)) + ctk.CTkLabel(port_frame, text="Port", anchor="w").pack(fill="x") + self.port_entry = ctk.CTkEntry(port_frame, placeholder_text="22") + self.port_entry.pack(fill="x") + + # User + ctk.CTkLabel(self, text="Username", anchor="w").pack(fill="x", **pad) + self.user_entry = ctk.CTkEntry(self, placeholder_text="root") + self.user_entry.pack(fill="x", **entry_pad) + + # Password + ctk.CTkLabel(self, text="Password", anchor="w").pack(fill="x", **pad) + pass_frame = ctk.CTkFrame(self, fg_color="transparent") + pass_frame.pack(fill="x", padx=20, pady=(2, 5)) + self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text="password") + self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) + self.show_pass = ctk.CTkButton(pass_frame, text="Show", width=60, command=self._toggle_password) + self.show_pass.pack(side="right") + self._pass_visible = False + + # Notes + ctk.CTkLabel(self, text="Notes", anchor="w").pack(fill="x", **pad) + self.notes_entry = ctk.CTkEntry(self, placeholder_text="optional description") + self.notes_entry.pack(fill="x", **entry_pad) + + # Buttons + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=20, pady=(15, 20)) + ctk.CTkButton(btn_frame, text="Cancel", fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5)) + ctk.CTkButton(btn_frame, text="Save", command=self._save).pack(side="right", expand=True, padx=(5, 0)) + + # Fill values if editing + if server: + self.alias_entry.insert(0, server.get("alias", "")) + self.alias_entry.configure(state="disabled") + self.ip_entry.insert(0, server.get("ip", "")) + self.type_var.set(server.get("type", "ssh")) + self.port_entry.insert(0, str(server.get("port", 22))) + self.user_entry.insert(0, server.get("user", "")) + self.password_entry.insert(0, server.get("password", "")) + self.notes_entry.insert(0, server.get("notes", "")) + + def _on_type_change(self, value): + default_port = DEFAULT_PORTS.get(value, 22) + self.port_entry.delete(0, "end") + self.port_entry.insert(0, str(default_port)) + + def _toggle_password(self): + self._pass_visible = not self._pass_visible + self.password_entry.configure(show="" if self._pass_visible else "*") + self.show_pass.configure(text="Hide" if self._pass_visible else "Show") + + def _save(self): + alias = self.alias_entry.get().strip() + ip = self.ip_entry.get().strip() + port_str = self.port_entry.get().strip() + user = self.user_entry.get().strip() + password = self.password_entry.get() + server_type = self.type_var.get() + notes = self.notes_entry.get().strip() + + # Validation + if not alias: + self._show_error("Alias is required") + return + if not ip: + self._show_error("IP is required") + return + try: + port = int(port_str) if port_str else DEFAULT_PORTS.get(server_type, 22) + except ValueError: + self._show_error("Port must be a number") + return + + server_data = { + "alias": alias, + "ip": ip, + "port": port, + "user": user or "root", + "password": password, + "type": server_type, + "notes": notes, + } + + try: + if self.editing: + self.store.update_server(alias, server_data) + else: + self.store.add_server(server_data) + self.result = server_data + self.destroy() + except ValueError as e: + self._show_error(str(e)) + + def _show_error(self, message: str): + # Simple error via title flash + self.title(f"Error: {message}") + self.after(2000, lambda: self.title("Edit Server" if self.editing else "Add Server")) diff --git a/gui/sidebar.py b/gui/sidebar.py new file mode 100644 index 0000000..60be640 --- /dev/null +++ b/gui/sidebar.py @@ -0,0 +1,128 @@ +""" +Sidebar — server list with search, add/edit/delete buttons. +""" + +import customtkinter as ctk +from gui.widgets.status_badge import StatusBadge + + +class Sidebar(ctk.CTkFrame): + def __init__(self, master, store, on_select=None): + super().__init__(master, width=250, corner_radius=0) + self.store = store + self.on_select = on_select + self._selected_alias: str | None = None + self._server_frames: dict[str, ctk.CTkFrame] = {} + self._badges: dict[str, StatusBadge] = {} + + self.pack_propagate(False) + + # Title + title = ctk.CTkLabel(self, text="Servers", font=ctk.CTkFont(size=18, weight="bold")) + title.pack(padx=15, pady=(15, 5)) + + # Search + self.search_var = ctk.StringVar() + self.search_var.trace_add("write", lambda *_: self._refresh_list()) + search = ctk.CTkEntry(self, placeholder_text="Search...", textvariable=self.search_var) + search.pack(fill="x", padx=10, pady=(5, 10)) + + # Server list + self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent") + self.list_frame.pack(fill="both", expand=True, padx=5, pady=0) + + # Buttons + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=10, pady=10) + self.add_btn = ctk.CTkButton(btn_frame, text="+ Add", width=70, height=30, command=self._on_add) + self.add_btn.pack(side="left", padx=(0, 3)) + self.edit_btn = ctk.CTkButton(btn_frame, text="Edit", width=70, height=30, fg_color="#6b7280", command=self._on_edit) + self.edit_btn.pack(side="left", padx=3) + self.del_btn = ctk.CTkButton(btn_frame, text="Delete", width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete) + self.del_btn.pack(side="right", padx=(3, 0)) + + # Callbacks for add/edit/delete — set by app.py + self.add_callback = None + self.edit_callback = None + self.delete_callback = None + + # Subscribe to store changes + self.store.subscribe(self._refresh_list) + self._refresh_list() + + def _refresh_list(self): + # Clear + for widget in self.list_frame.winfo_children(): + widget.destroy() + self._server_frames.clear() + self._badges.clear() + + search = self.search_var.get().lower() + servers = self.store.get_all() + + for server in servers: + alias = server["alias"] + ip = server["ip"] + stype = server.get("type", "ssh") + + if search and search not in alias.lower() and search not in ip.lower(): + continue + + frame = ctk.CTkFrame(self.list_frame, cursor="hand2", height=45) + frame.pack(fill="x", padx=2, pady=2) + frame.pack_propagate(False) + + # Status badge + badge = StatusBadge(frame, status=self.store.get_status(alias)) + badge.pack(side="left", padx=(10, 5), pady=10) + self._badges[alias] = badge + + # Info + info = ctk.CTkFrame(frame, fg_color="transparent") + info.pack(side="left", fill="both", expand=True, padx=5) + + name_label = ctk.CTkLabel(info, text=alias, font=ctk.CTkFont(size=13, weight="bold"), anchor="w") + name_label.pack(fill="x") + detail = f"{ip} [{stype}]" + detail_label = ctk.CTkLabel(info, text=detail, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w") + detail_label.pack(fill="x") + + # Click handlers + for widget in [frame, info, name_label, detail_label, badge]: + widget.bind("", lambda e, a=alias: self._select(a)) + + self._server_frames[alias] = frame + + self._highlight_selected() + + def _select(self, alias: str): + self._selected_alias = alias + self._highlight_selected() + if self.on_select: + self.on_select(alias) + + def _highlight_selected(self): + for alias, frame in self._server_frames.items(): + if alias == self._selected_alias: + frame.configure(fg_color=("#3b82f6", "#1d4ed8")) + else: + frame.configure(fg_color=("gray85", "gray20")) + + def get_selected(self) -> str | None: + return self._selected_alias + + def update_statuses(self): + for alias, badge in self._badges.items(): + badge.set_status(self.store.get_status(alias)) + + def _on_add(self): + if self.add_callback: + self.add_callback() + + def _on_edit(self): + if self.edit_callback and self._selected_alias: + self.edit_callback(self._selected_alias) + + def _on_delete(self): + if self.delete_callback and self._selected_alias: + self.delete_callback(self._selected_alias) diff --git a/gui/tabs/__init__.py b/gui/tabs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/tabs/files_tab.py b/gui/tabs/files_tab.py new file mode 100644 index 0000000..9fdb412 --- /dev/null +++ b/gui/tabs/files_tab.py @@ -0,0 +1,163 @@ +""" +Files tab — SFTP upload/download. +""" + +import os +import threading +import customtkinter as ctk +from tkinter import filedialog +from core.ssh_client import SSHClientWrapper + + +class FilesTab(ctk.CTkFrame): + def __init__(self, master, store): + super().__init__(master, fg_color="transparent") + self.store = store + self._current_alias: str | None = None + + # Upload section + upload_label = ctk.CTkLabel(self, text="Upload", font=ctk.CTkFont(size=14, weight="bold"), anchor="w") + upload_label.pack(fill="x", padx=15, pady=(15, 5)) + + upload_frame = ctk.CTkFrame(self, fg_color="transparent") + upload_frame.pack(fill="x", padx=15, pady=(0, 5)) + + ctk.CTkLabel(upload_frame, text="Local:", width=60, anchor="w").pack(side="left") + self.upload_local = ctk.CTkEntry(upload_frame, placeholder_text="/path/to/local/file") + self.upload_local.pack(side="left", fill="x", expand=True, padx=5) + ctk.CTkButton(upload_frame, text="Browse", width=70, command=self._browse_upload).pack(side="right") + + upload_remote_frame = ctk.CTkFrame(self, fg_color="transparent") + upload_remote_frame.pack(fill="x", padx=15, pady=(0, 5)) + + ctk.CTkLabel(upload_remote_frame, text="Remote:", width=60, anchor="w").pack(side="left") + self.upload_remote = ctk.CTkEntry(upload_remote_frame, placeholder_text="/remote/path/file") + self.upload_remote.pack(side="left", fill="x", expand=True, padx=5) + self.upload_btn = ctk.CTkButton(upload_remote_frame, text="Upload", width=70, command=self._upload) + self.upload_btn.pack(side="right") + + # Separator + ctk.CTkFrame(self, height=2, fg_color="gray40").pack(fill="x", padx=15, pady=10) + + # Download section + download_label = ctk.CTkLabel(self, text="Download", font=ctk.CTkFont(size=14, weight="bold"), anchor="w") + download_label.pack(fill="x", padx=15, pady=(5, 5)) + + download_remote_frame = ctk.CTkFrame(self, fg_color="transparent") + download_remote_frame.pack(fill="x", padx=15, pady=(0, 5)) + + ctk.CTkLabel(download_remote_frame, text="Remote:", width=60, anchor="w").pack(side="left") + self.download_remote = ctk.CTkEntry(download_remote_frame, placeholder_text="/remote/path/file") + self.download_remote.pack(side="left", fill="x", expand=True, padx=5) + + download_local_frame = ctk.CTkFrame(self, fg_color="transparent") + download_local_frame.pack(fill="x", padx=15, pady=(0, 5)) + + ctk.CTkLabel(download_local_frame, text="Local:", width=60, anchor="w").pack(side="left") + self.download_local = ctk.CTkEntry(download_local_frame, placeholder_text="/path/to/save") + self.download_local.pack(side="left", fill="x", expand=True, padx=5) + ctk.CTkButton(download_local_frame, text="Browse", width=70, command=self._browse_download).pack(side="left", padx=(5, 0)) + self.download_btn = ctk.CTkButton(download_local_frame, text="Download", width=80, command=self._download) + self.download_btn.pack(side="right") + + # Progress + self.progress = ctk.CTkProgressBar(self) + self.progress.pack(fill="x", padx=15, pady=(10, 5)) + self.progress.set(0) + + # Log + self.log = ctk.CTkTextbox(self, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled") + self.log.pack(fill="both", expand=True, padx=15, pady=(5, 15)) + + def set_server(self, alias: str | None): + self._current_alias = alias + + def _log_msg(self, text: str): + self.log.configure(state="normal") + self.log.insert("end", text + "\n") + self.log.configure(state="disabled") + self.log.see("end") + + def _browse_upload(self): + path = filedialog.askopenfilename() + if path: + self.upload_local.delete(0, "end") + self.upload_local.insert(0, path) + if not self.upload_remote.get(): + self.upload_remote.insert(0, "/tmp/" + os.path.basename(path)) + + def _browse_download(self): + path = filedialog.asksaveasfilename() + if path: + self.download_local.delete(0, "end") + self.download_local.insert(0, path) + + def _upload(self): + if not self._current_alias: + self._log_msg("[!] No server selected") + return + local = self.upload_local.get().strip() + remote = self.upload_remote.get().strip() + if not local or not remote: + self._log_msg("[!] Both paths required") + return + if not os.path.exists(local): + self._log_msg(f"[!] File not found: {local}") + return + + server = self.store.get_server(self._current_alias) + if not server: + return + + self.upload_btn.configure(state="disabled") + self.progress.set(0) + file_size = os.path.getsize(local) + + def _progress(transferred, total): + if total > 0: + self.after(0, lambda: self.progress.set(transferred / total)) + + def _do(): + try: + wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path()) + wrapper.upload(local, remote, progress_cb=_progress) + self.after(0, lambda: self._log_msg(f"OK: {local} -> {self._current_alias}:{remote}")) + except Exception as e: + self.after(0, lambda: self._log_msg(f"[ERROR] {e}")) + finally: + self.after(0, lambda: self.upload_btn.configure(state="normal")) + + threading.Thread(target=_do, daemon=True).start() + + def _download(self): + if not self._current_alias: + self._log_msg("[!] No server selected") + return + remote = self.download_remote.get().strip() + local = self.download_local.get().strip() + if not remote or not local: + self._log_msg("[!] Both paths required") + return + + server = self.store.get_server(self._current_alias) + if not server: + return + + self.download_btn.configure(state="disabled") + self.progress.set(0) + + def _progress(transferred, total): + if total > 0: + self.after(0, lambda: self.progress.set(transferred / total)) + + def _do(): + try: + wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path()) + wrapper.download(remote, local, progress_cb=_progress) + self.after(0, lambda: self._log_msg(f"OK: {self._current_alias}:{remote} -> {local}")) + except Exception as e: + self.after(0, lambda: self._log_msg(f"[ERROR] {e}")) + finally: + self.after(0, lambda: self.download_btn.configure(state="normal")) + + threading.Thread(target=_do, daemon=True).start() diff --git a/gui/tabs/info_tab.py b/gui/tabs/info_tab.py new file mode 100644 index 0000000..47e078a --- /dev/null +++ b/gui/tabs/info_tab.py @@ -0,0 +1,66 @@ +""" +Info tab — display server details, edit button. +""" + +import customtkinter as ctk + + +class InfoTab(ctk.CTkFrame): + def __init__(self, master, store, edit_callback=None): + super().__init__(master, fg_color="transparent") + self.store = store + self.edit_callback = edit_callback + self._current_alias: str | None = None + + # Header + self.header = ctk.CTkLabel(self, text="No server selected", font=ctk.CTkFont(size=20, weight="bold")) + self.header.pack(padx=20, pady=(20, 10)) + + # Info card + self.card = ctk.CTkFrame(self) + self.card.pack(fill="x", padx=20, pady=10) + + self._fields: dict[str, ctk.CTkLabel] = {} + for label in ["Alias", "IP", "Port", "User", "Type", "Notes", "Status"]: + row = ctk.CTkFrame(self.card, fg_color="transparent") + row.pack(fill="x", padx=15, pady=4) + ctk.CTkLabel(row, text=f"{label}:", width=80, anchor="w", + font=ctk.CTkFont(size=12), text_color="#9ca3af").pack(side="left") + val = ctk.CTkLabel(row, text="-", anchor="w", font=ctk.CTkFont(size=13)) + val.pack(side="left", fill="x", expand=True) + self._fields[label] = val + + # Edit button + self.edit_btn = ctk.CTkButton(self, text="Edit Server", command=self._on_edit) + self.edit_btn.pack(pady=15) + + def set_server(self, alias: str | None): + self._current_alias = alias + self.refresh() + + def refresh(self): + if not self._current_alias: + self.header.configure(text="No server selected") + for v in self._fields.values(): + v.configure(text="-") + return + + server = self.store.get_server(self._current_alias) + if not server: + return + + self.header.configure(text=server["alias"]) + self._fields["Alias"].configure(text=server.get("alias", "-")) + self._fields["IP"].configure(text=server.get("ip", "-")) + self._fields["Port"].configure(text=str(server.get("port", 22))) + self._fields["User"].configure(text=server.get("user", "root")) + self._fields["Type"].configure(text=server.get("type", "ssh").upper()) + self._fields["Notes"].configure(text=server.get("notes", "-") or "-") + + status = self.store.get_status(self._current_alias) + color = {"online": "#22c55e", "offline": "#ef4444"}.get(status, "#9ca3af") + self._fields["Status"].configure(text=status.upper(), text_color=color) + + def _on_edit(self): + if self.edit_callback and self._current_alias: + self.edit_callback(self._current_alias) diff --git a/gui/tabs/keys_tab.py b/gui/tabs/keys_tab.py new file mode 100644 index 0000000..f2690d1 --- /dev/null +++ b/gui/tabs/keys_tab.py @@ -0,0 +1,116 @@ +""" +Keys tab — SSH key management: view, generate, install. +""" + +import os +import threading +import customtkinter as ctk +from core.ssh_client import SSHClientWrapper + + +class KeysTab(ctk.CTkFrame): + def __init__(self, master, store): + super().__init__(master, fg_color="transparent") + self.store = store + self._current_alias: str | None = None + + # Key info + ctk.CTkLabel(self, text="SSH Key", font=ctk.CTkFont(size=16, weight="bold"), anchor="w").pack(fill="x", padx=15, pady=(15, 5)) + + self.key_path_label = ctk.CTkLabel(self, text="", anchor="w", text_color="#9ca3af") + self.key_path_label.pack(fill="x", padx=15) + + self.pub_key_box = ctk.CTkTextbox(self, height=80, font=ctk.CTkFont(family="Consolas", size=11), state="disabled") + self.pub_key_box.pack(fill="x", padx=15, pady=(5, 10)) + + # Buttons + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=15, pady=5) + + self.gen_btn = ctk.CTkButton(btn_frame, text="Generate Key", command=self._generate) + self.gen_btn.pack(side="left", padx=(0, 10)) + + self.install_btn = ctk.CTkButton(btn_frame, text="Install on Server", fg_color="#22c55e", hover_color="#16a34a", command=self._install) + self.install_btn.pack(side="left") + + self.copy_btn = ctk.CTkButton(btn_frame, text="Copy Public Key", fg_color="#6b7280", command=self._copy_key) + self.copy_btn.pack(side="right") + + # Status log + self.status_log = ctk.CTkTextbox(self, height=120, font=ctk.CTkFont(family="Consolas", size=11), state="disabled") + self.status_log.pack(fill="both", expand=True, padx=15, pady=(10, 15)) + + self._refresh_key_info() + + def set_server(self, alias: str | None): + self._current_alias = alias + + def _refresh_key_info(self): + key_path = self.store.get_ssh_key_path() + pub_path = key_path + ".pub" + self.key_path_label.configure(text=f"Path: {key_path}") + + self.pub_key_box.configure(state="normal") + self.pub_key_box.delete("1.0", "end") + + if os.path.exists(pub_path): + with open(pub_path, "r") as f: + pub_key = f.read().strip() + self.pub_key_box.insert("1.0", pub_key) + self.gen_btn.configure(state="disabled", text="Key exists") + else: + self.pub_key_box.insert("1.0", "No key found. Click 'Generate Key' to create one.") + self.gen_btn.configure(state="normal", text="Generate Key") + + self.pub_key_box.configure(state="disabled") + + def _log(self, text: str): + self.status_log.configure(state="normal") + self.status_log.insert("end", text + "\n") + self.status_log.configure(state="disabled") + self.status_log.see("end") + + def _generate(self): + try: + key_path = self.store.get_ssh_key_path() + wrapper = SSHClientWrapper({"alias": "temp", "ip": "0", "user": "root"}, key_path) + msg = wrapper.generate_key() + self._log(msg) + self._refresh_key_info() + except Exception as e: + self._log(f"[ERROR] {e}") + + def _install(self): + if not self._current_alias: + self._log("[!] No server selected") + return + + server = self.store.get_server(self._current_alias) + if not server: + return + + self.install_btn.configure(state="disabled", text="Installing...") + + def _do(): + try: + wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path()) + msg = wrapper.install_key() + self.after(0, lambda: self._log(f"[{self._current_alias}] {msg}")) + except Exception as e: + self.after(0, lambda: self._log(f"[ERROR] {e}")) + finally: + self.after(0, lambda: self.install_btn.configure(state="normal", text="Install on Server")) + + threading.Thread(target=_do, daemon=True).start() + + def _copy_key(self): + key_path = self.store.get_ssh_key_path() + pub_path = key_path + ".pub" + if os.path.exists(pub_path): + with open(pub_path, "r") as f: + pub_key = f.read().strip() + self.clipboard_clear() + self.clipboard_append(pub_key) + self._log("Public key copied to clipboard") + else: + self._log("[!] No public key to copy") diff --git a/gui/tabs/terminal_tab.py b/gui/tabs/terminal_tab.py new file mode 100644 index 0000000..b0675bc --- /dev/null +++ b/gui/tabs/terminal_tab.py @@ -0,0 +1,104 @@ +""" +Terminal tab — command input + output display. +""" + +import threading +import customtkinter as ctk +from core.ssh_client import SSHClientWrapper + + +class TerminalTab(ctk.CTkFrame): + def __init__(self, master, store): + super().__init__(master, fg_color="transparent") + self.store = store + self._current_alias: str | None = None + + # Output + self.output = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Consolas", size=12), state="disabled") + self.output.pack(fill="both", expand=True, padx=10, pady=(10, 5)) + + # Input row + input_frame = ctk.CTkFrame(self, fg_color="transparent") + input_frame.pack(fill="x", padx=10, pady=(0, 10)) + + self.sudo_var = ctk.BooleanVar(value=True) + self.sudo_check = ctk.CTkCheckBox(input_frame, text="sudo", variable=self.sudo_var, width=60) + self.sudo_check.pack(side="left", padx=(0, 5)) + + self.cmd_entry = ctk.CTkEntry(input_frame, placeholder_text="Enter command...") + self.cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) + self.cmd_entry.bind("", lambda e: self._run_command()) + + self.run_btn = ctk.CTkButton(input_frame, text="Run", width=70, command=self._run_command) + self.run_btn.pack(side="left", padx=(0, 5)) + + self.clear_btn = ctk.CTkButton(input_frame, text="Clear", width=60, fg_color="#6b7280", command=self._clear) + self.clear_btn.pack(side="right") + + def set_server(self, alias: str | None): + self._current_alias = alias + if alias: + server = self.store.get_server(alias) + user = server.get("user", "root") if server else "root" + self.sudo_var.set(user != "root") + + def _append_output(self, text: str, color: str = "white"): + self.output.configure(state="normal") + self.output.insert("end", text) + self.output.configure(state="disabled") + self.output.see("end") + + def _run_command(self): + if not self._current_alias: + self._append_output("[!] No server selected\n") + return + + command = self.cmd_entry.get().strip() + if not command: + return + + server = self.store.get_server(self._current_alias) + if not server: + self._append_output(f"[!] Server '{self._current_alias}' not found\n") + return + + self.cmd_entry.delete(0, "end") + use_sudo = self.sudo_var.get() + prefix = f"[{self._current_alias}]$ " + if use_sudo and server.get("user", "root") != "root": + prefix = f"[{self._current_alias}]# " + self._append_output(f"{prefix}{command}\n") + + self.run_btn.configure(state="disabled", text="...") + + def _exec(): + try: + key_path = self.store.get_ssh_key_path() + wrapper = SSHClientWrapper(server, key_path) + out, err, code = wrapper.exec_command(command, use_sudo=use_sudo) + + def _show(): + if out: + self._append_output(out) + if not out.endswith("\n"): + self._append_output("\n") + if err: + self._append_output(f"STDERR: {err}\n") + if code != 0: + self._append_output(f"[exit code: {code}]\n") + self._append_output("\n") + self.run_btn.configure(state="normal", text="Run") + + self.after(0, _show) + except Exception as e: + def _err(): + self._append_output(f"[ERROR] {e}\n\n") + self.run_btn.configure(state="normal", text="Run") + self.after(0, _err) + + threading.Thread(target=_exec, daemon=True).start() + + def _clear(self): + self.output.configure(state="normal") + self.output.delete("1.0", "end") + self.output.configure(state="disabled") diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/widgets/status_badge.py b/gui/widgets/status_badge.py new file mode 100644 index 0000000..71fa513 --- /dev/null +++ b/gui/widgets/status_badge.py @@ -0,0 +1,26 @@ +""" +Status badge widget — colored circle indicator for online/offline. +""" + +import customtkinter as ctk + +COLORS = { + "online": "#22c55e", # green + "offline": "#ef4444", # red + "unknown": "#6b7280", # gray +} + + +class StatusBadge(ctk.CTkLabel): + def __init__(self, master, status: str = "unknown", **kwargs): + super().__init__(master, text="", width=12, height=12, **kwargs) + self._status = status + self._update_color() + + def set_status(self, status: str): + self._status = status + self._update_color() + + def _update_color(self): + color = COLORS.get(self._status, COLORS["unknown"]) + self.configure(text="\u25cf", text_color=color, font=("", 14)) diff --git a/main.py b/main.py new file mode 100644 index 0000000..1a5088f --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +""" +ServerManager — GUI application for managing remote servers. +""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from gui.app import App + + +def main(): + app = App() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..291d59e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +customtkinter>=5.2.0 +paramiko>=3.4.0 +pillow>=10.0.0