修复:向导认证成功后卡死、配置不持久化

根因:非 Windows 走 keyring 加密,无桌面环境的服务器上 keyring 后端不可用,
保存时抛异常/挂起 → 第2步不出现且配置未落盘。
- storage: 非 Windows 改为明文保存 + 文件权限 600(与 ~/.ssh 私钥同级),不再依赖 keyring
- cli: 顶层异常兜底(打印堆栈,不再无声退出);向导显示配置文件路径
- 兼容读取旧 kr: 前缀配置;install.sh 去掉 keyring 依赖
This commit is contained in:
leefer
2026-09-27 20:24:01 +08:00
parent 0fb9f3898c
commit 199b46b593
4 changed files with 29 additions and 27 deletions
+2 -2
View File
@@ -46,8 +46,8 @@ ssh-tunnel --help # 完整参数(host add / tunnel add 等)
## 数据与安全
配置文件位置:Windows `%APPDATA%\SshTunnelManager\`、Linux `~/ssh-e2e/.config/`、
macOS `~/ssh-e2e/Library/`。密码与私钥口令:Windows 经 DPAPI 加密;Linux/macOS
在装有 keyring 时经 AES-GCM 加密(keyring 派生密钥),否则明文保存并在运行时提示。
macOS `~/ssh-e2e/Library/`。密码与私钥口令:Windows 经 DPAPI 加密(绑定系统用户);
Linux/macOS 以明文保存,但文件权限已收紧为仅本用户可读(600),与 `~/.ssh/` 私钥同级。
主机公钥记录在配置目录 `known_hosts`,主机密钥变更时拒绝连接。
## 开发
+14 -4
View File
@@ -112,7 +112,7 @@ def cmd_tunnel_add(store, args) -> int:
def cmd_list(store, _args) -> int:
if store.warn_plaintext:
_print("提示: 当前平台无可用密钥后端,密码以明文保存于配置文件。")
_print("提示: 当前平台密码以明文保存于配置文件(权限已设为仅本用户可读)。")
_print("主机:")
if not store.data["hosts"]:
_print(" (无) 添加: ssh-tunnel host add <名称> user@host")
@@ -171,7 +171,7 @@ def run_foreground(store: Store, chosen: list) -> int:
mgr.apply_config(chosen, hosts_by_id)
if store.warn_plaintext:
_print("提示: 当前平台无可用密钥后端,密码以明文保存于配置文件。")
_print("提示: 当前平台密码以明文保存于配置文件(权限已设为仅本用户可读)。")
names = ", ".join(t["name"] for t in chosen)
_print(f"启动隧道: {names} — Ctrl+C 退出")
@@ -376,9 +376,10 @@ def wizard(store: Store) -> int:
_print("│ SSH 隧道配置向导 │")
_print("╰─────────────────────────────╯")
_print("按中文提示输入,直接回车采用 [方括号] 里的默认值;Ctrl+C 随时退出。")
_print(f"配置文件: {store.path}")
if store.warn_plaintext:
_print("提示: 当前平台无可用密钥后端,密码将以明文保存于配置文件。")
_print("提示: 当前平台密码以明文保存于配置文件(权限已设为仅本用户可读)。")
host = _wizard_pick_host(store)
if not host:
@@ -458,4 +459,13 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)
except SystemExit:
raise
except Exception: # noqa: BLE001 出错必须可见,禁止无声退出
import traceback
sys.stderr.write("\n发生错误,已退出:\n" + traceback.format_exc())
sys.exit(1)
+1 -1
View File
@@ -110,7 +110,7 @@ if [ ! -f "$APP_DIR/.venv/deps-done" ]; then
die "虚拟环境创建失败(Debian/Ubuntu 可执行: sudo apt install python3-venv)"
}
"$APP_DIR/.venv/bin/pip" install --quiet --disable-pip-version-check \
paramiko cryptography keyring > /dev/null
paramiko cryptography > /dev/null
touch "$APP_DIR/.venv/deps-done"
fi
+12 -20
View File
@@ -77,35 +77,21 @@ def _dpapi_unprotect(text: str) -> str:
ctypes.windll.kernel32.LocalFree(ctypes.c_void_p(dst.pbData))
# ---------- keyring(Linux/macOS) ----------
# ---------- keyring(仅兼容读取旧配置;不再用于写入) ----------
# 实践证明无桌面环境的服务器上 keyring 后端"注册了但不可用",
# 调用时抛异常甚至挂起,会连累配置保存。非 Windows 改为明文 + 文件权限 600。
def _keyring():
try:
import keyring # noqa: PLC0415
if keyring.get_keyring().priority >= 0: # 有真实后端(非 fail/null)
if keyring.get_keyring().priority >= 0:
return keyring
except Exception:
pass
return None
def _kr_protect(text: str) -> str:
import base64
kr = _keyring()
if kr is None:
return text # 无后端:明文(加载时会告警)
import hashlib
key = hashlib.sha256((kr.get_password("ssh-tunnel-manager", "salt")
or "fixed-salt").encode()).digest()[:16]
nonce = os.urandom(12)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
ct = AESGCM(key).encrypt(nonce, text.encode("utf-8"), None)
return _PREFIX2 + base64.b64encode(nonce + ct).decode("ascii")
def _kr_unprotect(text: str) -> str:
if not text.startswith(_PREFIX2):
return text
import base64
raw = base64.b64decode(text[len(_PREFIX2):])
kr = _keyring()
@@ -121,11 +107,15 @@ def _kr_unprotect(text: str) -> str:
# ---------- 统一入口 ----------
def protect(text: str) -> str:
"""Windows: DPAPI 加密。其他平台:明文(文件权限已收紧到 600)。"""
if not text:
return text
if sys.platform == "win32":
return _dpapi_protect(text)
return _kr_protect(text)
try:
return _dpapi_protect(text)
except Exception:
return text
return text
def unprotect(text: str) -> str:
@@ -185,6 +175,8 @@ class Store:
h[k] = protect(h[k])
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
if sys.platform != "win32":
os.chmod(tmp, 0o600) # 明文机密:至少限制为仅本用户可读
os.replace(tmp, self.path)
@staticmethod