fix: target-first SSH flow — ask user for THEIR target key/password, don't scan source keys

This commit is contained in:
2026-05-22 23:59:37 +04:00
parent 3ee5104302
commit 0cd968a16d

View File

@@ -1,38 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
ssh.py — SSH-подготовка для transfer на target-сервер. ssh.py — SSH-подготовка для transfer на target-сервер.
Логика: найти ключ → проверить подключение к target. НЕ ищем ключи на source — спрашиваем пользователя как подключиться к target.
Если ключ зашифрован (passphrase) — пропускаем (пользователь должен был разблокировать через ssh-agent).
Если нет ключа или не подходит — меню: пароль target / новый ключ.
""" """
import os import os
import glob
from core.color import info, warn, success, error as cerror, prompt, confirm from core.color import info, warn, success, error as cerror, prompt, confirm
from core.runner import run, exists from core.runner import run, exists
_SSH_DIR = os.path.expanduser("~/.ssh")
def list_private_keys():
"""Ищет приватные SSH-ключи в ~/.ssh/ (id_* без .pub)."""
if not os.path.isdir(_SSH_DIR):
return []
keys = []
for pattern in ("id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"):
for f in glob.glob(os.path.join(_SSH_DIR, pattern)):
if not f.endswith(".pub") and os.path.isfile(f):
pub = f + ".pub"
keys.append({
"private": f,
"public": pub if os.path.isfile(pub) else None,
"type": os.path.basename(f).replace("id_", ""),
})
return keys
def check_ssh_keyless(host, user, port, key_path, timeout=10): def check_ssh_keyless(host, user, port, key_path, timeout=10):
"""Проверяет подключение к target с данным ключом. БЕЗ passphrase-промптов.""" """Проверяет подключение к target с данным ключом."""
if not exists("ssh"): if not exists("ssh"):
return (False, "ssh не найден") return (False, "ssh не найден")
identity = f"-i '{key_path}'" if key_path and os.path.isfile(key_path) else "" identity = f"-i '{key_path}'" if key_path and os.path.isfile(key_path) else ""
@@ -40,13 +18,10 @@ def check_ssh_keyless(host, user, port, key_path, timeout=10):
r = run(cmd, check=False, capture=True, timeout=30) r = run(cmd, check=False, capture=True, timeout=30)
if r.returncode == 0 and "migrate-ok" in r.stdout: if r.returncode == 0 and "migrate-ok" in r.stdout:
return (True, "OK") return (True, "OK")
# Разбираем ошибку
err = (r.stderr or "").strip() err = (r.stderr or "").strip()
if "passphrase" in err.lower(): if "passphrase" in err.lower():
return (False, "PASSPHRASE") return (False, "PASSPHRASE")
if "permission denied" in err.lower() or "too many auth" in err.lower(): return (False, err[:120])
return (False, "DENIED")
return (False, err[:100])
def generate_temp_keypair(host_label): def generate_temp_keypair(host_label):
@@ -65,7 +40,7 @@ def generate_temp_keypair(host_label):
def ssh_copy_id(host, user, port, pubkey_path): def ssh_copy_id(host, user, port, pubkey_path):
"""ssh-copy-id на target. Интерактивно (пользователь вводит пароль target).""" """ssh-copy-id на target. Интерактивно (пароль target)."""
if not exists("ssh-copy-id"): if not exists("ssh-copy-id"):
return False return False
warn("⚠ Сейчас запустится ssh-copy-id — введите ПАРОЛЬ от target-сервера ↓") warn("⚠ Сейчас запустится ssh-copy-id — введите ПАРОЛЬ от target-сервера ↓")
@@ -73,27 +48,7 @@ def ssh_copy_id(host, user, port, pubkey_path):
f"ssh-copy-id -p {port} -o ConnectTimeout=30 -o StrictHostKeyChecking=accept-new -i '{pubkey_path}' {user}@{host}", f"ssh-copy-id -p {port} -o ConnectTimeout=30 -o StrictHostKeyChecking=accept-new -i '{pubkey_path}' {user}@{host}",
check=False, capture=False, timeout=120 check=False, capture=False, timeout=120
) )
if r.returncode == 0: return r.returncode == 0
success("Ключ добавлен на target")
return True
warn(f"ssh-copy-id не удался (код: {r.returncode})")
return False
def manual_copy_instructions(host, user, port, pubkey_path):
"""Инструкции по ручному добавлению ключа на target."""
print()
cerror("SSH-подключение к target не удалось.")
info("Варианты:")
try:
with open(pubkey_path, "r") as f:
pk = f.read().strip()
except Exception:
pk = "(не удалось прочитать)"
print(f" 1) Вручную на target: mkdir -p ~/.ssh; echo '{pk}' >> ~/.ssh/authorized_keys")
print(f" 2) Или: ssh-copy-id -p {port} -i {pubkey_path} {user}@{host}")
print(f" 3) Потом: docker-migrate --resume")
print()
def ensure_sshpass(): def ensure_sshpass():
@@ -113,69 +68,54 @@ def ensure_sshpass():
def pick_or_setup_ssh_key(host, user, port): def pick_or_setup_ssh_key(host, user, port):
""" """
Возвращает путь к рабочему приватному ключу для target. Возвращает путь к приватному ключу для target.
Если None — значит нужен пароль target (sshpass). Если None — значит нужен пароль target (sshpass).
""" """
keys = list_private_keys()
if keys:
info(f"Найдено SSH-ключей на этом сервере: {len(keys)}")
for k in keys:
pub_ok = "✓ .pub" if k["public"] else "✗ нет .pub"
print(f" - {os.path.basename(k['private'])} ({pub_ok})")
# Проверяем каждый ключ
usable = None
encrypted = 0
denied = 0
for k in keys:
label = os.path.basename(k["private"])
ok, why = check_ssh_keyless(host, user, port, k["private"])
if ok:
success(f" ✓ Ключ '{label}' подходит для {host}")
usable = k["private"]
break
if why == "PASSPHRASE":
warn(f" ! Ключ '{label}' зашифрован passphrase (разблокируйте через ssh-add, если нужен)")
encrypted += 1
elif why == "DENIED":
info(f" ✗ Ключ '{label}' не принят target (Permission denied)")
denied += 1
else:
info(f" ✗ Ключ '{label}': {why[:60]}")
if usable:
return usable
# Выводим диагностику
print()
if encrypted and not denied:
info(f"Все ключи зашифрованы passphrase. Если хотите использовать их — разблокируйте:")
info(f" eval $(ssh-agent) && ssh-add")
info(f" Затем перезапустите: docker-migrate --resume")
elif denied:
info(f"Ключи есть, но target их не принимает (не добавлены в ~/.ssh/authorized_keys).")
# Меню выбора
info("") info("")
info("Как подключиться к target-серверу?") info("Как подключиться к target-серверу?")
print(" 1) Пароль — ввести пароль от target (sshpass)") print(" 1) У меня есть приватный ключ от target (файл .pem/.key/.ppk)")
print(" 2) Новый ключ — сгенерировать ed25519 + ssh-copy-id на target") print(" 2) У меня есть логин и пароль от target")
print(" 0) Отмена") print(" 0) Отмена")
choice = prompt("Ваш выбор", default="1").strip() choice = prompt("Ваш выбор", default="1").strip()
if choice == "2":
temp = generate_temp_keypair(host.replace(".", "_"))
if not temp:
raise RuntimeError("Не удалось сгенерировать ключ")
if ssh_copy_id(host, user, port, temp["public"]):
ok, _ = check_ssh_keyless(host, user, port, temp["private"])
if ok:
success("Временный ключ работает!")
return temp["private"]
manual_copy_instructions(host, user, port, temp["public"])
raise RuntimeError("ssh-copy-id не удался. Настройте вручную и запустите --resume")
if choice == "1": if choice == "1":
return None # caller запросит пароль # Пользователь даёт путь к ключу target
key_path = prompt("Путь к приватному ключу (например, /root/server.pem)").strip()
if not key_path or not os.path.isfile(key_path):
raise RuntimeError(f"Файл не найден: {key_path}")
ok, why = check_ssh_keyless(host, user, port, key_path)
if ok:
success("Ключ подходит для target!")
return key_path
if why == "PASSPHRASE":
warn(f"Ключ защищён passphrase. Разблокируйте: ssh-add {key_path}")
raise RuntimeError("Ключ зашифрован. Разблокируйте через ssh-add и перезапустите.")
raise RuntimeError(f"Ключ не подходит: {why}")
if choice == "2":
# Пароль target
info("")
info("Выберите способ:")
print(" 1) Ввести пароль от target — scp через sshpass")
print(" 2) Сгенерировать временный ключ + ssh-copy-id (target спросит пароль)")
sub = prompt("Ваш выбор", default="1").strip()
if sub == "2":
temp = generate_temp_keypair(host.replace(".", "_"))
if not temp:
raise RuntimeError("Не удалось сгенерировать ключ")
if ssh_copy_id(host, user, port, temp["public"]):
ok, _ = check_ssh_keyless(host, user, port, temp["private"])
if ok:
success("Временный ключ добавлен на target!")
return temp["private"]
# ssh-copy-id не сработал
warn("ssh-copy-id не удался. Варианты:")
print(f" 1) Вручную на target: mkdir -p ~/.ssh; echo '$(cat {temp['public']})' >> ~/.ssh/authorized_keys")
print(f" 2) Затем: docker-migrate --resume")
raise RuntimeError("ssh-copy-id не удался.")
# sub == "1" или любое другое — возвращаем None, caller запросит пароль
return None
raise RuntimeError("Перенос отменён пользователем.") raise RuntimeError("Перенос отменён пользователем.")