381 lines
16 KiB
Python
381 lines
16 KiB
Python
"""
|
||
Local AI agent integration setup.
|
||
Installs the shared ssh.py/encryption.py backend, Claude /ssh command,
|
||
Codex skill package, platform-specific wrappers, and SSH key material.
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
|
||
from core.logger import log
|
||
|
||
SHARED_DIR = os.path.expanduser("~/.server-connections")
|
||
|
||
# PyInstaller: bundled data is in sys._MEIPASS; otherwise use project dir
|
||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||
_BASE_DIR = sys._MEIPASS
|
||
else:
|
||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
SSH_SCRIPT_SRC = os.path.join(_BASE_DIR, "tools", "ssh.py")
|
||
ENCRYPTION_SRC = os.path.join(_BASE_DIR, "core", "encryption.py")
|
||
CLAUDE_SKILL_SRC = os.path.join(_BASE_DIR, "tools", "skill-ssh.md")
|
||
|
||
CLAUDE_SKILL_DST_DIR = os.path.expanduser("~/.claude/commands")
|
||
CLAUDE_SKILL_DST = os.path.join(CLAUDE_SKILL_DST_DIR, "ssh.md")
|
||
SSH_KEY_PATH = os.path.expanduser("~/.ssh/id_ed25519")
|
||
GLOBAL_CLAUDE_MD = os.path.expanduser("~/.claude/CLAUDE.md")
|
||
|
||
CODEX_SKILL_SRC_DIR = os.path.join(_BASE_DIR, ".codex", "skills", "server-manager")
|
||
CODEX_SKILL_DST_ROOT = os.path.expanduser("~/.codex/skills")
|
||
CODEX_SKILL_DST_DIR = os.path.join(CODEX_SKILL_DST_ROOT, "server-manager")
|
||
CODEX_SKILL_ENTRY = os.path.join(CODEX_SKILL_DST_DIR, "SKILL.md")
|
||
CODEX_WRAPPER_SRC_SH = os.path.join(CODEX_SKILL_SRC_DIR, "scripts", "codex-ssh-wrapper.sh")
|
||
CODEX_WRAPPER_SRC_CMD = os.path.join(CODEX_SKILL_SRC_DIR, "scripts", "codex-ssh-wrapper.cmd")
|
||
CODEX_WRAPPER_DST = os.path.join(
|
||
SHARED_DIR,
|
||
"codex-ssh.cmd" if sys.platform == "win32" else "codex-ssh",
|
||
)
|
||
|
||
_BLOCK_START = "<!-- server-manager:start -->"
|
||
_BLOCK_END = "<!-- server-manager:end -->"
|
||
|
||
GLOBAL_CLAUDE_MD_BLOCK = f"""{_BLOCK_START}
|
||
## Серверы — ТОЛЬКО через /ssh
|
||
|
||
**НИКОГДА не используй raw `ssh` команды.** НИКОГДА не читай `~/.ssh/config` для поиска серверов.
|
||
Все операции с серверами — **ТОЛЬКО через скилл `/ssh`** или напрямую через `ssh.py`:
|
||
|
||
```bash
|
||
python ~/.server-connections/ssh.py --list # список серверов (alias, тип, заметки)
|
||
python ~/.server-connections/ssh.py --info ALIAS # инфо (без creds)
|
||
python ~/.server-connections/ssh.py --status # online/offline
|
||
```
|
||
|
||
При вопросе о сервере — **СНАЧАЛА `--list`**, найди нужный алиас по заметкам и **ПРОВЕРЬ ТИП**.
|
||
Скрипт `ssh.py` сам читает credentials из зашифрованного хранилища. Claude НЕ видит IP, логины, пароли.
|
||
|
||
### КРИТИЧНО — команды зависят от типа сервера
|
||
|
||
**`ALIAS "command"` (shell) — ТОЛЬКО для типов `ssh` и `telnet`!**
|
||
|
||
| Тип | Команды |
|
||
|-----|---------|
|
||
| `ssh`/`telnet` | `ALIAS "cmd"`, `--upload ALIAS local remote`, `--download ALIAS remote local` |
|
||
| `s3` (MinIO и др.) | `--s3-buckets ALIAS`, `--s3-ls ALIAS bucket/prefix`, `--s3-upload ALIAS local bucket/key`, `--s3-download ALIAS bucket/key local`, `--s3-delete ALIAS bucket/key`, `--s3-url ALIAS bucket/key [SEC]`, `--s3-create-bucket ALIAS name` |
|
||
| `mariadb`/`mssql`/`postgresql` | `--sql ALIAS "SELECT ..."`, `--sql-databases ALIAS`, `--sql-tables ALIAS [db]` |
|
||
| `redis` | `--redis ALIAS "GET key"`, `--redis-info ALIAS`, `--redis-keys ALIAS "pattern"` |
|
||
| `grafana` | `--grafana-dashboards ALIAS`, `--grafana-alerts ALIAS` |
|
||
| `prometheus` | `--prom-query ALIAS "up"`, `--prom-targets ALIAS`, `--prom-alerts ALIAS` |
|
||
| `winrm` | `--ps ALIAS "Get-Process"`, `--cmd ALIAS "dir"` |
|
||
|
||
**Формат: `python ~/.server-connections/ssh.py КОМАНДА АЛИАС АРГУМЕНТЫ`** — алиас ВСЕГДА второй после команды.
|
||
|
||
**S3 правило:** перед `--s3-upload/download/delete` — СНАЧАЛА `--s3-buckets ALIAS` и `--s3-ls ALIAS bucket/` чтобы узнать реальные бакеты и пути. НЕ УГАДЫВАЙ имена бакетов!
|
||
|
||
**Запрещено:** использовать `ssh`/`sshpass`, читать `~/.server-connections/` напрямую, раскрывать IP/пароли/порты.
|
||
{_BLOCK_END}
|
||
"""
|
||
|
||
|
||
def _ensure_executable(path: str):
|
||
if sys.platform == "win32" or not os.path.exists(path):
|
||
return
|
||
mode = os.stat(path).st_mode
|
||
os.chmod(path, mode | 0o755)
|
||
|
||
|
||
def _copy_file(src: str, dst: str, executable: bool = False) -> str:
|
||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
if executable:
|
||
_ensure_executable(dst)
|
||
return dst
|
||
|
||
|
||
def _copy_tree(src: str, dst: str) -> str:
|
||
os.makedirs(dst, exist_ok=True)
|
||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||
return dst
|
||
|
||
|
||
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")),
|
||
"encryption": os.path.exists(os.path.join(SHARED_DIR, "encryption.py")),
|
||
"claude_skill_installed": os.path.exists(CLAUDE_SKILL_DST),
|
||
"codex_skill_installed": os.path.exists(CODEX_SKILL_ENTRY),
|
||
"codex_wrapper_installed": os.path.exists(CODEX_WRAPPER_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 and encryption.py to shared dir."""
|
||
os.makedirs(SHARED_DIR, exist_ok=True)
|
||
results = []
|
||
|
||
dst = os.path.join(SHARED_DIR, "ssh.py")
|
||
if os.path.exists(SSH_SCRIPT_SRC):
|
||
_copy_file(SSH_SCRIPT_SRC, dst, executable=True)
|
||
log.info(f"ssh.py installed: {dst}")
|
||
results.append(f"ssh.py installed: {dst}")
|
||
elif os.path.exists(dst):
|
||
results.append(f"ssh.py already exists: {dst}")
|
||
else:
|
||
results.append("ERROR: ssh.py source not found")
|
||
|
||
enc_dst = os.path.join(SHARED_DIR, "encryption.py")
|
||
if os.path.exists(ENCRYPTION_SRC):
|
||
_copy_file(ENCRYPTION_SRC, enc_dst)
|
||
log.info(f"encryption.py installed: {enc_dst}")
|
||
results.append(f"encryption.py installed: {enc_dst}")
|
||
elif os.path.exists(enc_dst):
|
||
results.append(f"encryption.py already exists: {enc_dst}")
|
||
else:
|
||
results.append("ERROR: encryption.py source not found")
|
||
|
||
return "\n".join(results)
|
||
|
||
|
||
def install_claude_skill() -> str:
|
||
"""Install /ssh skill for Claude Code."""
|
||
os.makedirs(CLAUDE_SKILL_DST_DIR, exist_ok=True)
|
||
if os.path.exists(CLAUDE_SKILL_SRC):
|
||
_copy_file(CLAUDE_SKILL_SRC, CLAUDE_SKILL_DST)
|
||
log.info(f"Claude skill installed: {CLAUDE_SKILL_DST}")
|
||
return f"Claude skill installed: {CLAUDE_SKILL_DST}"
|
||
if os.path.exists(CLAUDE_SKILL_DST):
|
||
return f"Claude skill already exists: {CLAUDE_SKILL_DST}"
|
||
|
||
skill_content = _generate_skill_content()
|
||
with open(CLAUDE_SKILL_DST, "w", encoding="utf-8") as f:
|
||
f.write(skill_content)
|
||
log.info(f"Claude skill generated: {CLAUDE_SKILL_DST}")
|
||
return f"Claude skill generated: {CLAUDE_SKILL_DST}"
|
||
|
||
|
||
def install_codex_skill() -> str:
|
||
"""Install ServerManager skill package for Codex and the local wrapper."""
|
||
results = []
|
||
|
||
if os.path.isdir(CODEX_SKILL_SRC_DIR):
|
||
_copy_tree(CODEX_SKILL_SRC_DIR, CODEX_SKILL_DST_DIR)
|
||
for rel_path in [
|
||
os.path.join("scripts", "server-manager-doctor.sh"),
|
||
os.path.join("scripts", "server-manager-doctor.cmd"),
|
||
os.path.join("scripts", "codex-ssh-wrapper.sh"),
|
||
os.path.join("scripts", "codex-ssh-wrapper.cmd"),
|
||
]:
|
||
_ensure_executable(os.path.join(CODEX_SKILL_DST_DIR, rel_path))
|
||
log.info(f"Codex skill installed: {CODEX_SKILL_DST_DIR}")
|
||
results.append(f"Codex skill installed: {CODEX_SKILL_DST_DIR}")
|
||
elif os.path.exists(CODEX_SKILL_ENTRY):
|
||
results.append(f"Codex skill already exists: {CODEX_SKILL_DST_DIR}")
|
||
else:
|
||
results.append("ERROR: Codex skill source not found")
|
||
|
||
wrapper_src = CODEX_WRAPPER_SRC_CMD if sys.platform == "win32" else CODEX_WRAPPER_SRC_SH
|
||
if os.path.exists(wrapper_src):
|
||
_copy_file(wrapper_src, CODEX_WRAPPER_DST, executable=(sys.platform != "win32"))
|
||
log.info(f"Codex wrapper installed: {CODEX_WRAPPER_DST}")
|
||
results.append(f"Codex wrapper installed: {CODEX_WRAPPER_DST}")
|
||
elif os.path.exists(CODEX_WRAPPER_DST):
|
||
results.append(f"Codex wrapper already exists: {CODEX_WRAPPER_DST}")
|
||
else:
|
||
results.append("ERROR: Codex wrapper source not found")
|
||
|
||
return "\n".join(results)
|
||
|
||
|
||
def install_skill() -> str:
|
||
"""Backward-compatible alias for the Claude /ssh skill installer."""
|
||
return install_claude_skill()
|
||
|
||
|
||
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)
|
||
|
||
try:
|
||
subprocess.run(
|
||
["ssh-keygen", "-t", "ed25519", "-f", SSH_KEY_PATH,
|
||
"-N", "", "-C", "server-manager"],
|
||
check=True, capture_output=True, timeout=15
|
||
)
|
||
log.info(f"SSH key generated: {SSH_KEY_PATH}")
|
||
return f"Key generated: {SSH_KEY_PATH}"
|
||
except FileNotFoundError:
|
||
hint = "enable OpenSSH optional feature" if sys.platform == "win32" else "install openssh-client"
|
||
msg = f"ERROR: ssh-keygen not found — {hint}"
|
||
log.error(msg)
|
||
return msg
|
||
except subprocess.CalledProcessError as e:
|
||
msg = f"ERROR: ssh-keygen failed: {e.stderr.decode().strip()}"
|
||
log.error(msg)
|
||
return msg
|
||
|
||
|
||
def install_global_claude_md() -> str:
|
||
"""Add/update server manager section in global ~/.claude/CLAUDE.md."""
|
||
os.makedirs(os.path.dirname(GLOBAL_CLAUDE_MD), exist_ok=True)
|
||
|
||
existing = ""
|
||
if os.path.exists(GLOBAL_CLAUDE_MD):
|
||
with open(GLOBAL_CLAUDE_MD, encoding="utf-8") as f:
|
||
existing = f.read()
|
||
|
||
pattern = re.compile(
|
||
re.escape(_BLOCK_START) + r".*?" + re.escape(_BLOCK_END),
|
||
re.DOTALL
|
||
)
|
||
|
||
if pattern.search(existing):
|
||
updated = pattern.sub(GLOBAL_CLAUDE_MD_BLOCK.strip(), existing)
|
||
with open(GLOBAL_CLAUDE_MD, "w", encoding="utf-8") as f:
|
||
f.write(updated)
|
||
log.info(f"Global CLAUDE.md block updated: {GLOBAL_CLAUDE_MD}")
|
||
return f"Global CLAUDE.md block updated: {GLOBAL_CLAUDE_MD}"
|
||
|
||
with open(GLOBAL_CLAUDE_MD, "a", encoding="utf-8") as f:
|
||
if existing and not existing.endswith("\n"):
|
||
f.write("\n")
|
||
f.write("\n" + GLOBAL_CLAUDE_MD_BLOCK)
|
||
log.info(f"Global CLAUDE.md block added: {GLOBAL_CLAUDE_MD}")
|
||
return f"Global CLAUDE.md block added: {GLOBAL_CLAUDE_MD}"
|
||
|
||
|
||
def install_all() -> list[str]:
|
||
"""Full setup — install everything for Claude Code and Codex."""
|
||
results = []
|
||
|
||
steps = [
|
||
("ssh_script", install_ssh_script),
|
||
("claude_skill", install_claude_skill),
|
||
("codex_skill", install_codex_skill),
|
||
("ssh_key", generate_ssh_key),
|
||
("global_claude_md", install_global_claude_md),
|
||
]
|
||
|
||
for name, func in steps:
|
||
try:
|
||
log.info(f"install_all: running {name}")
|
||
result = func()
|
||
results.append(result)
|
||
except Exception as e:
|
||
msg = f"ERROR ({name}): {e}"
|
||
log.error(msg)
|
||
results.append(msg)
|
||
|
||
return results
|
||
|
||
|
||
def _generate_skill_content() -> str:
|
||
"""Generate /ssh skill markdown (fallback if skill-ssh.md not found)."""
|
||
return """# Скилл /ssh — управление удалёнными серверами
|
||
|
||
Ты управляешь удалёнными серверами через SSH-утилиту.
|
||
|
||
## ВАЖНО — Безопасность
|
||
|
||
- **НИКОГДА не читай файлы** в директории `~/.server-connections/` напрямую
|
||
- **НИКОГДА не читай** файлы `encryption.py`, `servers.json`, `settings.json`
|
||
- **НИКОГДА не выводи пароли, IP-адреса, логины, порты** пользователю и в контекст нейронки
|
||
- **НИКОГДА не используй** `--list-full` — он выводит IP/порты/логины в контекст AI
|
||
- **Все операции только через** `python ~/.server-connections/ssh.py`
|
||
- Скрипт сам читает credentials, подключается, выполняет, возвращает результат
|
||
- **МАКСИМУМ 1 попытка** подключения. Если timeout/ошибка — сообщи, НЕ повторяй
|
||
- fail2ban банит IP после 5-10 неудач — спам попытками УБЬЁТ доступ к серверу
|
||
- **Серверы добавляются ТОЛЬКО через GUI** ServerManager, НЕ через CLI
|
||
|
||
## Аргументы
|
||
|
||
Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни.
|
||
|
||
## Команды
|
||
|
||
### Список серверов (безопасный — alias, тип, ключ, заметки)
|
||
```bash
|
||
python ~/.server-connections/ssh.py --list
|
||
```
|
||
|
||
### Информация о сервере (безопасная — без IP/логина/пароля/порта)
|
||
```bash
|
||
python ~/.server-connections/ssh.py --info ALIAS
|
||
```
|
||
|
||
### Статус всех серверов (alias + online/offline)
|
||
```bash
|
||
python ~/.server-connections/ssh.py --status
|
||
```
|
||
|
||
### Выполнить команду на сервере
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS "command"
|
||
```
|
||
|
||
### Выполнить команду БЕЗ sudo
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --no-sudo "command"
|
||
```
|
||
|
||
### Загрузить файл на сервер
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --upload "local/file" //remote/path/file
|
||
```
|
||
**ВАЖНО (Windows/Git Bash):** remote path ОБЯЗАТЕЛЬНО с двойным слешем `//home/...`, `//tmp/...`.
|
||
|
||
### Скачать файл с сервера
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --download //remote/path/file "local/file"
|
||
```
|
||
|
||
### Установить SSH-ключ на сервер
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --install-key
|
||
```
|
||
|
||
### Проверить доступность сервера
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --ping
|
||
```
|
||
|
||
### Обновить заметки сервера
|
||
```bash
|
||
python ~/.server-connections/ssh.py --set-note ALIAS "описание сервера"
|
||
```
|
||
|
||
### Удалить сервер
|
||
```bash
|
||
python ~/.server-connections/ssh.py --remove ALIAS
|
||
```
|
||
**Спроси подтверждение у пользователя перед удалением!**
|
||
|
||
## Поведение
|
||
|
||
- **Auto-sudo**: если user не root — команды автоматически оборачиваются в `sudo -S`
|
||
- **--no-sudo**: для команд без root (например `ls`, `cat`)
|
||
- **Timeout**: 120 секунд на команду, 15 секунд на подключение
|
||
- **SSH-ключ**: пробуется первым, fallback на пароль
|
||
- **Прогресс**: файлы >=1MB показывают 25/50/75%, итог с размером/временем/скоростью
|
||
|
||
## Правила
|
||
|
||
- Отвечай на русском языке
|
||
- Показывай результат каждой операции
|
||
- При ошибках — объясняй причину и предлагай решение
|
||
- Если timeout — предложи проверить VPN/firewall/панель хостера
|
||
- Файлы создаваемые на сервере должны иметь права 664 (owner+group rw)
|
||
- При вопросе о серверах — СНАЧАЛА `--list`, потом `--info ALIAS` если нужны детали
|
||
"""
|