diff --git a/README.md b/README.md index 90a9aba..dc76c03 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Desktop GUI for managing remote servers
- CustomTkinter + Paramiko | Dark Theme + CustomTkinter + Paramiko | Dark Theme | Claude Code Integration

@@ -22,6 +22,7 @@ - **SFTP Transfer** — upload/download files with progress bar - **SSH Keys** — generate ed25519, install on server, copy to clipboard - **Status Monitor** — background check every 60 sec (online/offline badges) +- **Claude Code Integration** — one-click setup, shared config with `/ssh` skill - **Dark Theme** — modern CustomTkinter interface ### Installation @@ -55,12 +56,41 @@ Output goes to `releases/ServerManager-v1.0.0-{platform}.exe` 3. **Terminal** — select server → Terminal tab → type command → Run 4. **Files** — select server → Files tab → set paths → Upload/Download 5. **Keys** — Keys tab → Generate Key → Install on Server -6. Status badges update automatically (green = online, red = offline) +6. **Setup** — Setup tab → "Install Everything" → Claude Code ready +7. Status badges update automatically (green = online, red = offline) + +### Claude Code Integration + +ServerManager and Claude Code share the same config file: `~/.server-connections/servers.json` + +**How it works:** +``` +ServerManager GUI ←→ ~/.server-connections/servers.json ←→ ssh.py (Claude Code) + ↕ ↕ + Add/edit/delete /ssh skill + servers in GUI executes commands +``` + +- Add a server in GUI → Claude Code sees it immediately via `/ssh list` +- Both use the same `ssh.py` + `servers.json` +- Passwords **never** pass through the AI API + +**Setup on a new machine:** +1. Install ServerManager (clone repo or download binary) +2. Open Setup tab → click "Install Everything" +3. Done. Claude Code now has `/ssh` skill and access to your servers + +The Setup tab installs: +- `ssh.py` → `~/.server-connections/` (SSH utility) +- `/ssh` skill → `~/.claude/commands/ssh.md` (Claude Code skill) +- SSH key (ed25519) — if not exists +- Checks for duplicates — safe to run multiple times ### Configuration -On first launch, `config/servers.json` is created from template. -Add servers via GUI or edit the JSON directly. +Shared config location: `~/.server-connections/servers.json` + +Add servers via GUI or edit the JSON directly: ```json { @@ -96,7 +126,7 @@ App executes: sudo -S -p '' bash -c 'systemctl restart nginx' ### Security -- `config/servers.json` is in `.gitignore` — never committed +- `servers.json` is in `.gitignore` — never committed - Passwords stored locally only, **never sent to any AI/API** - SSH keys (ed25519) — recommended auth method - sudo password sent via stdin (not visible in process list) @@ -110,19 +140,40 @@ ServerManager/ ├── version.py # Version info ├── build.py # PyInstaller build script ├── core/ # Business logic -│ ├── server_store.py # CRUD + JSON + observer +│ ├── server_store.py # CRUD + JSON + observer (shared config) │ ├── ssh_client.py # Paramiko SSH/SFTP wrapper +│ ├── claude_setup.py # Claude Code integration installer │ ├── status_checker.py # Background monitoring │ └── connection_factory.py ├── gui/ # CustomTkinter UI │ ├── app.py # Main window │ ├── sidebar.py # Server list + search │ ├── server_dialog.py # Add/Edit modal -│ ├── tabs/ # Terminal, Files, Info, Keys +│ ├── tabs/ # Terminal, Files, Info, Keys, Setup │ └── widgets/ # StatusBadge -├── config/ # Server configs +├── tools/ # CLI tools (installed to ~/.server-connections/) +│ ├── ssh.py # SSH utility for Claude Code +│ └── skill-ssh.md # /ssh skill template +├── config/ # Example configs ├── releases/ # Built executables -└── docs/ # Documentation +└── README.md # This file (EN/RU/ZH) +``` + +### Deploy on a new machine + +```bash +# 1. Clone +git clone https://git.sensey24.ru/aibot777/server-manager.git +cd server-manager + +# 2. Install deps +pip install -r requirements.txt + +# 3. Launch and setup +python main.py +# → Setup tab → Install Everything +# → Add your servers via + Add +# → Done! Both GUI and Claude Code are ready ``` --- @@ -136,6 +187,7 @@ ServerManager/ - **SFTP** — загрузка и скачивание файлов с прогресс-баром - **SSH-ключи** — генерация ed25519, установка на сервер, копирование - **Мониторинг** — фоновая проверка каждые 60 сек (бейджи online/offline) +- **Интеграция с Claude Code** — установка в один клик, общий конфиг со скиллом `/ssh` - **Тёмная тема** — современный интерфейс CustomTkinter ### Установка @@ -169,12 +221,57 @@ python build.py 3. **Терминал** — выберите сервер → вкладка Terminal → введите команду → Run 4. **Файлы** — выберите сервер → вкладка Files → укажите пути → Upload/Download 5. **Ключи** — вкладка Keys → Generate Key → Install on Server -6. Бейджи статуса обновляются автоматически (зелёный = online, красный = offline) +6. **Настройка Claude** — вкладка Setup → "Install Everything" → Claude Code готов +7. Бейджи статуса обновляются автоматически (зелёный = online, красный = offline) + +### Интеграция с Claude Code + +ServerManager и Claude Code используют **один и тот же файл конфигурации**: `~/.server-connections/servers.json` + +**Как это работает:** +``` +ServerManager GUI ←→ ~/.server-connections/servers.json ←→ ssh.py (Claude Code) + ↕ ↕ + Добавил/изменил скилл /ssh + сервер в GUI выполняет команды +``` + +- Добавил сервер в GUI → Claude Code сразу видит его через `/ssh list` +- Оба используют один `ssh.py` + `servers.json` +- Пароли **никогда** не проходят через API нейронки + +**Настройка на новой машине:** +1. Установить ServerManager (клонировать репо или скачать бинарник) +2. Открыть вкладку Setup → нажать "Install Everything" +3. Готово. Claude Code теперь имеет скилл `/ssh` и доступ к серверам + +Вкладка Setup устанавливает: +- `ssh.py` → `~/.server-connections/` (SSH-утилита) +- скилл `/ssh` → `~/.claude/commands/ssh.md` (скилл Claude Code) +- SSH-ключ (ed25519) — если ещё не создан +- Проверяет дубли — безопасно запускать повторно ### Конфигурация -При первом запуске `config/servers.json` создаётся из шаблона. -Добавляйте серверы через GUI или редактируйте JSON напрямую. +Общий конфиг: `~/.server-connections/servers.json` + +Добавляйте серверы через GUI или редактируйте JSON: + +```json +{ + "servers": [ + { + "alias": "my-server", + "ip": "1.2.3.4", + "port": 22, + "user": "root", + "password": "secret", + "type": "ssh", + "notes": "Production" + } + ] +} +``` ### Авто-sudo @@ -194,12 +291,29 @@ python build.py ### Безопасность -- `config/servers.json` в `.gitignore` — никогда не коммитится +- `servers.json` в `.gitignore` — никогда не коммитится - Пароли хранятся только локально, **никогда не передаются в AI/API** - SSH-ключи (ed25519) — рекомендуемый метод аутентификации - sudo-пароль передаётся через stdin (не виден в списке процессов) - При использовании с Claude Code: через API нейронки проходят только alias + команда, пароли остаются в локальном JSON-файле +### Развёртывание на новой машине + +```bash +# 1. Клонировать +git clone https://git.sensey24.ru/aibot777/server-manager.git +cd server-manager + +# 2. Установить зависимости +pip install -r requirements.txt + +# 3. Запустить и настроить +python main.py +# → Вкладка Setup → Install Everything +# → Добавить серверы через + Add +# → Готово! GUI и Claude Code работают с одним конфигом +``` + --- ## 中文 @@ -211,6 +325,7 @@ python build.py - **SFTP传输** — 带进度条的文件上传/下载 - **SSH密钥** — 生成ed25519、安装到服务器、复制到剪贴板 - **状态监控** — 每60秒后台检查(在线/离线徽标) +- **Claude Code集成** — 一键设置,与`/ssh`技能共享配置 - **深色主题** — 现代CustomTkinter界面 ### 安装 @@ -244,12 +359,57 @@ python build.py 3. **终端** — 选择服务器 → Terminal标签 → 输入命令 → Run 4. **文件** — 选择服务器 → Files标签 → 设置路径 → Upload/Download 5. **密钥** — Keys标签 → Generate Key → Install on Server -6. 状态徽标自动更新(绿色 = 在线,红色 = 离线) +6. **设置Claude** — Setup标签 → "Install Everything" → Claude Code就绪 +7. 状态徽标自动更新(绿色 = 在线,红色 = 离线) + +### Claude Code集成 + +ServerManager和Claude Code共享**同一个配置文件**:`~/.server-connections/servers.json` + +**工作原理:** +``` +ServerManager GUI ←→ ~/.server-connections/servers.json ←→ ssh.py (Claude Code) + ↕ ↕ + 在GUI中添加/编辑 /ssh技能 + 服务器 执行命令 +``` + +- 在GUI中添加服务器 → Claude Code立即通过 `/ssh list` 看到 +- 两者使用相同的 `ssh.py` + `servers.json` +- 密码**绝不**通过AI API传递 + +**在新机器上设置:** +1. 安装ServerManager(克隆仓库或下载二进制文件) +2. 打开Setup标签 → 点击 "Install Everything" +3. 完成。Claude Code现在拥有 `/ssh` 技能并可访问您的服务器 + +Setup标签安装: +- `ssh.py` → `~/.server-connections/`(SSH工具) +- `/ssh` 技能 → `~/.claude/commands/ssh.md`(Claude Code技能) +- SSH密钥(ed25519)— 如果不存在 +- 检查重复 — 可安全重复运行 ### 配置 -首次启动时,`config/servers.json` 从模板自动创建。 -通过GUI添加服务器或直接编辑JSON文件。 +共享配置位置:`~/.server-connections/servers.json` + +通过GUI添加服务器或直接编辑JSON: + +```json +{ + "servers": [ + { + "alias": "my-server", + "ip": "1.2.3.4", + "port": 22, + "user": "root", + "password": "secret", + "type": "ssh", + "notes": "Production" + } + ] +} +``` ### 自动sudo @@ -269,12 +429,29 @@ python build.py ### 安全性 -- `config/servers.json` 在 `.gitignore` 中 — 永不提交 +- `servers.json` 在 `.gitignore` 中 — 永不提交 - 密码仅存储在本地,**绝不发送到任何AI/API** - SSH密钥(ed25519)— 推荐的认证方式 - sudo密码通过stdin传递(在进程列表中不可见) - 与Claude Code配合使用时:只有别名和命令通过AI API传递,密码保留在本地JSON文件中 +### 在新机器上部署 + +```bash +# 1. 克隆 +git clone https://git.sensey24.ru/aibot777/server-manager.git +cd server-manager + +# 2. 安装依赖 +pip install -r requirements.txt + +# 3. 启动并设置 +python main.py +# → Setup标签 → Install Everything +# → 通过 + Add 添加服务器 +# → 完成!GUI和Claude Code使用同一个配置 +``` + --- ## License diff --git a/core/claude_setup.py b/core/claude_setup.py new file mode 100644 index 0000000..34e46df --- /dev/null +++ b/core/claude_setup.py @@ -0,0 +1,110 @@ +""" +Claude Code integration setup. +Installs ssh.py, /ssh skill, SSH key — everything needed for Claude Code +to manage servers via the shared servers.json. +""" + +import os +import shutil + +SHARED_DIR = os.path.expanduser("~/.server-connections") +PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SSH_SCRIPT_SRC = os.path.join(PROJECT_DIR, "tools", "ssh.py") +SKILL_SRC = os.path.join(PROJECT_DIR, "tools", "skill-ssh.md") + +SKILL_DST_DIR = os.path.expanduser("~/.claude/commands") +SKILL_DST = os.path.join(SKILL_DST_DIR, "ssh.md") +SSH_KEY_PATH = os.path.expanduser("~/.ssh/id_ed25519") + + +def check_status() -> dict: + """Check what's installed and what's missing.""" + return { + "shared_dir": os.path.exists(SHARED_DIR), + "servers_json": os.path.exists(os.path.join(SHARED_DIR, "servers.json")), + "ssh_script": os.path.exists(os.path.join(SHARED_DIR, "ssh.py")), + "skill_installed": os.path.exists(SKILL_DST), + "ssh_key_exists": os.path.exists(SSH_KEY_PATH), + "ssh_key_pub": os.path.exists(SSH_KEY_PATH + ".pub"), + } + + +def install_ssh_script() -> str: + """Copy ssh.py to shared dir.""" + os.makedirs(SHARED_DIR, exist_ok=True) + dst = os.path.join(SHARED_DIR, "ssh.py") + if os.path.exists(SSH_SCRIPT_SRC): + shutil.copy2(SSH_SCRIPT_SRC, dst) + return f"ssh.py installed: {dst}" + # Fallback: check if already exists in shared dir + if os.path.exists(dst): + return f"ssh.py already exists: {dst}" + return "ERROR: ssh.py source not found" + + +def install_skill() -> str: + """Install /ssh skill for Claude Code.""" + os.makedirs(SKILL_DST_DIR, exist_ok=True) + if os.path.exists(SKILL_SRC): + shutil.copy2(SKILL_SRC, SKILL_DST) + return f"Skill installed: {SKILL_DST}" + # Fallback: check existing + if os.path.exists(SKILL_DST): + return f"Skill already exists: {SKILL_DST}" + # Generate minimal skill + skill_content = _generate_skill_content() + with open(SKILL_DST, "w", encoding="utf-8") as f: + f.write(skill_content) + return f"Skill generated: {SKILL_DST}" + + +def generate_ssh_key() -> str: + """Generate ed25519 SSH key if not exists.""" + if os.path.exists(SSH_KEY_PATH): + return f"Key already exists: {SSH_KEY_PATH}" + + os.makedirs(os.path.dirname(SSH_KEY_PATH), exist_ok=True) + + import paramiko + key = paramiko.Ed25519Key.generate() + key.write_private_key_file(SSH_KEY_PATH) + + pub_key = f"ssh-ed25519 {key.get_base64()} server-manager" + with open(SSH_KEY_PATH + ".pub", "w") as f: + f.write(pub_key + "\n") + + return f"Key generated: {SSH_KEY_PATH}" + + +def install_all() -> list[str]: + """Full setup — install everything.""" + results = [] + results.append(install_ssh_script()) + results.append(install_skill()) + results.append(generate_ssh_key()) + return results + + +def _generate_skill_content() -> str: + """Generate /ssh skill markdown.""" + ssh_py_path = os.path.join(SHARED_DIR, "ssh.py").replace("\\", "/") + return f"""SSH skill for Claude Code. + +RULES: +- NEVER read servers.json directly +- ONLY use ssh.py for all server operations +- Maximum 1 connection attempt per command +- If connection fails — report error, do NOT retry automatically + +Usage: + python "{ssh_py_path}" ALIAS "command" + python "{ssh_py_path}" ALIAS --no-sudo "command" + python "{ssh_py_path}" ALIAS --upload LOCAL REMOTE + python "{ssh_py_path}" ALIAS --download REMOTE LOCAL + python "{ssh_py_path}" ALIAS --install-key + python "{ssh_py_path}" ALIAS --ping + python "{ssh_py_path}" --list + python "{ssh_py_path}" --status + +The user's arguments: $ARGUMENTS +""" diff --git a/core/server_store.py b/core/server_store.py index aa0d023..47864a0 100644 --- a/core/server_store.py +++ b/core/server_store.py @@ -4,11 +4,16 @@ Server store — CRUD + JSON persistence + observer pattern. import json import os +import shutil 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") +# Shared config — same file used by ssh.py and Claude Code /ssh skill +SHARED_DIR = os.path.expanduser("~/.server-connections") +SERVERS_FILE = os.path.join(SHARED_DIR, "servers.json") + +# Fallback: local config dir (for example file) +LOCAL_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config") +EXAMPLE_FILE = os.path.join(LOCAL_CONFIG_DIR, "servers.example.json") SERVER_TYPES = ["ssh", "telnet", "rdp", "mariadb", "mssql", "postgresql"] @@ -39,7 +44,7 @@ class ServerStore: self._save() def _save(self): - os.makedirs(CONFIG_DIR, exist_ok=True) + os.makedirs(SHARED_DIR, exist_ok=True) with open(SERVERS_FILE, "w", encoding="utf-8") as f: json.dump(self._data, f, indent=2, ensure_ascii=False) diff --git a/gui/app.py b/gui/app.py index 2668f72..ce208b9 100644 --- a/gui/app.py +++ b/gui/app.py @@ -13,6 +13,7 @@ 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 +from gui.tabs.setup_tab import SetupTab class App(ctk.CTk): @@ -63,6 +64,7 @@ class App(ctk.CTk): self.tabview.add("Files") self.tabview.add("Info") self.tabview.add("Keys") + self.tabview.add("Setup") self.terminal_tab = TerminalTab(self.tabview.tab("Terminal"), self.store) self.terminal_tab.pack(fill="both", expand=True) @@ -76,6 +78,9 @@ class App(ctk.CTk): self.keys_tab = KeysTab(self.tabview.tab("Keys"), self.store) self.keys_tab.pack(fill="both", expand=True) + self.setup_tab = SetupTab(self.tabview.tab("Setup"), self.store) + self.setup_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) diff --git a/gui/tabs/setup_tab.py b/gui/tabs/setup_tab.py new file mode 100644 index 0000000..f909ccd --- /dev/null +++ b/gui/tabs/setup_tab.py @@ -0,0 +1,126 @@ +""" +Setup tab — one-click installation for Claude Code integration. +""" + +import threading +import customtkinter as ctk +from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key + + +class SetupTab(ctk.CTkFrame): + def __init__(self, master, store): + super().__init__(master, fg_color="transparent") + self.store = store + + # Header + ctk.CTkLabel( + self, text="Claude Code Integration", + font=ctk.CTkFont(size=20, weight="bold") + ).pack(padx=20, pady=(20, 5)) + + ctk.CTkLabel( + self, + text="Setup everything so Claude Code can manage your servers via /ssh skill.\n" + "Both GUI and Claude Code share the same servers.json — add a server here,\n" + "Claude sees it immediately.", + text_color="#9ca3af", justify="center" + ).pack(padx=20, pady=(0, 15)) + + # Status card + self.status_frame = ctk.CTkFrame(self) + self.status_frame.pack(fill="x", padx=20, pady=10) + + ctk.CTkLabel( + self.status_frame, text="Status", + font=ctk.CTkFont(size=14, weight="bold"), anchor="w" + ).pack(fill="x", padx=15, pady=(10, 5)) + + self._status_labels: dict[str, ctk.CTkLabel] = {} + status_items = [ + ("shared_dir", "Shared config dir (~/.server-connections)"), + ("servers_json", "servers.json"), + ("ssh_script", "ssh.py (CLI tool)"), + ("skill_installed", "/ssh skill for Claude Code"), + ("ssh_key_exists", "SSH key (ed25519)"), + ] + for key, label in status_items: + row = ctk.CTkFrame(self.status_frame, fg_color="transparent") + row.pack(fill="x", padx=15, pady=2) + indicator = ctk.CTkLabel(row, text="\u25cf", width=20, text_color="#6b7280") + indicator.pack(side="left") + ctk.CTkLabel(row, text=label, anchor="w").pack(side="left", fill="x", expand=True) + self._status_labels[key] = indicator + + # Buttons + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=20, pady=15) + + self.install_all_btn = ctk.CTkButton( + btn_frame, text="Install Everything", + font=ctk.CTkFont(size=14, weight="bold"), + height=40, fg_color="#22c55e", hover_color="#16a34a", + command=self._install_all + ) + self.install_all_btn.pack(fill="x", pady=(0, 10)) + + # Individual buttons row + ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent") + ind_frame.pack(fill="x") + + ctk.CTkButton(ind_frame, text="ssh.py", width=100, fg_color="#6b7280", + command=self._install_script).pack(side="left", padx=(0, 5)) + ctk.CTkButton(ind_frame, text="/ssh skill", width=100, fg_color="#6b7280", + command=self._install_skill).pack(side="left", padx=5) + ctk.CTkButton(ind_frame, text="SSH key", width=100, fg_color="#6b7280", + command=self._gen_key).pack(side="left", padx=5) + ctk.CTkButton(ind_frame, text="Refresh", width=80, fg_color="#3b82f6", + command=self._refresh_status).pack(side="right") + + # 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=20, pady=(5, 20)) + + # Initial status check + self._refresh_status() + + def _log(self, text: str): + self.log.configure(state="normal") + self.log.insert("end", text + "\n") + self.log.configure(state="disabled") + self.log.see("end") + + def _refresh_status(self): + status = check_status() + for key, label in self._status_labels.items(): + if status.get(key, False): + label.configure(text="\u25cf", text_color="#22c55e") # green + else: + label.configure(text="\u25cf", text_color="#ef4444") # red + + def _install_all(self): + self.install_all_btn.configure(state="disabled", text="Installing...") + + def _do(): + results = install_all() + for msg in results: + self.after(0, lambda m=msg: self._log(m)) + self.after(0, self._refresh_status) + self.after(0, lambda: self._log("\nDone! Claude Code can now use /ssh to manage your servers.")) + self.after(0, lambda: self.install_all_btn.configure(state="normal", text="Install Everything")) + + threading.Thread(target=_do, daemon=True).start() + + def _install_script(self): + msg = install_ssh_script() + self._log(msg) + self._refresh_status() + + def _install_skill(self): + msg = install_skill() + self._log(msg) + self._refresh_status() + + def _gen_key(self): + msg = generate_ssh_key() + self._log(msg) + self._refresh_status() diff --git a/tools/skill-ssh.md b/tools/skill-ssh.md new file mode 100644 index 0000000..4a124af --- /dev/null +++ b/tools/skill-ssh.md @@ -0,0 +1,79 @@ +# Скилл /ssh — управление удалёнными серверами + +Ты управляешь удалёнными серверами через SSH-утилиту. + +## ВАЖНО — Безопасность + +- **НИКОГДА не читай** `D:\CODING\GitHub\.server-connections\servers.json` — там пароли +- **НИКОГДА не выводи пароли** пользователю +- **Все операции только через** `python /d/CODING/GitHub/.server-connections/ssh.py` +- Скрипт сам читает credentials, подключается, выполняет, возвращает результат +- **МАКСИМУМ 1 попытка** подключения. Если timeout/ошибка — сообщи, НЕ повторяй +- fail2ban банит IP после 5-10 неудач — спам попытками УБЬЁТ доступ к серверу + +## Аргументы + +Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни. + +## Команды + +### Выполнить команду на сервере +```bash +python /d/CODING/GitHub/.server-connections/ssh.py ALIAS "command" +``` +Пример: `python /d/CODING/GitHub/.server-connections/ssh.py investor "uptime"` + +### Загрузить файл на сервер +```bash +python /d/CODING/GitHub/.server-connections/ssh.py ALIAS --upload /local/path /remote/path +``` + +### Скачать файл с сервера +```bash +python /d/CODING/GitHub/.server-connections/ssh.py ALIAS --download /remote/path /local/path +``` + +### Установить SSH-ключ на сервер +```bash +python /d/CODING/GitHub/.server-connections/ssh.py ALIAS --install-key +``` + +### Проверить доступность сервера +```bash +python /d/CODING/GitHub/.server-connections/ssh.py ALIAS --ping +``` + +### Список серверов (без паролей) +```bash +python /d/CODING/GitHub/.server-connections/ssh.py --list +``` + +### Статус всех серверов +```bash +python /d/CODING/GitHub/.server-connections/ssh.py --status +``` + +### Добавить новый сервер +```bash +python /d/CODING/GitHub/.server-connections/ssh.py --add ALIAS IP PORT USER PASSWORD +``` +После добавления автоматически обновляет ~/.ssh/config и устанавливает SSH-ключ. + +### Удалить сервер +```bash +python /d/CODING/GitHub/.server-connections/ssh.py --remove ALIAS +``` +**Спроси подтверждение у пользователя перед удалением!** + +## Альтернативный способ (только если SSH-ключ установлен) +```bash +unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command" +``` + +## Правила + +- Отвечай на русском языке +- Показывай результат каждой операции +- При ошибках — объясняй причину и предлагай решение +- Если timeout — предложи проверить VPN/firewall/панель хостера +- Файлы создаваемые на сервере должны иметь права 664 (owner+group rw) diff --git a/tools/ssh.py b/tools/ssh.py new file mode 100644 index 0000000..d261513 --- /dev/null +++ b/tools/ssh.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +""" +SSH utility for Claude Code — connects to servers by alias. +Credentials stored locally in servers.json, NEVER exposed to AI API. + +Usage: + python ssh.py ALIAS "command" # run as configured user (auto-sudo if needed) + python ssh.py ALIAS --no-sudo "command" # run without sudo elevation + python ssh.py ALIAS --upload LOCAL REMOTE + python ssh.py ALIAS --download REMOTE LOCAL + python ssh.py ALIAS --install-key + python ssh.py ALIAS --ping + python ssh.py --list + python ssh.py --status + python ssh.py --add ALIAS IP PORT USER PASSWORD [--note "desc"] + python ssh.py --remove ALIAS +""" + +import sys +import os +import json +import time +import paramiko + +# Shared config — same file used by ServerManager GUI +SHARED_DIR = os.path.expanduser("~/.server-connections") +SERVERS_FILE = os.path.join(SHARED_DIR, "servers.json") +SSH_KEY_PATH = os.path.expanduser("~/.ssh/id_ed25519") +SSH_CONFIG_PATH = os.path.expanduser("~/.ssh/config") + + +# ── Data ────────────────────────────────────────────── + +def load_servers(): + with open(SERVERS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data, {s["alias"]: s for s in data.get("servers", [])} + + +def save_servers(data): + with open(SERVERS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +# ── Connection ──────────────────────────────────────── + +def get_client(server: dict) -> paramiko.SSHClient: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + kwargs = { + "hostname": server["ip"], + "port": server.get("port", 22), + "username": server.get("user", "root"), + "timeout": 15, + "banner_timeout": 15, + } + + # Try key first + if os.path.exists(SSH_KEY_PATH): + try: + kwargs["key_filename"] = SSH_KEY_PATH + client.connect(**kwargs) + return client + except Exception: + del kwargs["key_filename"] + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + # Fallback to password + password = server.get("password", "") + if password: + kwargs["password"] = password + kwargs["look_for_keys"] = False + kwargs["allow_agent"] = False + client.connect(**kwargs) + return client + + raise Exception(f"No auth method for {server['alias']}") + + +# ── Command execution ───────────────────────────────── + +def run_command(server: dict, command: str, use_sudo: bool = True) -> tuple: + """Execute command. If user != root and use_sudo=True, auto-elevates via sudo. + Password is fed through stdin (not visible in process list).""" + client = get_client(server) + try: + user = server.get("user", "root") + need_sudo = use_sudo and user != "root" + + if need_sudo: + # Use sudo -S to read password from stdin + # -p '' suppresses the password prompt text + 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 = 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 from stderr + 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 _shell_quote(s: str) -> str: + """Safely quote a string for bash -c.""" + return "'" + s.replace("'", "'\\''") + "'" + + +# ── File transfer ───────────────────────────────────── + +def upload_file(server: dict, local_path: str, remote_path: str): + client = get_client(server) + try: + sftp = client.open_sftp() + sftp.put(local_path, remote_path) + sftp.chmod(remote_path, 0o664) + sftp.close() + print(f"OK: {local_path} -> {server['alias']}:{remote_path}") + finally: + client.close() + + +def download_file(server: dict, remote_path: str, local_path: str): + client = get_client(server) + try: + sftp = client.open_sftp() + sftp.get(remote_path, local_path) + sftp.close() + print(f"OK: {server['alias']}:{remote_path} -> {local_path}") + finally: + client.close() + + +# ── Key management ──────────────────────────────────── + +def install_key(server: dict): + pub_key_path = SSH_KEY_PATH + ".pub" + if not os.path.exists(pub_key_path): + print(f"ERROR: No public key at {pub_key_path}") + sys.exit(1) + + with open(pub_key_path, "r") as f: + pub_key = f.read().strip() + + check_cmd = f'grep -c "{pub_key}" ~/.ssh/authorized_keys 2>/dev/null || echo 0' + out, _, _ = run_command(server, check_cmd, use_sudo=False) + if out.strip() != "0": + print(f"Key already installed on {server['alias']}") + return + + 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 = run_command(server, command, use_sudo=False) + if "KEY_OK" in out: + print(f"SSH key installed on {server['alias']} ({server['ip']})") + else: + print(f"ERROR: {err or out}") + sys.exit(1) + + +# ── Server management ───────────────────────────────── + +def ping_server(server: dict): + try: + client = get_client(server) + client.close() + print(f"{server['alias']}: ONLINE") + except Exception as e: + print(f"{server['alias']}: OFFLINE ({type(e).__name__})") + + +def list_servers(): + _, servers = load_servers() + print(f"{'Alias':<20} {'IP':<20} {'Port':<8} {'User':<10} {'Key':<6}") + print("-" * 64) + for alias, s in servers.items(): + has_key = "yes" if os.path.exists(SSH_KEY_PATH) else "no" + print(f"{alias:<20} {s['ip']:<20} {s.get('port', 22):<8} {s.get('user', 'root'):<10} {has_key:<6}") + + +def check_status(): + _, servers = load_servers() + print(f"{'Alias':<20} {'IP':<20} {'Status':<10}") + print("-" * 50) + for alias, s in servers.items(): + try: + client = get_client(s) + client.close() + status = "ONLINE" + except Exception: + status = "OFFLINE" + print(f"{alias:<20} {s['ip']:<20} {status:<10}") + + +def add_server(args): + if len(args) < 5: + print("Usage: --add ALIAS IP PORT USER PASSWORD [--note \"desc\"]") + sys.exit(1) + + alias, ip, port, user, password = args[0], args[1], int(args[2]), args[3], args[4] + note = "" + if "--note" in args: + idx = args.index("--note") + if idx + 1 < len(args): + note = args[idx + 1] + + data, servers = load_servers() + if alias in servers: + print(f"ERROR: '{alias}' already exists") + sys.exit(1) + + new_server = { + "alias": alias, "ip": ip, "port": port, + "user": user, "auth": "ssh-key", "password": password, + "notes": note + } + data["servers"].append(new_server) + save_servers(data) + update_ssh_config(alias, ip, port, user) + print(f"Added: {alias} ({user}@{ip}:{port})") + + try: + install_key(new_server) + except Exception as e: + print(f"Warning: key not installed ({e}). Run: ssh.py {alias} --install-key") + + +def remove_server(alias: str): + data, servers = load_servers() + if alias not in servers: + print(f"ERROR: Unknown '{alias}'") + sys.exit(1) + data["servers"] = [s for s in data["servers"] if s["alias"] != alias] + save_servers(data) + remove_from_ssh_config(alias) + print(f"Removed: {alias}") + + +# ── SSH config ──────────────────────────────────────── + +def update_ssh_config(alias, ip, port, user): + if not os.path.exists(SSH_CONFIG_PATH): + return + with open(SSH_CONFIG_PATH, "r") as f: + content = f.read() + if f"Host {alias}\n" in content: + return + with open(SSH_CONFIG_PATH, "a") as f: + f.write(f"\nHost {alias}\n HostName {ip}\n User {user}\n Port {port}\n") + + +def remove_from_ssh_config(alias): + if not os.path.exists(SSH_CONFIG_PATH): + return + with open(SSH_CONFIG_PATH, "r") as f: + lines = f.readlines() + new_lines, skip = [], False + for line in lines: + if line.strip() == f"Host {alias}": + skip = True + continue + if skip and line.startswith(" "): + continue + skip = False + new_lines.append(line) + with open(SSH_CONFIG_PATH, "w") as f: + f.writelines(new_lines) + + +# ── Main ────────────────────────────────────────────── + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "--list": + list_servers(); sys.exit(0) + if cmd == "--status": + check_status(); sys.exit(0) + if cmd == "--add": + add_server(sys.argv[2:]); sys.exit(0) + if cmd == "--remove" and len(sys.argv) >= 3: + remove_server(sys.argv[2]); sys.exit(0) + + # Server commands + alias = cmd + _, servers = load_servers() + if alias not in servers: + print(f"Unknown: {alias}. Available: {', '.join(servers.keys())}") + sys.exit(1) + + server = servers[alias] + if len(sys.argv) < 3: + print(f"Usage: ssh.py {alias} ") + sys.exit(1) + + action = sys.argv[2] + + if action == "--install-key": + install_key(server) + elif action == "--ping": + ping_server(server) + elif action == "--upload" and len(sys.argv) >= 5: + upload_file(server, sys.argv[3], sys.argv[4]) + elif action == "--download" and len(sys.argv) >= 5: + download_file(server, sys.argv[3], sys.argv[4]) + elif action == "--no-sudo": + command = " ".join(sys.argv[3:]) + out, err, code = run_command(server, command, use_sudo=False) + if out: print(out, end="") + if err: print(err, end="", file=sys.stderr) + sys.exit(code) + else: + command = " ".join(sys.argv[2:]) + out, err, code = run_command(server, command) + if out: print(out, end="") + if err: print(err, end="", file=sys.stderr) + sys.exit(code) + + +if __name__ == "__main__": + main()