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:
chrome-storm-c442
2026-02-23 07:49:13 -05:00
commit 6179ded862
21 changed files with 1352 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Credentials - NEVER commit
config/servers.json
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
*.spec
# IDE
.vscode/
.idea/
*.swp

36
README.md Normal file
View File

@@ -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`)

View File

@@ -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"
}
}

0
core/__init__.py Normal file
View File

View 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
View 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
View 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
View 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

0
gui/__init__.py Normal file
View File

107
gui/app.py Normal file
View File

@@ -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()

152
gui/server_dialog.py Normal file
View File

@@ -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"))

128
gui/sidebar.py Normal file
View File

@@ -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("<Button-1>", 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)

0
gui/tabs/__init__.py Normal file
View File

163
gui/tabs/files_tab.py Normal file
View File

@@ -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()

66
gui/tabs/info_tab.py Normal file
View File

@@ -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)

116
gui/tabs/keys_tab.py Normal file
View File

@@ -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")

104
gui/tabs/terminal_tab.py Normal file
View File

@@ -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("<Return>", 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")

0
gui/widgets/__init__.py Normal file
View File

View File

@@ -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))

21
main.py Normal file
View File

@@ -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()

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
customtkinter>=5.2.0
paramiko>=3.4.0
pillow>=10.0.0