v1.5.0: network interface binding, SSH fixes, terminal, release script
- Add network interface selection per server (VPN/multi-NIC support) - Fix "Install Everything" button hanging on error - Add interactive SSH terminal with PTY (pyte + xterm-256color) - Add release.py for automated versioning and changelog generation - Add CLAUDE.md with project instructions - Add screenshots and release binaries for v1.1–v1.4 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ for Claude Code to manage servers via the shared servers.json.
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from core.logger import log
|
||||
|
||||
SHARED_DIR = os.path.expanduser("~/.server-connections")
|
||||
|
||||
@@ -47,6 +48,7 @@ def install_ssh_script() -> str:
|
||||
dst = os.path.join(SHARED_DIR, "ssh.py")
|
||||
if os.path.exists(SSH_SCRIPT_SRC):
|
||||
shutil.copy2(SSH_SCRIPT_SRC, dst)
|
||||
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}")
|
||||
@@ -57,6 +59,7 @@ def install_ssh_script() -> str:
|
||||
enc_dst = os.path.join(SHARED_DIR, "encryption.py")
|
||||
if os.path.exists(ENCRYPTION_SRC):
|
||||
shutil.copy2(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}")
|
||||
@@ -71,6 +74,7 @@ def install_skill() -> str:
|
||||
os.makedirs(SKILL_DST_DIR, exist_ok=True)
|
||||
if os.path.exists(SKILL_SRC):
|
||||
shutil.copy2(SKILL_SRC, SKILL_DST)
|
||||
log.info(f"Skill installed: {SKILL_DST}")
|
||||
return f"Skill installed: {SKILL_DST}"
|
||||
# Fallback: check existing
|
||||
if os.path.exists(SKILL_DST):
|
||||
@@ -79,6 +83,7 @@ def install_skill() -> str:
|
||||
skill_content = _generate_skill_content()
|
||||
with open(SKILL_DST, "w", encoding="utf-8") as f:
|
||||
f.write(skill_content)
|
||||
log.info(f"Skill generated: {SKILL_DST}")
|
||||
return f"Skill generated: {SKILL_DST}"
|
||||
|
||||
|
||||
@@ -89,7 +94,13 @@ def generate_ssh_key() -> str:
|
||||
|
||||
os.makedirs(os.path.dirname(SSH_KEY_PATH), exist_ok=True)
|
||||
|
||||
import paramiko
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
msg = "ERROR: paramiko is not installed — cannot generate SSH key"
|
||||
log.error(msg)
|
||||
return msg
|
||||
|
||||
key = paramiko.Ed25519Key.generate()
|
||||
key.write_private_key_file(SSH_KEY_PATH)
|
||||
|
||||
@@ -97,15 +108,30 @@ def generate_ssh_key() -> str:
|
||||
with open(SSH_KEY_PATH + ".pub", "w") as f:
|
||||
f.write(pub_key + "\n")
|
||||
|
||||
log.info(f"SSH key generated: {SSH_KEY_PATH}")
|
||||
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())
|
||||
|
||||
steps = [
|
||||
("ssh_script", install_ssh_script),
|
||||
("skill", install_skill),
|
||||
("ssh_key", generate_ssh_key),
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
63
core/i18n.py
63
core/i18n.py
@@ -107,6 +107,12 @@ _EN = {
|
||||
"clear": "Clear",
|
||||
"no_server_selected": "[!] No server selected",
|
||||
"server_not_found": "[!] Server '{alias}' not found",
|
||||
"term_connecting": "Connecting to {alias}...",
|
||||
"term_connected": "Connected to {alias}",
|
||||
"term_disconnected": "Disconnected",
|
||||
"term_reconnecting": "Reconnecting ({n}/{max})...",
|
||||
"term_connect_failed": "Connection failed: {error}",
|
||||
"term_reconnect_fail": "Disconnected (reconnect failed)",
|
||||
|
||||
# Files
|
||||
"upload": "Upload",
|
||||
@@ -209,6 +215,21 @@ _EN = {
|
||||
"totp_secret_dialog": "TOTP Secret",
|
||||
"placeholder_totp_secret": "Base32 secret (optional)",
|
||||
"port_out_of_range": "Port must be 1-65535",
|
||||
|
||||
# Monitoring
|
||||
"monitoring": "Monitoring",
|
||||
"check_interval": "Check interval",
|
||||
"skip_check": "Skip status checks",
|
||||
"skip_check_desc": "Don't check this server's availability",
|
||||
"interval_30s": "30s",
|
||||
"interval_60s": "60s",
|
||||
"interval_120s": "2min",
|
||||
"interval_300s": "5min",
|
||||
"status_disabled": "disabled",
|
||||
|
||||
# Network interface
|
||||
"network_interface": "Network Interface",
|
||||
"auto_default": "Auto (default)",
|
||||
}
|
||||
|
||||
_RU = {
|
||||
@@ -293,6 +314,12 @@ _RU = {
|
||||
"clear": "Очистить",
|
||||
"no_server_selected": "[!] Сервер не выбран",
|
||||
"server_not_found": "[!] Сервер '{alias}' не найден",
|
||||
"term_connecting": "Подключение к {alias}...",
|
||||
"term_connected": "Подключено к {alias}",
|
||||
"term_disconnected": "Отключено",
|
||||
"term_reconnecting": "Переподключение ({n}/{max})...",
|
||||
"term_connect_failed": "Ошибка подключения: {error}",
|
||||
"term_reconnect_fail": "Отключено (не удалось переподключиться)",
|
||||
|
||||
# Files
|
||||
"upload": "Загрузить",
|
||||
@@ -395,6 +422,21 @@ _RU = {
|
||||
"totp_secret_dialog": "TOTP-секрет",
|
||||
"placeholder_totp_secret": "Base32 секрет (необязательно)",
|
||||
"port_out_of_range": "Порт должен быть от 1 до 65535",
|
||||
|
||||
# Monitoring
|
||||
"monitoring": "Мониторинг",
|
||||
"check_interval": "Интервал проверки",
|
||||
"skip_check": "Не проверять доступность",
|
||||
"skip_check_desc": "Исключить сервер из автопроверки",
|
||||
"interval_30s": "30с",
|
||||
"interval_60s": "60с",
|
||||
"interval_120s": "2мин",
|
||||
"interval_300s": "5мин",
|
||||
"status_disabled": "отключено",
|
||||
|
||||
# Network interface
|
||||
"network_interface": "Сетевой интерфейс",
|
||||
"auto_default": "Авто (по умолчанию)",
|
||||
}
|
||||
|
||||
_ZH = {
|
||||
@@ -479,6 +521,12 @@ _ZH = {
|
||||
"clear": "清除",
|
||||
"no_server_selected": "[!] 未选择服务器",
|
||||
"server_not_found": "[!] 未找到服务器 '{alias}'",
|
||||
"term_connecting": "正在连接 {alias}...",
|
||||
"term_connected": "已连接到 {alias}",
|
||||
"term_disconnected": "已断开",
|
||||
"term_reconnecting": "重新连接中 ({n}/{max})...",
|
||||
"term_connect_failed": "连接失败:{error}",
|
||||
"term_reconnect_fail": "已断开(重连失败)",
|
||||
|
||||
# Files
|
||||
"upload": "上传",
|
||||
@@ -581,6 +629,21 @@ _ZH = {
|
||||
"totp_secret_dialog": "TOTP密钥",
|
||||
"placeholder_totp_secret": "Base32密钥(可选)",
|
||||
"port_out_of_range": "端口必须在1-65535之间",
|
||||
|
||||
# Monitoring
|
||||
"monitoring": "监控",
|
||||
"check_interval": "检查间隔",
|
||||
"skip_check": "跳过状态检查",
|
||||
"skip_check_desc": "不检查此服务器的可用性",
|
||||
"interval_30s": "30秒",
|
||||
"interval_60s": "60秒",
|
||||
"interval_120s": "2分钟",
|
||||
"interval_300s": "5分钟",
|
||||
"status_disabled": "已禁用",
|
||||
|
||||
# Network interface
|
||||
"network_interface": "网络接口",
|
||||
"auto_default": "自动(默认)",
|
||||
}
|
||||
|
||||
_TRANSLATIONS = {
|
||||
|
||||
@@ -44,6 +44,7 @@ class ServerStore:
|
||||
def __init__(self):
|
||||
self._data: dict = {"servers": [], "ssh_key": {"type": "ed25519", "path": "~/.ssh/id_ed25519"}}
|
||||
self._observers: list[Callable] = []
|
||||
self._check_interval: int = 60
|
||||
self._statuses: dict[str, str] = {} # alias -> "online" | "offline" | "unknown"
|
||||
self._statuses_lock = threading.Lock()
|
||||
self._file_lock = threading.Lock()
|
||||
@@ -66,6 +67,7 @@ class ServerStore:
|
||||
from core import i18n
|
||||
lang = settings.get("language", "en")
|
||||
i18n.set_language(lang)
|
||||
self._check_interval = settings.get("check_interval", 60)
|
||||
except json.JSONDecodeError:
|
||||
log.warning("Corrupted settings.json, using defaults")
|
||||
except Exception as e:
|
||||
@@ -77,6 +79,7 @@ class ServerStore:
|
||||
settings = {
|
||||
"servers_path": self._servers_file,
|
||||
"language": i18n.get_language(),
|
||||
"check_interval": self._check_interval,
|
||||
}
|
||||
try:
|
||||
tmp = SETTINGS_FILE + ".tmp"
|
||||
@@ -284,6 +287,13 @@ class ServerStore:
|
||||
|
||||
# ── Status management (thread-safe) ───────────────
|
||||
|
||||
def get_check_interval(self) -> int:
|
||||
return self._check_interval
|
||||
|
||||
def set_check_interval(self, seconds: int):
|
||||
self._check_interval = max(10, min(600, seconds))
|
||||
self._save_settings()
|
||||
|
||||
def set_status(self, alias: str, status: str):
|
||||
with self._statuses_lock:
|
||||
self._statuses[alias] = status
|
||||
|
||||
@@ -4,10 +4,170 @@ SSH client wrapper — connect, exec, sftp, key management via paramiko.
|
||||
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import paramiko
|
||||
from core.logger import log
|
||||
|
||||
|
||||
def _create_bound_socket(bind_ip: str, hostname: str, port: int, timeout: int) -> socket.socket:
|
||||
"""Create a TCP socket bound to a specific local IP address."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
sock.bind((bind_ip, 0))
|
||||
sock.connect((hostname, port))
|
||||
return sock
|
||||
|
||||
|
||||
def _connect_client(server: dict, key_path: str, timeout: int = 15) -> paramiko.SSHClient:
|
||||
"""Create and authenticate a paramiko SSHClient. Shared by SSHClientWrapper and ShellSession."""
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
hostname = server["ip"]
|
||||
port = server.get("port", 22)
|
||||
bind_ip = server.get("bind_interface")
|
||||
|
||||
kwargs = {
|
||||
"hostname": hostname,
|
||||
"port": port,
|
||||
"username": server.get("user", "root"),
|
||||
"timeout": timeout,
|
||||
"banner_timeout": timeout,
|
||||
}
|
||||
|
||||
if bind_ip:
|
||||
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
|
||||
|
||||
# Try key first
|
||||
if key_path and os.path.exists(key_path):
|
||||
try:
|
||||
kwargs["key_filename"] = key_path
|
||||
client.connect(**kwargs)
|
||||
return client
|
||||
except paramiko.AuthenticationException:
|
||||
log.debug(f"Key auth failed for {server.get('alias', '?')}, trying password")
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
if bind_ip:
|
||||
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
|
||||
except Exception as e:
|
||||
log.debug(f"Key connect failed: {e}")
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
if bind_ip:
|
||||
kwargs["sock"] = _create_bound_socket(bind_ip, hostname, port, timeout)
|
||||
|
||||
# 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.get('alias', 'unknown')}")
|
||||
|
||||
|
||||
class ShellSession:
|
||||
"""Persistent interactive shell session over SSH."""
|
||||
|
||||
def __init__(self, server: dict, key_path: str, cols: int = 80, rows: int = 24):
|
||||
self.server = server
|
||||
self.key_path = key_path
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
self._channel: paramiko.Channel | None = None
|
||||
self._running = False
|
||||
self._read_thread: threading.Thread | None = None
|
||||
|
||||
# Callbacks — set by the owner
|
||||
self.on_data = None # on_data(data: bytes)
|
||||
self.on_disconnect = None # on_disconnect()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return (
|
||||
self._channel is not None
|
||||
and self._channel.get_transport() is not None
|
||||
and self._channel.get_transport().is_active()
|
||||
)
|
||||
|
||||
def connect(self):
|
||||
self._client = _connect_client(self.server, self.key_path)
|
||||
self._channel = self._client.invoke_shell(
|
||||
term="xterm-256color",
|
||||
width=self.cols,
|
||||
height=self.rows,
|
||||
)
|
||||
self._channel.settimeout(0.1)
|
||||
self._running = True
|
||||
self._read_thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._read_thread.start()
|
||||
|
||||
def _read_loop(self):
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
data = self._channel.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
if self.on_data:
|
||||
self.on_data(data)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
except Exception as e:
|
||||
log.debug(f"ShellSession read loop error: {e}")
|
||||
finally:
|
||||
if self._running:
|
||||
self._running = False
|
||||
if self.on_disconnect:
|
||||
self.on_disconnect()
|
||||
|
||||
def send(self, data: bytes):
|
||||
if self._channel and self._running:
|
||||
try:
|
||||
self._channel.sendall(data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def resize(self, cols: int, rows: int):
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
if self._channel and self._running:
|
||||
try:
|
||||
self._channel.resize_pty(width=cols, height=rows)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
self._running = False
|
||||
if self._channel:
|
||||
try:
|
||||
self._channel.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._channel = None
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
def reconnect(self):
|
||||
self.disconnect()
|
||||
time.sleep(0.2)
|
||||
self.connect()
|
||||
|
||||
|
||||
class SSHClientWrapper:
|
||||
def __init__(self, server: dict, key_path: str = ""):
|
||||
self.server = server
|
||||
@@ -15,46 +175,9 @@ class SSHClientWrapper:
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
|
||||
def connect(self) -> paramiko.SSHClient:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
kwargs = {
|
||||
"hostname": self.server["ip"],
|
||||
"port": self.server.get("port", 22),
|
||||
"username": self.server.get("user", "root"),
|
||||
"timeout": 15,
|
||||
"banner_timeout": 15,
|
||||
}
|
||||
|
||||
# Try key first
|
||||
if os.path.exists(self.key_path):
|
||||
try:
|
||||
kwargs["key_filename"] = self.key_path
|
||||
client.connect(**kwargs)
|
||||
self._client = client
|
||||
return client
|
||||
except paramiko.AuthenticationException:
|
||||
log.debug(f"Key auth failed for {self.server.get('alias', '?')}, trying password")
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
except Exception as e:
|
||||
log.debug(f"Key connect failed: {e}")
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
# Fallback to password
|
||||
password = self.server.get("password", "")
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
kwargs["look_for_keys"] = False
|
||||
kwargs["allow_agent"] = False
|
||||
client.connect(**kwargs)
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
raise Exception(f"No auth method for {self.server.get('alias', 'unknown')}")
|
||||
client = _connect_client(self.server, self.key_path)
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
def disconnect(self):
|
||||
if self._client:
|
||||
@@ -73,11 +196,11 @@ class SSHClientWrapper:
|
||||
need_sudo = use_sudo and user != "root"
|
||||
|
||||
if need_sudo:
|
||||
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}"
|
||||
full_cmd = f"export TERM=xterm; sudo -S -p '' bash -c {_shell_quote(command)}"
|
||||
else:
|
||||
full_cmd = command
|
||||
full_cmd = f"export TERM=xterm; {command}"
|
||||
|
||||
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120)
|
||||
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120, get_pty=True)
|
||||
|
||||
if need_sudo:
|
||||
password = self.server.get("password", "")
|
||||
@@ -131,38 +254,9 @@ class SSHClientWrapper:
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
try:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
kwargs = {
|
||||
"hostname": self.server["ip"],
|
||||
"port": self.server.get("port", 22),
|
||||
"username": self.server.get("user", "root"),
|
||||
"timeout": 5,
|
||||
"banner_timeout": 5,
|
||||
}
|
||||
|
||||
if os.path.exists(self.key_path):
|
||||
try:
|
||||
kwargs["key_filename"] = self.key_path
|
||||
client.connect(**kwargs)
|
||||
client.close()
|
||||
return True
|
||||
except Exception:
|
||||
del kwargs["key_filename"]
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
password = self.server.get("password", "")
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
kwargs["look_for_keys"] = False
|
||||
kwargs["allow_agent"] = False
|
||||
client.connect(**kwargs)
|
||||
client.close()
|
||||
return True
|
||||
|
||||
return False
|
||||
client = _connect_client(self.server, self.key_path, timeout=5)
|
||||
client.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ from core.logger import log
|
||||
|
||||
|
||||
class StatusChecker:
|
||||
def __init__(self, store: "ServerStore", interval: int = 60):
|
||||
def __init__(self, store: "ServerStore"):
|
||||
self.store = store
|
||||
self.interval = interval
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._gui_callback = None
|
||||
@@ -48,18 +47,25 @@ class StatusChecker:
|
||||
def _loop(self):
|
||||
while self._running:
|
||||
self._check_cycle()
|
||||
for _ in range(self.interval * 10):
|
||||
interval = self.store.get_check_interval()
|
||||
for _ in range(interval * 10):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
def _check_cycle(self):
|
||||
servers = self.store.get_all()
|
||||
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh"]
|
||||
|
||||
# Mark non-SSH as unknown
|
||||
# Mark skipped servers as disabled
|
||||
for s in servers:
|
||||
if s.get("type", "ssh") != "ssh":
|
||||
if s.get("skip_check", False):
|
||||
self.store.set_status(s["alias"], "disabled")
|
||||
|
||||
ssh_servers = [s for s in servers if s.get("type", "ssh") == "ssh" and not s.get("skip_check", False)]
|
||||
|
||||
# Mark non-SSH (non-skipped) as unknown
|
||||
for s in servers:
|
||||
if s.get("type", "ssh") != "ssh" and not s.get("skip_check", False):
|
||||
self.store.set_status(s["alias"], "unknown")
|
||||
|
||||
if not ssh_servers:
|
||||
|
||||
Reference in New Issue
Block a user