diff --git a/README.md b/README.md index 2666b28..5a0c81c 100644 --- a/README.md +++ b/README.md @@ -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`,主机密钥变更时拒绝连接。 ## 开发 diff --git a/cli.py b/cli.py index 80cfc35..27aac6e 100644 --- a/cli.py +++ b/cli.py @@ -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) diff --git a/install.sh b/install.sh index 35ec232..5a16d5f 100644 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/storage.py b/storage.py index 2a3ff17..b9c5821 100644 --- a/storage.py +++ b/storage.py @@ -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