修复:向导认证成功后卡死、配置不持久化
根因:非 Windows 走 keyring 加密,无桌面环境的服务器上 keyring 后端不可用, 保存时抛异常/挂起 → 第2步不出现且配置未落盘。 - storage: 非 Windows 改为明文保存 + 文件权限 600(与 ~/.ssh 私钥同级),不再依赖 keyring - cli: 顶层异常兜底(打印堆栈,不再无声退出);向导显示配置文件路径 - 兼容读取旧 kr: 前缀配置;install.sh 去掉 keyring 依赖
This commit is contained in:
@@ -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`,主机密钥变更时拒绝连接。
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user