Add Claude Code integration: shared config + Setup tab
- Shared servers.json at ~/.server-connections/ (GUI + Claude Code) - Setup tab: one-click install of ssh.py, /ssh skill, SSH key - Duplicate checks — safe to run multiple times - tools/ssh.py + tools/skill-ssh.md bundled - Updated README with integration docs (EN/RU/ZH) - Deploy guide for new machines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
211
README.md
211
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>Desktop GUI for managing remote servers</strong><br>
|
||||
CustomTkinter + Paramiko | Dark Theme
|
||||
CustomTkinter + Paramiko | Dark Theme | Claude Code Integration
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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
|
||||
|
||||
110
core/claude_setup.py
Normal file
110
core/claude_setup.py
Normal file
@@ -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
|
||||
"""
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
126
gui/tabs/setup_tab.py
Normal file
126
gui/tabs/setup_tab.py
Normal file
@@ -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()
|
||||
79
tools/skill-ssh.md
Normal file
79
tools/skill-ssh.md
Normal file
@@ -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)
|
||||
345
tools/ssh.py
Normal file
345
tools/ssh.py
Normal file
@@ -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} <command>")
|
||||
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()
|
||||
Reference in New Issue
Block a user