dsh-scnet 0.6.10 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/README_CN.md +9 -7
- package/package.json +1 -1
- package/skills/scnet-hpc/SKILL.md +7 -0
- package/skills/scnet-hpc/VERSION +1 -1
- package/skills/scnet-hpc/references/first-use.md +84 -0
- package/skills/scnet-hpc/references/openapi.md +61 -2
- package/skills/scnet-hpc/references/setup.md +58 -0
- package/skills/scnet-hpc/scripts/_common.sh +4 -2
- package/skills/scnet-hpc/scripts/scnet.py +179 -37
- package/skills/scnet-hpc/scripts/scnet_config.py +4 -2
- package/skills/scnet-hpc/scripts/scnet_credentials.py +122 -16
- package/skills/scnet-hpc/scripts/scnet_sdk/client.py +8 -11
- package/skills/scnet-hpc/scripts/setup-ssh.sh +6 -2
- package/skills/scnet-hpc/scripts/setup.sh +13 -0
package/README.md
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
DSH-SCNet (`dsh-scnet` on npm) is a community-maintained DSH bundle for operating Supercomputing Network (SCNet). It packages the source [`scnet-hpc`](https://github.com/lql341/scnet-hpc) Skill, profile-aware utilities, and bounded tools for SSH, Slurm, OpenAPI files, regional resources, and Notebook discovery.
|
|
6
6
|
|
|
7
|
-
Current release: **0.6.
|
|
7
|
+
Current release: **0.6.12**
|
|
8
8
|
|
|
9
9
|
<!-- scnet-release:start -->
|
|
10
|
-
## 0.6.
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
10
|
+
## 0.6.12 highlights
|
|
11
|
+
|
|
12
|
+
- Make first-time AK/SK setup explicit: validate pasted values, hide both keys, show their source,
|
|
13
|
+
and read back saved credentials before confirming persistence.
|
|
14
|
+
- Support Windows Credential Manager and explain macOS Keychain, Linux keyring requirements,
|
|
15
|
+
PowerShell session scope, and the Windows/WSL2 boundary.
|
|
16
|
+
- Preview SSH key installation and effective configuration, document cluster usernames and
|
|
17
|
+
key expiry, and add onboarding tests across Linux, macOS and Windows.
|
|
16
18
|
<!-- scnet-release:end -->
|
|
17
19
|
|
|
18
20
|
> This is an independent community project. It is compatible with DeepSeek Harness but is not an official DeepSeek product and does not imply endorsement, partnership, or authorization by DeepSeek.
|
package/README_CN.md
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
<p align="center">在 DeepSeek Harness 里操作超算互联网(SCNet):配置 SSH、管理 Slurm 作业、查询 OpenAPI 区域和文件、检查 Notebook 资源与实例,并保留海光 DCU/DTK 诊断能力。</p>
|
|
6
6
|
|
|
7
|
-
当前版本:**0.6.
|
|
7
|
+
当前版本:**0.6.12**
|
|
8
8
|
|
|
9
9
|
<!-- scnet-release:start -->
|
|
10
|
-
## 0.6.
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
10
|
+
## 0.6.12 更新
|
|
11
|
+
|
|
12
|
+
- 优化首次 AK/SK 配置:检查粘贴错误、隐藏双密钥、显示实际来源,保存后读回验证,
|
|
13
|
+
明确区分区域元数据与凭据持久保存。
|
|
14
|
+
- 支持 Windows 凭据管理器,解释 macOS Keychain、Linux 密钥环依赖、PowerShell 会话
|
|
15
|
+
作用域与 Windows/WSL2 环境隔离。
|
|
16
|
+
- 提前展示 SSH 私钥安装计划与实际配置,补充集群用户名、密钥有效期和 Windows OpenSSH
|
|
17
|
+
指引,并增加 Linux、macOS、Windows 三平台配置回归测试。
|
|
16
18
|
<!-- scnet-release:end -->
|
|
17
19
|
|
|
18
20
|
> 本项目是独立维护的社区项目,与 DeepSeek Harness 兼容,但不是 DeepSeek 官方产品,也不表示 DeepSeek 对本项目提供背书、合作或授权。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-scnet",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
4
4
|
"description": "Community DeepSeek Harness bundle for SCNet SSH and OpenAPI jobs, files, Notebook resources, Slurm diagnostics, and Hygon DCU/DTK guidance.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -11,6 +11,13 @@ available through the API. Never silently switch backends.
|
|
|
11
11
|
|
|
12
12
|
## First use and selection
|
|
13
13
|
|
|
14
|
+
- On first use or missing credentials, read `references/first-use.md` and explain in the user's
|
|
15
|
+
language what is needed, where to obtain it, and the next local command before starting setup.
|
|
16
|
+
Prefer OpenAPI (platform username + AK/SK); request SSH credentials only for SSH operations.
|
|
17
|
+
Explain-only/offline work needs no credentials. Reuse working configuration without re-entry.
|
|
18
|
+
- Never request AK/SK or private-key contents in chat. Give a command using the resolved absolute
|
|
19
|
+
Skill path so users can enter secrets in their own terminal; do not launch hidden prompts in
|
|
20
|
+
an agent tool session. Read `references/openapi.md` or `references/setup.md` for backend details.
|
|
14
21
|
- Run `scripts/setup.sh new|modify|status|reset` for explicit configuration lifecycle.
|
|
15
22
|
OpenAPI setup requires Python 3. With no saved preference, default to OpenAPI; credentials
|
|
16
23
|
can come from environment variables or the supported OS credential store.
|
package/skills/scnet-hpc/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.12
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# 首次使用:准备什么、从哪里获取
|
|
2
|
+
|
|
3
|
+
首次介绍或缺少凭据时,先按用户当前任务说明下面的信息,再给本机配置命令。
|
|
4
|
+
使用用户的语言;已经可用的配置直接复用,说明用法或离线准备无需先配置凭据。
|
|
5
|
+
|
|
6
|
+
## 先选需要的连接方式
|
|
7
|
+
|
|
8
|
+
| 现在要做什么 | 推荐方式 | 用户需要准备 |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| 查询队列、文件或管理标准 HPC 作业 | OpenAPI(默认) | SCNet 平台用户名、AK、SK |
|
|
11
|
+
| 安装环境、编译、执行远程 shell、API 未覆盖的操作 | SSH | 目标集群、集群/Linux 用户名、下载的 SSH 私钥文件路径 |
|
|
12
|
+
| 阅读说明、讨论参数、离线准备作业内容 | 暂不连接 | 无需凭据 |
|
|
13
|
+
|
|
14
|
+
不要求用户同时准备两套凭据。平台账号需要已开通目标区域资源;只有 AK/SK 并不会开通资源。
|
|
15
|
+
若还没有账号或资源,参考[SCNet 新手指南](https://www.scnet.cn/help/docs/mainsite/hpc/beginner-guide/)。
|
|
16
|
+
|
|
17
|
+
## OpenAPI:三个输入从哪里来
|
|
18
|
+
|
|
19
|
+
登录 SCNet,进入 **个人中心 → 访问控制**,生成并下载授权码文件。
|
|
20
|
+
按[官方 AK/SK 说明](https://www.scnet.cn/ac/openapi/doc/2.0/api/safecertification/get-user-tokens-aksk.html)对应填写:
|
|
21
|
+
|
|
22
|
+
| 向导里的字段 | 填什么 |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| 平台用户名 | SCNet 平台用户名,不是区域内的 Linux 用户名或账号密码 |
|
|
25
|
+
| AccessKey / AK | 下载文件中 `Accesskey` 列的值 |
|
|
26
|
+
| SecretKey / SK | 同一份文件中 `Secretkey` 列的值 |
|
|
27
|
+
|
|
28
|
+
每次仅复制一个值,不复制列名、整行表格或 `export` / `$env:` 赋值命令。
|
|
29
|
+
这里使用 HPC OpenAPI 授权码,不使用模型服务的 API Key。用户无需手填区域 ID、调度器 ID
|
|
30
|
+
或接口地址;认证后向导会发现已授权区域,再让用户选择。
|
|
31
|
+
|
|
32
|
+
AK/SK 只在用户自己的终端输入,两个字段都隐藏,输入时不显示星号是正常现象。
|
|
33
|
+
不要求用户把授权码文件、密钥或带密钥的截图发到聊天。
|
|
34
|
+
|
|
35
|
+
## SSH:下载什么、核对什么
|
|
36
|
+
|
|
37
|
+
在 SCNet 控制台选择目标区域,进入 **命令行 E-Shell → 左上角 SSH 连接**,下载私钥,
|
|
38
|
+
同时记录连接域名和端口。下载文件可能以 `_rsa.txt` 结尾,`.txt` 本身不代表文件选错。
|
|
39
|
+
参见[官方 SSH 下载步骤](https://www2.scnet.cn/help/docs/mainsite/hpc/advanced-guide/advanced-efile/index.html)。
|
|
40
|
+
|
|
41
|
+
- 集群用户名取该区域实际 Linux 用户名;可在网页 E-Shell 中运行 `whoami` 核对。
|
|
42
|
+
- 私钥字段填本机下载文件的路径,保留原文件,不填私钥正文,也不选 `.pub` 公钥。
|
|
43
|
+
- 主机和端口默认来自所选 `clusters/<集群>.conf`;与控制台不一致时先处理 profile,避免连错目标。
|
|
44
|
+
- SSH 私钥和 AK/SK 不互通。配置、保存位置、有效期及 Windows OpenSSH 步骤见
|
|
45
|
+
[连接配置详解](setup.md)。
|
|
46
|
+
|
|
47
|
+
## 给用户能直接运行的本机命令
|
|
48
|
+
|
|
49
|
+
Agent 应将下面的 `<Skill目录>` 替换为实际安装目录的绝对路径,保留引号,按用户系统提供
|
|
50
|
+
一组命令。交互向导必须在用户可见且可输入的本机终端运行,不能在 Agent 后台工具里等待秘密输入。
|
|
51
|
+
|
|
52
|
+
Windows PowerShell(OpenAPI):
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
python "<Skill目录>/scripts/scnet.py" setup new --mode openapi
|
|
56
|
+
python "<Skill目录>/scripts/scnet.py" setup status
|
|
57
|
+
python "<Skill目录>/scripts/scnet.py" doctor
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
macOS / Linux(OpenAPI):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python3 "<Skill目录>/scripts/scnet.py" setup new --mode openapi
|
|
64
|
+
python3 "<Skill目录>/scripts/scnet.py" setup status
|
|
65
|
+
python3 "<Skill目录>/scripts/scnet.py" doctor
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
已有配置需更换凭据时,用 `setup modify --mode openapi`。SSH 自动安装在 macOS/Linux/WSL2
|
|
69
|
+
使用 `python3 "<Skill目录>/scripts/scnet.py" setup new --mode ssh`,然后选择目标集群。
|
|
70
|
+
Windows 原生使用 OpenAPI 无需 WSL2;SSH 自动安装需要 WSL2 或按连接详解手工配置。
|
|
71
|
+
|
|
72
|
+
保存提示要与本机相符:Windows 用系统凭据管理器,macOS 用系统 Keychain,Linux 用
|
|
73
|
+
`secret-tool`(Ubuntu/Debian 的 `libsecret-tools`)和已解锁的会话密钥环。
|
|
74
|
+
无密钥环的服务器可用宿主秘密注入;完整示例与 PowerShell 会话范围见 [OpenAPI 说明](openapi.md)。
|
|
75
|
+
|
|
76
|
+
`setup status` 查看实际凭据来源及配置状态;`doctor` 只读检查认证和连接。
|
|
77
|
+
区域配置完成不等于凭据已保存;不能仅凭 setup 成功就宣称以后不需要提供凭据。
|
|
78
|
+
|
|
79
|
+
## 首次介绍示例
|
|
80
|
+
|
|
81
|
+
> 建议先用 OpenAPI 配置查询和作业管理。你需要 SCNet 平台用户名,以及“个人中心 →
|
|
82
|
+
> 访问控制”下载文件中的 Accesskey、Secretkey 两项。请只在本机终端向导里填写,
|
|
83
|
+
> 不要发到聊天。我会给你当前安装目录对应的命令,完成后用 status 和 doctor 检查。
|
|
84
|
+
> 如需远程编译或装环境,再到目标区域的“命令行 E-Shell → SSH 连接”下载 SSH 私钥。
|
|
@@ -26,10 +26,48 @@ Obtain AK/SK from the SCNet website:
|
|
|
26
26
|
3. Open **Access Control / 访问控制**.
|
|
27
27
|
4. Generate and download the authorization file containing AccessKey and SecretKey.
|
|
28
28
|
|
|
29
|
+
Use the platform username for `user`, the downloaded file's `Accesskey` column for AK,
|
|
30
|
+
and its `Secretkey` column for SK. Copy each value separately, without column labels or shell
|
|
31
|
+
assignments. These credentials differ from both cluster/Linux usernames and model-service API keys.
|
|
32
|
+
For the initial user briefing and commands that work outside the Skill directory, read
|
|
33
|
+
[first-use.md](first-use.md).
|
|
34
|
+
|
|
29
35
|
Official instructions:
|
|
30
36
|
<https://www.scnet.cn/ac/openapi/doc/2.0/api/safecertification/get-user-tokens-aksk.html>
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
## Guided first-time setup
|
|
39
|
+
|
|
40
|
+
Run `python3 scripts/scnet.py setup new --mode openapi` on macOS/Linux, or
|
|
41
|
+
`python scripts/scnet.py setup new --mode openapi` in Windows PowerShell.
|
|
42
|
+
Use `setup modify --mode openapi` to replace credentials for an existing setup.
|
|
43
|
+
|
|
44
|
+
Copy only each field's value, one at a time: platform login user, AccessKey, then SecretKey.
|
|
45
|
+
The cluster/Linux username is discovered later and may differ. Both key inputs are invisible;
|
|
46
|
+
surrounding quotes are removed, while pasted shell assignments, multiline values, and invisible
|
|
47
|
+
characters are rejected with a retry. The wizard does not display either key.
|
|
48
|
+
|
|
49
|
+
| Environment | Where persistent AK/SK are stored | Dependency and recovery |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| macOS | Login user's Keychain, service `scnet-hpc-openapi` | Built-in `security`; allow access/unlock when prompted; find the item in Keychain Access |
|
|
52
|
+
| Linux desktop | Secret Service, service `scnet-hpc-openapi` | Ubuntu/Debian: `sudo apt install libsecret-tools`; a running, unlocked keyring and session D-Bus are also needed |
|
|
53
|
+
| Linux headless / CI | Host-injected environment | No `libsecret-tools` required for API calls; installing the package alone does not create a working keyring |
|
|
54
|
+
| Native Windows | Current user's Windows Credential Manager, Generic Credential `scnet-hpc-openapi` | Built in; manage through Control Panel > Credential Manager > Windows Credentials |
|
|
55
|
+
| WSL2 | Linux credentials inside WSL2 | Windows Credential Manager and Windows environment changes are not automatically shared |
|
|
56
|
+
|
|
57
|
+
Saving is optional and happens after authentication. A saved item is read back before persistence is
|
|
58
|
+
reported as verified. If saving fails or is declined, **region setup is not credential persistence**:
|
|
59
|
+
the panel records `not_saved` and explains how to provide credentials for subsequent commands.
|
|
60
|
+
Use `setup status` for metadata and the actual credential source visible to the current process;
|
|
61
|
+
use `doctor --no-network` for local checks or `doctor` for a read-only connection check.
|
|
62
|
+
`setup reset` can delete the credential-store item separately from region metadata.
|
|
63
|
+
|
|
64
|
+
### Environment injection
|
|
65
|
+
|
|
66
|
+
Set all three variables together. A partial set is rejected instead of mixing one account's
|
|
67
|
+
username/key with another account's saved credential set. Environment values take precedence over
|
|
68
|
+
the system store; clear stale overrides after saving replacement credentials.
|
|
69
|
+
|
|
70
|
+
Bash/Zsh, in the same terminal that will launch the client:
|
|
33
71
|
|
|
34
72
|
```bash
|
|
35
73
|
export SCNET_OPENAPI_USER="<platform-user>"
|
|
@@ -37,10 +75,31 @@ export SCNET_OPENAPI_ACCESS_KEY="<access-key>"
|
|
|
37
75
|
export SCNET_OPENAPI_SECRET_KEY="<secret-key>"
|
|
38
76
|
```
|
|
39
77
|
|
|
78
|
+
PowerShell, in the same window that will launch the client:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
$env:SCNET_OPENAPI_USER = "<platform-user>"
|
|
82
|
+
$env:SCNET_OPENAPI_ACCESS_KEY = "<access-key>"
|
|
83
|
+
$env:SCNET_OPENAPI_SECRET_KEY = "<secret-key>"
|
|
84
|
+
python scripts/scnet.py --backend openapi doctor
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
These `$env:` assignments last only for the current PowerShell session and its subsequently launched
|
|
88
|
+
children. Already-running desktop applications do not receive them. Prefer the interactive wizard and
|
|
89
|
+
Windows Credential Manager for daily desktop use. Do not use plaintext shell profiles, `setx`, or
|
|
90
|
+
checked-in environment files as the default credential store. Commands containing real secrets may
|
|
91
|
+
be recorded in shell history; host-managed secret injection avoids manual command entry.
|
|
92
|
+
|
|
93
|
+
To stop overriding the store in PowerShell:
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
Remove-Item Env:SCNET_OPENAPI_USER,Env:SCNET_OPENAPI_ACCESS_KEY,Env:SCNET_OPENAPI_SECRET_KEY -ErrorAction SilentlyContinue
|
|
97
|
+
```
|
|
98
|
+
|
|
40
99
|
The client signs the canonical AK/timestamp/user JSON with HMAC-SHA256 when requesting
|
|
41
100
|
region tokens. Tokens are reused from a permission-restricted disk cache for up to four hours
|
|
42
101
|
when available; an expired or unavailable cache triggers authentication. The setup panel can store the
|
|
43
|
-
platform credential set in macOS Keychain
|
|
102
|
+
platform credential set in macOS Keychain, Linux Secret Service, or Windows Credential Manager; otherwise use environment
|
|
44
103
|
variables. Region IDs, region users, schedulers, and home paths are discovered and cached as
|
|
45
104
|
non-secret metadata.
|
|
46
105
|
|
|
@@ -3,6 +3,64 @@
|
|
|
3
3
|
集群的主机名、端口等参数来自 `clusters/<集群短名>.conf`。
|
|
4
4
|
本文用 `<集群>`、`<主机名>`、`<端口>` 表示从 profile 读出的值。
|
|
5
5
|
|
|
6
|
+
## 首次配置前先分清三件事
|
|
7
|
+
|
|
8
|
+
1. **凭据**:在控制台选择目标区域,进入“命令行 E-Shell → 左上角 SSH 连接”,下载私钥并
|
|
9
|
+
记录域名和端口([官方步骤](https://www2.scnet.cn/help/docs/mainsite/hpc/advanced-guide/advanced-efile/index.html))。
|
|
10
|
+
它与 OpenAPI 的 AK/SK 不同;保留原始文件,可能以 `_rsa.txt` 结尾,不要选择 `.pub` 公钥。
|
|
11
|
+
2. **用户名**:填写区域内的集群/Linux 用户名,不一定等于 SCNet 平台登录名;文件名只作为
|
|
12
|
+
自动推断线索,不确定时在该区域网页 E-Shell 中执行 `whoami` 核对。
|
|
13
|
+
3. **运行环境**:macOS/Linux/WSL2 可运行自动安装脚本。Windows 原生 OpenSSH 的配置和
|
|
14
|
+
WSL2 里的 `~/.ssh` 不共享,在哪个环境运行作业工具,就在该环境配置 SSH。
|
|
15
|
+
|
|
16
|
+
在 macOS/Linux/WSL2 运行 `./scripts/setup.sh new --backend ssh`,或
|
|
17
|
+
`python3 scripts/scnet.py setup new --mode ssh`。路径带空格时可使用外层引号;向导只需要路径,
|
|
18
|
+
不要粘贴私钥正文。安装前会显示主机、端口、目标位置,安装后显示实际生效的 `ssh -G` 参数。
|
|
19
|
+
|
|
20
|
+
安装位置为 `~/.ssh/id_rsa_<集群>`,配置为 `~/.ssh/config`。不同的旧私钥先备份;只更新
|
|
21
|
+
本工具管理的 Host 块,其他已有 Host 保留。保留旧 Host 时,务必核对实际 IdentityFile
|
|
22
|
+
是否指向新密钥。`--skip-connect` 只保存元数据,不代表私钥已安装或连接已验证。
|
|
23
|
+
|
|
24
|
+
私钥有口令时先执行 `ssh-add <私钥文件路径>`;如果无法解析但没有设置口令,重新下载原始
|
|
25
|
+
文件,不要把损坏的格式误认为“有口令”。`Permission denied` 还可能表示私钥过期、用户名
|
|
26
|
+
不匹配或 IP 白名单限制,不只是输入错误。
|
|
27
|
+
|
|
28
|
+
## Windows 原生 OpenSSH(手工配置)
|
|
29
|
+
|
|
30
|
+
若使用完整 Bash 工作流,优先在 WSL2 运行上方安装向导。若只使用 Python SSH backend,
|
|
31
|
+
可手工配置 Windows OpenSSH,然后运行 `python scripts/scnet.py setup modify --mode ssh`
|
|
32
|
+
接管已有别名;这不会让 Bash 安装脚本变成 Windows 原生脚本。
|
|
33
|
+
|
|
34
|
+
1. 在 PowerShell 执行 `Get-Command ssh,scp,ssh-keygen`,确认 Windows OpenSSH 客户端已安装。
|
|
35
|
+
2. 把下载的私钥复制到 `$env:USERPROFILE\.ssh\id_rsa_<集群>`;路径可自选,但需与配置一致。
|
|
36
|
+
3. 对复制的目标私钥限制访问权限(下方路径占位符需替换):
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
$sshKeyPath = "$env:USERPROFILE\.ssh\id_rsa_<集群>"
|
|
40
|
+
$sshIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
41
|
+
icacls $sshKeyPath /inheritance:r
|
|
42
|
+
icacls $sshKeyPath /grant:r "${sshIdentity}:(R)"
|
|
43
|
+
notepad "$env:USERPROFILE\.ssh\config"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
在编辑器里追加以下块,替换为 profile 和账号中的实际值,保存为无 BOM 的 UTF-8 文本;
|
|
47
|
+
保留已有无关的 Host 配置。Windows 不添加 `UseKeychain` 和 Unix socket 连接复用选项:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
Host <集群>
|
|
51
|
+
HostName <主机名>
|
|
52
|
+
Port <端口>
|
|
53
|
+
User <集群用户名>
|
|
54
|
+
IdentityFile ~/.ssh/id_rsa_<集群>
|
|
55
|
+
IdentitiesOnly yes
|
|
56
|
+
ServerAliveInterval 60
|
|
57
|
+
ServerAliveCountMax 3
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
先执行 `ssh -G <集群>` 核对 `hostname/user/port/identityfile`,再执行 `ssh <集群>`。
|
|
61
|
+
首次主机指纹请与平台提供的指纹核对。口令私钥需要当前环境的 ssh-agent,Windows 中的
|
|
62
|
+
`ssh-add` 与 WSL2 中的 ssh-agent 也互相独立。
|
|
63
|
+
|
|
6
64
|
## 手工配置
|
|
7
65
|
|
|
8
66
|
```bash
|
|
@@ -53,10 +53,12 @@ refresh_live_node_count() {
|
|
|
53
53
|
[ -n "${CLUSTER_ID:-}" ] || return 0
|
|
54
54
|
[ -n "${PARTITION:-}" ] || return 0
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
# Live metadata is optional; a failed SSH probe must not trigger callers'
|
|
57
|
+
# errexit/pipefail settings or discard the existing profile/cache value.
|
|
58
|
+
local live=""
|
|
57
59
|
live=$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \
|
|
58
60
|
"$CLUSTER_ID" "scontrol show partition ${PARTITION}" 2>/dev/null \
|
|
59
|
-
| tr ' ' '\n' | sed -n 's/^TotalNodes=//p' | head -1)
|
|
61
|
+
| tr ' ' '\n' | sed -n 's/^TotalNodes=//p' | head -1) || live=""
|
|
60
62
|
|
|
61
63
|
if [[ "$live" =~ ^[0-9]+$ ]] && [ "$live" -gt 0 ]; then
|
|
62
64
|
NODE_COUNT="$live"
|
|
@@ -34,6 +34,8 @@ from scnet_credentials import (
|
|
|
34
34
|
CredentialError,
|
|
35
35
|
delete_openapi_credentials,
|
|
36
36
|
load_openapi_credentials,
|
|
37
|
+
load_secure_credentials,
|
|
38
|
+
normalize_credential,
|
|
37
39
|
secure_store_name,
|
|
38
40
|
store_openapi_credentials,
|
|
39
41
|
)
|
|
@@ -414,6 +416,78 @@ def _ask_secret(prompt: str) -> str:
|
|
|
414
416
|
return getpass.getpass(f"{prompt}: ").strip()
|
|
415
417
|
|
|
416
418
|
|
|
419
|
+
def _ask_credential(label: str, *, secret: bool = False, default: str = "") -> str:
|
|
420
|
+
while True:
|
|
421
|
+
value = _ask_secret(label) if secret else _ask(label, default)
|
|
422
|
+
try:
|
|
423
|
+
return normalize_credential(value, label)
|
|
424
|
+
except CredentialError as exc:
|
|
425
|
+
print(f"输入未通过检查:{exc}")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _credential_help() -> None:
|
|
429
|
+
provider = secure_store_name()
|
|
430
|
+
print("\n凭据保存方式(AK/SK 与 SSH 私钥是两套独立凭据):")
|
|
431
|
+
if provider == "Windows Credential Manager":
|
|
432
|
+
print("Windows:可保存到当前用户的 Windows 凭据管理器,无需安装额外工具。")
|
|
433
|
+
print("查看/删除:控制面板 → 凭据管理器 → Windows 凭据 → 普通凭据 → scnet-hpc-openapi。")
|
|
434
|
+
elif provider == "macOS Keychain":
|
|
435
|
+
print("macOS:使用系统自带 Keychain,无需安装依赖;系统可能提示解锁或授权访问。")
|
|
436
|
+
print("可在“钥匙串访问”搜索 scnet-hpc-openapi 查看或删除。")
|
|
437
|
+
elif provider == "Secret Service":
|
|
438
|
+
print("Linux:已找到 secret-tool;保存时还需要运行且已解锁的 Secret Service 密钥环。")
|
|
439
|
+
print("仅安装 libsecret-tools 不保证密钥环可用,无桌面/无 D-Bus 的服务器可使用环境变量。")
|
|
440
|
+
else:
|
|
441
|
+
print("当前没有可用的系统凭据工具;仍可校验 AK/SK,但不会自动持久保存。")
|
|
442
|
+
print("Ubuntu/Debian:sudo apt install libsecret-tools;还需桌面密钥环服务运行并解锁。")
|
|
443
|
+
print("环境变量优先于系统凭据库;config.json/openapi.json 只保存账号与区域等非敏感信息。")
|
|
444
|
+
print("PowerShell 的 $env: 设置仅对当前会话及其子进程生效,已启动的桌面应用不会自动获得。")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _print_environment_help() -> None:
|
|
448
|
+
if platform.system() == "Windows":
|
|
449
|
+
print("在运行本程序的同一个 PowerShell 中注入;以下占位符需替换为真实值:")
|
|
450
|
+
print('$env:SCNET_OPENAPI_USER = "<平台用户名>"')
|
|
451
|
+
print('$env:SCNET_OPENAPI_ACCESS_KEY = "<AccessKey>"')
|
|
452
|
+
print('$env:SCNET_OPENAPI_SECRET_KEY = "<SecretKey>"')
|
|
453
|
+
print("关闭窗口后失效;日常桌面使用建议重新配置并选择系统凭据库。")
|
|
454
|
+
else:
|
|
455
|
+
print("在运行本程序的同一个 Bash/Zsh 中注入,或使用宿主的秘密注入功能:")
|
|
456
|
+
print('export SCNET_OPENAPI_USER="<平台用户名>"')
|
|
457
|
+
print('export SCNET_OPENAPI_ACCESS_KEY="<AccessKey>"')
|
|
458
|
+
print('export SCNET_OPENAPI_SECRET_KEY="<SecretKey>"')
|
|
459
|
+
print("命令中的秘密可能进入 shell 历史;不要把 AK/SK 写入仓库、普通配置或聊天。")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _private_key_path(value: str) -> Path:
|
|
463
|
+
value = value.strip()
|
|
464
|
+
if "PRIVATE KEY" in value or "\n" in value or "\r" in value:
|
|
465
|
+
raise BackendError("这里只接受私钥文件路径,不接受私钥正文。")
|
|
466
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
467
|
+
value = value[1:-1]
|
|
468
|
+
path = Path(value).expanduser()
|
|
469
|
+
if not value or path.suffix == ".pub":
|
|
470
|
+
raise BackendError("请选择私钥文件路径,不要选择 .pub 公钥。")
|
|
471
|
+
return path
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _ssh_setup_help(cluster: str, profile: Mapping[str, str]) -> None:
|
|
475
|
+
print("\nSSH 私钥获取:SCNet 控制台选择目标区域 → 命令行 E-Shell → 左上角 SSH 连接。")
|
|
476
|
+
print("下载原始私钥文件(可能以 _rsa.txt 结尾),记录页面上的域名和端口,与下方目标核对。")
|
|
477
|
+
print("官方步骤:https://www2.scnet.cn/help/docs/mainsite/hpc/advanced-guide/advanced-efile/index.html")
|
|
478
|
+
print("AK/SK 授权码不能用于 SSH。集群用户名可在该区域网页 E-Shell 运行 whoami 核对。")
|
|
479
|
+
print("输入文件路径即可(支持空格和外层引号),不要粘贴私钥正文,也不要选择 .pub 公钥。")
|
|
480
|
+
print(f"连接目标:{profile.get('SSH_HOST')}:{profile.get('SSH_PORT', '22')},别名 {cluster}。")
|
|
481
|
+
print("用户名是该区域的集群/Linux 用户名,可能与 OpenAPI 平台账号不同。")
|
|
482
|
+
print(f"自动安装位置:{Path.home() / '.ssh' / ('id_rsa_' + cluster)}")
|
|
483
|
+
print(f"SSH 配置文件:{Path.home() / '.ssh/config'}")
|
|
484
|
+
print("不同的旧私钥会备份;已有非本工具管理的 Host 块会保留,不会自动改写。")
|
|
485
|
+
if platform.system() == "Windows":
|
|
486
|
+
print("Windows 原生 OpenSSH 与 WSL2 的 ~/.ssh 互相独立;自动安装脚本需在 WSL2 内执行。")
|
|
487
|
+
print("已有 Windows OpenSSH 配置可用 setup modify 接管;手工安装步骤见 references/setup.md。")
|
|
488
|
+
print("若私钥设有口令,先用 ssh-add 加载;连接失败还需检查有效期、端口及平台 IP 白名单。")
|
|
489
|
+
|
|
490
|
+
|
|
417
491
|
def _ask_yes_no(prompt: str, default: bool = True) -> bool:
|
|
418
492
|
hint = "Y/n" if default else "y/N"
|
|
419
493
|
answer = input(f"{prompt} [{hint}]: ").strip().lower()
|
|
@@ -706,10 +780,16 @@ def _print_terminal_help() -> None:
|
|
|
706
780
|
"Linux:使用 ↑/↓ 和 Enter,也可输入编号;"
|
|
707
781
|
"Ctrl+C 可取消。敏感输入不会回显。"
|
|
708
782
|
)
|
|
783
|
+
elif system == "Windows":
|
|
784
|
+
print("Windows:输入编号并按 Enter 选择;Ctrl+C 可取消。敏感输入不会回显。")
|
|
785
|
+
print("OpenAPI 配置可直接运行 Python,并保存到 Windows 凭据管理器;无需 Bash。")
|
|
786
|
+
print("SSH 自动安装及完整 shell 脚本环境请使用 WSL2。")
|
|
709
787
|
print()
|
|
710
788
|
|
|
711
789
|
|
|
712
790
|
def _run_setup_ssh(cluster: str, key_path: str, username: str) -> None:
|
|
791
|
+
if platform.system() == "Windows":
|
|
792
|
+
raise BackendError("Windows 原生环境无法执行 SSH 自动安装脚本;请在 WSL2 中运行 setup,或按 references/setup.md 配置 Windows OpenSSH。")
|
|
713
793
|
script = REPO_ROOT / "scripts" / "setup-ssh.sh"
|
|
714
794
|
command = [str(script), "--cluster", cluster, key_path, username]
|
|
715
795
|
try:
|
|
@@ -873,8 +953,10 @@ def _resolved_ssh_connection(
|
|
|
873
953
|
}
|
|
874
954
|
|
|
875
955
|
|
|
876
|
-
def _ssh_key_metadata(
|
|
877
|
-
|
|
956
|
+
def _ssh_key_metadata(
|
|
957
|
+
cluster: str, source: str, username: str, *, installed_path: str | None = None
|
|
958
|
+
) -> dict[str, Any]:
|
|
959
|
+
installed = Path(installed_path).expanduser() if installed_path else Path.home() / ".ssh" / f"id_rsa_{cluster}"
|
|
878
960
|
metadata: dict[str, Any] = {
|
|
879
961
|
"username": username,
|
|
880
962
|
"key_path": str(installed),
|
|
@@ -925,7 +1007,7 @@ def _print_setup_status(config: Mapping[str, Any]) -> None:
|
|
|
925
1007
|
expiry = profile.get("key_expires_at") or "未知"
|
|
926
1008
|
print(
|
|
927
1009
|
f" - {name}: user={profile.get('username') or '未知'}, "
|
|
928
|
-
f"key_expires={expiry}"
|
|
1010
|
+
f"key={profile.get('key_path') or '未安装/未记录'}, key_expires={expiry}"
|
|
929
1011
|
)
|
|
930
1012
|
configured_names = set(clusters) if isinstance(clusters, dict) else set()
|
|
931
1013
|
for item in list_profiles(REPO_ROOT):
|
|
@@ -956,6 +1038,11 @@ def _print_setup_status(config: Mapping[str, Any]) -> None:
|
|
|
956
1038
|
f"OpenAPI:user={openapi.get('platform_user') or '未知'}, "
|
|
957
1039
|
f"provider={openapi.get('credential_provider') or '环境变量'}"
|
|
958
1040
|
)
|
|
1041
|
+
try:
|
|
1042
|
+
active_credentials, active_provider = load_openapi_credentials()
|
|
1043
|
+
print(f" 当前进程实际凭据来源:{active_provider if active_credentials else '未检测到凭据(区域缓存不等于已认证)'}")
|
|
1044
|
+
except CredentialError as exc:
|
|
1045
|
+
print(f" 当前凭据检查失败:{exc}")
|
|
959
1046
|
print(f" 已发现区域:{len(regions)},已启用区域:{len(enabled)}")
|
|
960
1047
|
for region_id in enabled:
|
|
961
1048
|
region = regions.get(str(region_id), {})
|
|
@@ -1117,6 +1204,7 @@ def setup_panel(
|
|
|
1117
1204
|
selected_cluster = cluster
|
|
1118
1205
|
result["cluster"] = cluster
|
|
1119
1206
|
_, profile = load_profile(REPO_ROOT, cluster)
|
|
1207
|
+
_ssh_setup_help(cluster, profile)
|
|
1120
1208
|
existing_profile = existing_clusters.get(cluster, {})
|
|
1121
1209
|
if not isinstance(existing_profile, dict):
|
|
1122
1210
|
existing_profile = {}
|
|
@@ -1133,17 +1221,19 @@ def setup_panel(
|
|
|
1133
1221
|
key_path = ""
|
|
1134
1222
|
if not skip_connect:
|
|
1135
1223
|
detected_key = str(existing_profile.get("key_path") or "")
|
|
1224
|
+
if platform.system() == "Windows" and not detected_connection:
|
|
1225
|
+
raise BackendError("尚未检测到 Windows OpenSSH 连接。请先按 references/setup.md 手工配置,或在 WSL2 运行 SSH 安装向导。")
|
|
1136
1226
|
if action == "modify" and detected_key:
|
|
1137
1227
|
rotate = _ask_yes_no(
|
|
1138
1228
|
"是否轮换为新的 SSH 私钥?", default=False
|
|
1139
1229
|
)
|
|
1140
1230
|
key_path = (
|
|
1141
|
-
str(
|
|
1231
|
+
str(_private_key_path(_ask("新私钥文件路径")))
|
|
1142
1232
|
if rotate
|
|
1143
1233
|
else detected_key
|
|
1144
1234
|
)
|
|
1145
1235
|
else:
|
|
1146
|
-
key_path = str(
|
|
1236
|
+
key_path = str(_private_key_path(_ask("私钥文件路径")))
|
|
1147
1237
|
if not key_path or not Path(key_path).is_file():
|
|
1148
1238
|
raise BackendError(f"找不到私钥文件: {key_path}")
|
|
1149
1239
|
detected_user = _detect_ssh_username(
|
|
@@ -1155,6 +1245,9 @@ def setup_panel(
|
|
|
1155
1245
|
)
|
|
1156
1246
|
if not username:
|
|
1157
1247
|
raise BackendError("无法自动探测 SSH 用户名,请手工输入")
|
|
1248
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", username):
|
|
1249
|
+
raise BackendError("集群用户名包含空白或不支持的字符,请只填写用户名。")
|
|
1250
|
+
print(f"配置摘要:集群={cluster},用户={username},私钥={key_path or '未安装(skip-connect)'}")
|
|
1158
1251
|
clusters = dict(existing_clusters)
|
|
1159
1252
|
clusters[cluster] = {**existing_profile, "username": username}
|
|
1160
1253
|
result["ssh"] = {"clusters": clusters}
|
|
@@ -1174,6 +1267,8 @@ def setup_panel(
|
|
|
1174
1267
|
if not _ask_yes_no(prompt, default=True):
|
|
1175
1268
|
print("已跳过 SSH 写入;可稍后单独运行 setup-ssh.sh。")
|
|
1176
1269
|
elif adopted:
|
|
1270
|
+
if username != detected_connection.get("username"):
|
|
1271
|
+
raise BackendError("接管时输入的用户名与现有 SSH 别名不同;请先修改 ~/.ssh/config,再重新运行 setup。")
|
|
1177
1272
|
completed = subprocess.run(
|
|
1178
1273
|
[
|
|
1179
1274
|
"ssh",
|
|
@@ -1195,7 +1290,7 @@ def setup_panel(
|
|
|
1195
1290
|
)
|
|
1196
1291
|
clusters[cluster] = {
|
|
1197
1292
|
**existing_profile,
|
|
1198
|
-
**_ssh_key_metadata(cluster, key_path, username),
|
|
1293
|
+
**_ssh_key_metadata(cluster, key_path, username, installed_path=key_path),
|
|
1199
1294
|
"adopted": True,
|
|
1200
1295
|
}
|
|
1201
1296
|
else:
|
|
@@ -1231,8 +1326,16 @@ def setup_panel(
|
|
|
1231
1326
|
"官方说明:https://www.scnet.cn/ac/openapi/doc/2.0/"
|
|
1232
1327
|
"api/safecertification/get-user-tokens-aksk.html"
|
|
1233
1328
|
)
|
|
1234
|
-
|
|
1235
|
-
|
|
1329
|
+
_credential_help()
|
|
1330
|
+
print("字段对应:平台用户名 = SCNet 平台用户名;AK = 下载文件的 Accesskey 列;SK = Secretkey 列。")
|
|
1331
|
+
print("无需填写区域 ID 或接口地址,认证后自动发现已授权区域;账号需已开通区域资源。")
|
|
1332
|
+
print("\n请依次复制平台用户名、AccessKey、SecretKey 的值;不需要输入 export 或 $env: 命令。")
|
|
1333
|
+
print("AccessKey 和 SecretKey 都不会显示字符或星号;外层引号会自动去除。\n")
|
|
1334
|
+
try:
|
|
1335
|
+
saved_credentials, saved_provider = load_openapi_credentials()
|
|
1336
|
+
except CredentialError as exc:
|
|
1337
|
+
print(f"现有凭据无法使用:{exc}")
|
|
1338
|
+
saved_credentials, saved_provider = None, None
|
|
1236
1339
|
use_saved = bool(saved_credentials) and _ask_yes_no(
|
|
1237
1340
|
f"检测到 {saved_provider} 中的 OpenAPI 凭据,是否使用?",
|
|
1238
1341
|
default=True,
|
|
@@ -1243,14 +1346,17 @@ def setup_panel(
|
|
|
1243
1346
|
access_key = saved_credentials["access_key"]
|
|
1244
1347
|
secret_key = saved_credentials["secret_key"]
|
|
1245
1348
|
else:
|
|
1246
|
-
api_user =
|
|
1349
|
+
api_user = _ask_credential(
|
|
1247
1350
|
"平台用户名",
|
|
1248
|
-
str(existing_openapi.get("platform_user") or ""),
|
|
1351
|
+
default=str(existing_openapi.get("platform_user") or ""),
|
|
1249
1352
|
)
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1353
|
+
while True:
|
|
1354
|
+
access_key = _ask_credential("AccessKey", secret=True)
|
|
1355
|
+
secret_key = _ask_credential("SecretKey", secret=True)
|
|
1356
|
+
if access_key != secret_key:
|
|
1357
|
+
break
|
|
1358
|
+
print("AccessKey 与 SecretKey 完全相同,可能重复粘贴了同一个字段,请重新输入。")
|
|
1359
|
+
print(f"账号:{api_user};AK/SK 已读取且不回显。下一步只读校验认证并发现授权区域。")
|
|
1254
1360
|
backend_instance, previous = _with_openapi_credentials(
|
|
1255
1361
|
{},
|
|
1256
1362
|
user=api_user,
|
|
@@ -1328,8 +1434,8 @@ def setup_panel(
|
|
|
1328
1434
|
selected = enabled_contexts[selected_index]
|
|
1329
1435
|
finally:
|
|
1330
1436
|
_restore_environment(previous)
|
|
1331
|
-
stored_provider = saved_provider
|
|
1332
|
-
if entered_credentials and secure_store_name():
|
|
1437
|
+
stored_provider = saved_provider if use_saved else None
|
|
1438
|
+
if (entered_credentials or saved_provider == "environment") and secure_store_name():
|
|
1333
1439
|
if _ask_yes_no(
|
|
1334
1440
|
f"是否将 AK/SK 保存到 {secure_store_name()}?",
|
|
1335
1441
|
default=True,
|
|
@@ -1338,10 +1444,22 @@ def setup_panel(
|
|
|
1338
1444
|
provider = store_openapi_credentials(
|
|
1339
1445
|
api_user, access_key, secret_key
|
|
1340
1446
|
)
|
|
1447
|
+
expected = {"user": api_user, "access_key": access_key, "secret_key": secret_key}
|
|
1448
|
+
if load_secure_credentials() != expected:
|
|
1449
|
+
raise CredentialError("保存后读回校验失败;尚不能确认凭据已持久保存。")
|
|
1341
1450
|
stored_provider = provider
|
|
1342
1451
|
print(f"OpenAPI 凭据已保存到 {provider}。")
|
|
1343
1452
|
except CredentialError as exc:
|
|
1344
1453
|
print(f"无法保存到安全凭据库:{exc}")
|
|
1454
|
+
if stored_provider is None:
|
|
1455
|
+
print("凭据未保存:本次认证已完成,但后续命令仍需注入环境变量或重新保存 AK/SK。")
|
|
1456
|
+
_print_environment_help()
|
|
1457
|
+
elif stored_provider == "environment":
|
|
1458
|
+
print("凭据来源:当前进程环境变量;未另存到系统凭据库,重启后需由宿主重新注入。")
|
|
1459
|
+
else:
|
|
1460
|
+
print(f"持久保存已验证:{stored_provider};条目名 scnet-hpc-openapi。")
|
|
1461
|
+
if any(os.environ.get(key) for key in ("SCNET_OPENAPI_USER", "SCNET_OPENAPI_ACCESS_KEY", "SCNET_OPENAPI_SECRET_KEY")):
|
|
1462
|
+
print("注意:当前 shell 仍有环境变量,它们优先于刚保存的凭据;请清除旧变量后运行 doctor。")
|
|
1345
1463
|
selected_schedulers = selected.get("schedulers") or []
|
|
1346
1464
|
default_scheduler = (
|
|
1347
1465
|
selected_schedulers[0].get("id")
|
|
@@ -1350,7 +1468,7 @@ def setup_panel(
|
|
|
1350
1468
|
)
|
|
1351
1469
|
result["openapi"] = {
|
|
1352
1470
|
"platform_user": api_user,
|
|
1353
|
-
"credential_provider": stored_provider,
|
|
1471
|
+
"credential_provider": stored_provider or "not_saved",
|
|
1354
1472
|
"default_region_id": selected["region_id"],
|
|
1355
1473
|
"enabled_region_ids": [
|
|
1356
1474
|
item["region_id"] for item in enabled_contexts
|
|
@@ -1371,7 +1489,7 @@ def setup_panel(
|
|
|
1371
1489
|
},
|
|
1372
1490
|
}
|
|
1373
1491
|
print(
|
|
1374
|
-
"\nHPC OpenAPI
|
|
1492
|
+
"\nHPC OpenAPI 区域选择结果(凭据状态见上方):\n"
|
|
1375
1493
|
f" 默认区域:{selected.get('name') or selected['region_id']}"
|
|
1376
1494
|
f" ({selected['region_id']})\n"
|
|
1377
1495
|
f" 区域用户:{selected.get('username')}\n"
|
|
@@ -1386,11 +1504,10 @@ def setup_panel(
|
|
|
1386
1504
|
path = save_user_config(result)
|
|
1387
1505
|
print(f"\n已保存非敏感配置:{path}")
|
|
1388
1506
|
print("后续可用 `python3 scripts/scnet.py config` 查看。")
|
|
1389
|
-
if configure_openapi and not secure_store_name():
|
|
1390
|
-
print("系统没有可用安全凭据库;请通过环境变量注入 AK/SK。")
|
|
1391
1507
|
doctor_cluster = selected_cluster or str(current.get("cluster") or "")
|
|
1392
1508
|
suffix = f" --cluster {shlex.quote(doctor_cluster)}" if doctor_cluster else ""
|
|
1393
|
-
|
|
1509
|
+
interpreter = "python" if platform.system() == "Windows" else "python3"
|
|
1510
|
+
print(f"下一步:{interpreter} scripts/scnet.py --backend {default_backend}{suffix} doctor")
|
|
1394
1511
|
return result
|
|
1395
1512
|
|
|
1396
1513
|
|
|
@@ -1411,12 +1528,19 @@ def doctor_report(
|
|
|
1411
1528
|
try:
|
|
1412
1529
|
path = config_path()
|
|
1413
1530
|
if path.is_file():
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1531
|
+
if os.name == "posix":
|
|
1532
|
+
mode = path.stat().st_mode & 0o777
|
|
1533
|
+
add(
|
|
1534
|
+
"user_config",
|
|
1535
|
+
mode & 0o077 == 0,
|
|
1536
|
+
f"{path} mode={mode:03o}",
|
|
1537
|
+
)
|
|
1538
|
+
else:
|
|
1539
|
+
add(
|
|
1540
|
+
"user_config",
|
|
1541
|
+
True,
|
|
1542
|
+
f"{path}(非 POSIX,跳过 mode 检查;未验证 ACL)",
|
|
1543
|
+
)
|
|
1420
1544
|
else:
|
|
1421
1545
|
add("user_config", False, f"{path} 不存在;先运行 setup")
|
|
1422
1546
|
except OSError as exc:
|
|
@@ -1446,18 +1570,29 @@ def doctor_report(
|
|
|
1446
1570
|
if alias and shutil.which("ssh"):
|
|
1447
1571
|
completed = subprocess.run(
|
|
1448
1572
|
["ssh", "-G", alias],
|
|
1449
|
-
stdout=subprocess.
|
|
1573
|
+
stdout=subprocess.PIPE,
|
|
1450
1574
|
stderr=subprocess.PIPE,
|
|
1451
1575
|
text=True,
|
|
1452
1576
|
check=False,
|
|
1453
1577
|
)
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1578
|
+
resolved = {}
|
|
1579
|
+
for line in completed.stdout.splitlines():
|
|
1580
|
+
field, _, value = line.partition(" ")
|
|
1581
|
+
if field in {"hostname", "port", "user", "identityfile"}:
|
|
1582
|
+
resolved.setdefault(field, value)
|
|
1583
|
+
config_ok = (
|
|
1584
|
+
completed.returncode == 0
|
|
1585
|
+
and resolved.get("hostname") == profile.get("SSH_HOST")
|
|
1586
|
+
and resolved.get("port") == str(profile.get("SSH_PORT") or "22")
|
|
1460
1587
|
)
|
|
1588
|
+
detail = (
|
|
1589
|
+
f"alias={alias}, host={resolved.get('hostname', '未知')}, "
|
|
1590
|
+
f"port={resolved.get('port', '未知')}, user={resolved.get('user', '未知')}, "
|
|
1591
|
+
f"key={resolved.get('identityfile', '未配置')}"
|
|
1592
|
+
)
|
|
1593
|
+
if not config_ok:
|
|
1594
|
+
detail += "; 别名未解析到 profile 的主机/端口,请检查当前环境的 ~/.ssh/config"
|
|
1595
|
+
add("ssh_config", config_ok, detail)
|
|
1461
1596
|
if not no_network:
|
|
1462
1597
|
completed = subprocess.run(
|
|
1463
1598
|
[
|
|
@@ -1523,7 +1658,11 @@ def doctor_report(
|
|
|
1523
1658
|
|
|
1524
1659
|
elif backend_name == "openapi":
|
|
1525
1660
|
has_token = bool(os.environ.get("SCNET_OPENAPI_TOKEN"))
|
|
1526
|
-
|
|
1661
|
+
try:
|
|
1662
|
+
credentials, credential_provider = load_openapi_credentials()
|
|
1663
|
+
except CredentialError as exc:
|
|
1664
|
+
credentials, credential_provider = None, None
|
|
1665
|
+
add("credential_store", False, str(exc))
|
|
1527
1666
|
has_aksk = bool(credentials)
|
|
1528
1667
|
add(
|
|
1529
1668
|
"openapi_credentials",
|
|
@@ -1533,7 +1672,7 @@ def doctor_report(
|
|
|
1533
1672
|
else (
|
|
1534
1673
|
f"检测到 AK/SK({credential_provider})"
|
|
1535
1674
|
if has_aksk
|
|
1536
|
-
else "
|
|
1675
|
+
else "未检测到可用凭据;运行 setup new/modify --mode openapi,或同时注入三项环境变量"
|
|
1537
1676
|
),
|
|
1538
1677
|
)
|
|
1539
1678
|
openapi_config = user_config.get("openapi", {})
|
|
@@ -1743,7 +1882,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1743
1882
|
if args.operation == "doctor" and not data.get("ok", False):
|
|
1744
1883
|
return 1
|
|
1745
1884
|
return 0
|
|
1746
|
-
except
|
|
1885
|
+
except (KeyboardInterrupt, EOFError):
|
|
1886
|
+
print("\n已取消输入;未完成的配置请重新运行 setup。", file=sys.stderr)
|
|
1887
|
+
return 130
|
|
1888
|
+
except (BackendError, CredentialError) as exc:
|
|
1747
1889
|
if args.json:
|
|
1748
1890
|
print(
|
|
1749
1891
|
json.dumps(
|
|
@@ -89,8 +89,9 @@ def save_user_config(data: dict[str, Any]) -> Path:
|
|
|
89
89
|
prefix=".config.", suffix=".tmp", dir=str(path.parent)
|
|
90
90
|
)
|
|
91
91
|
try:
|
|
92
|
-
os.fchmod(fd, 0o600)
|
|
93
92
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
93
|
+
if os.name == "posix":
|
|
94
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
94
95
|
json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
|
95
96
|
stream.write("\n")
|
|
96
97
|
os.replace(temporary, path)
|
|
@@ -110,8 +111,9 @@ def _save_json_sidecar(path: Path, data: dict[str, Any]) -> Path:
|
|
|
110
111
|
prefix=f".{path.stem}.", suffix=".tmp", dir=str(path.parent)
|
|
111
112
|
)
|
|
112
113
|
try:
|
|
113
|
-
os.fchmod(fd, 0o600)
|
|
114
114
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
115
|
+
if os.name == "posix":
|
|
116
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
115
117
|
json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
|
116
118
|
stream.write("\n")
|
|
117
119
|
os.replace(temporary, path)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import platform
|
|
8
|
+
import re
|
|
8
9
|
import shutil
|
|
9
10
|
import subprocess
|
|
10
11
|
from typing import Any
|
|
@@ -17,6 +18,90 @@ class CredentialError(RuntimeError):
|
|
|
17
18
|
"""A user-facing credential-store failure."""
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def _credential_command(command: list[str], **options: Any) -> subprocess.CompletedProcess:
|
|
22
|
+
try:
|
|
23
|
+
return subprocess.run(command, timeout=30, **options)
|
|
24
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
25
|
+
# TimeoutExpired includes argv, which contains the Keychain payload.
|
|
26
|
+
raise CredentialError("系统凭据操作失败或超时;请检查密钥环服务及解锁状态后重试。") from None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_credential(value: str, label: str) -> str:
|
|
30
|
+
"""Accept surrounding paste quotes, but never echo a credential in an error."""
|
|
31
|
+
value = value.strip().lstrip("\ufeff")
|
|
32
|
+
if len(value) >= 2 and (value[0], value[-1]) in {
|
|
33
|
+
('"', '"'), ("'", "'"), ("“", "”"), ("‘", "’")
|
|
34
|
+
}:
|
|
35
|
+
value = value[1:-1].strip()
|
|
36
|
+
if not value:
|
|
37
|
+
raise CredentialError(f"{label} 不能为空,请只粘贴对应字段的值。")
|
|
38
|
+
if re.match(r"(?i)^(export\s|\$env:|SCNET_OPENAPI_|(?:AccessKey|SecretKey)\s*[:=])", value):
|
|
39
|
+
raise CredentialError(f"{label} 收到了变量赋值或字段名;请只粘贴值,不要粘贴整行命令。")
|
|
40
|
+
if any(character.isspace() or ord(character) < 32 or character in "\u200b\ufeff\u200e\u200f" for character in value):
|
|
41
|
+
raise CredentialError(f"{label} 含空白、换行或不可见字符,请重新复制单个字段。")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _windows_credential(action: str, payload: str = "", user: str = "") -> str | bool | None:
|
|
46
|
+
"""Use the current Windows user's Generic Credential store, without extra packages."""
|
|
47
|
+
import ctypes
|
|
48
|
+
from ctypes import wintypes
|
|
49
|
+
|
|
50
|
+
class Credential(ctypes.Structure):
|
|
51
|
+
_fields_ = [
|
|
52
|
+
("Flags", wintypes.DWORD), ("Type", wintypes.DWORD),
|
|
53
|
+
("TargetName", wintypes.LPWSTR), ("Comment", wintypes.LPWSTR),
|
|
54
|
+
("LastWritten", wintypes.FILETIME), ("CredentialBlobSize", wintypes.DWORD),
|
|
55
|
+
("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
|
|
56
|
+
("Persist", wintypes.DWORD), ("AttributeCount", wintypes.DWORD),
|
|
57
|
+
("Attributes", ctypes.c_void_p), ("TargetAlias", wintypes.LPWSTR),
|
|
58
|
+
("UserName", wintypes.LPWSTR),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
api = ctypes.WinDLL("Advapi32.dll", use_last_error=True)
|
|
62
|
+
pointer = ctypes.POINTER(Credential)
|
|
63
|
+
api.CredReadW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(pointer)]
|
|
64
|
+
api.CredReadW.restype = wintypes.BOOL
|
|
65
|
+
api.CredWriteW.argtypes = [pointer, wintypes.DWORD]
|
|
66
|
+
api.CredWriteW.restype = wintypes.BOOL
|
|
67
|
+
api.CredDeleteW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD]
|
|
68
|
+
api.CredDeleteW.restype = wintypes.BOOL
|
|
69
|
+
api.CredFree.argtypes = [ctypes.c_void_p]
|
|
70
|
+
api.CredFree.restype = None
|
|
71
|
+
|
|
72
|
+
if action == "read":
|
|
73
|
+
item = pointer()
|
|
74
|
+
success = api.CredReadW(SERVICE, 1, 0, ctypes.byref(item))
|
|
75
|
+
if success:
|
|
76
|
+
try:
|
|
77
|
+
return ctypes.string_at(item.contents.CredentialBlob, item.contents.CredentialBlobSize).decode("utf-8")
|
|
78
|
+
finally:
|
|
79
|
+
api.CredFree(item)
|
|
80
|
+
elif action == "write":
|
|
81
|
+
encoded = payload.encode("utf-8")
|
|
82
|
+
if len(encoded) > 2560:
|
|
83
|
+
raise CredentialError("凭据超出 Windows 凭据管理器容量,请检查是否粘贴了整个文件。")
|
|
84
|
+
buffer = (ctypes.c_ubyte * len(encoded)).from_buffer_copy(encoded)
|
|
85
|
+
item = Credential()
|
|
86
|
+
item.Type, item.TargetName, item.UserName = 1, SERVICE, user
|
|
87
|
+
item.CredentialBlobSize, item.CredentialBlob = len(encoded), buffer
|
|
88
|
+
item.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE: same user, across logon sessions.
|
|
89
|
+
success = api.CredWriteW(ctypes.byref(item), 0)
|
|
90
|
+
elif action == "delete":
|
|
91
|
+
success = api.CredDeleteW(SERVICE, 1, 0)
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError("unknown credential operation")
|
|
94
|
+
if success:
|
|
95
|
+
return True
|
|
96
|
+
error = ctypes.get_last_error()
|
|
97
|
+
if error == 1168 and action in {"read", "delete"}:
|
|
98
|
+
return None
|
|
99
|
+
raise CredentialError(
|
|
100
|
+
f"Windows 凭据管理器{action}失败(错误码 {error});"
|
|
101
|
+
"请在已登录的桌面用户会话运行,或使用环境变量注入。"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
20
105
|
def _valid(data: Any) -> bool:
|
|
21
106
|
return isinstance(data, dict) and all(
|
|
22
107
|
isinstance(data.get(key), str) and data[key]
|
|
@@ -30,10 +115,17 @@ def environment_credentials() -> dict[str, str] | None:
|
|
|
30
115
|
"access_key": os.environ.get("SCNET_OPENAPI_ACCESS_KEY", ""),
|
|
31
116
|
"secret_key": os.environ.get("SCNET_OPENAPI_SECRET_KEY", ""),
|
|
32
117
|
}
|
|
33
|
-
|
|
118
|
+
if not any(data.values()):
|
|
119
|
+
return None
|
|
120
|
+
missing = [name for name, value in data.items() if not value]
|
|
121
|
+
if missing:
|
|
122
|
+
raise CredentialError("OpenAPI 环境变量不完整;请同时设置 USER、ACCESS_KEY、SECRET_KEY,或清除这组三个变量后使用系统凭据库。")
|
|
123
|
+
return {key: normalize_credential(value, key) for key, value in data.items()}
|
|
34
124
|
|
|
35
125
|
|
|
36
126
|
def secure_store_name() -> str | None:
|
|
127
|
+
if platform.system() == "Windows":
|
|
128
|
+
return "Windows Credential Manager"
|
|
37
129
|
if platform.system() == "Darwin" and shutil.which("security"):
|
|
38
130
|
return "macOS Keychain"
|
|
39
131
|
if shutil.which("secret-tool"):
|
|
@@ -43,19 +135,28 @@ def secure_store_name() -> str | None:
|
|
|
43
135
|
|
|
44
136
|
def load_secure_credentials() -> dict[str, str] | None:
|
|
45
137
|
provider = secure_store_name()
|
|
138
|
+
if provider == "Windows Credential Manager":
|
|
139
|
+
payload = _windows_credential("read")
|
|
140
|
+
if payload is None:
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(payload)
|
|
144
|
+
except (ValueError, TypeError):
|
|
145
|
+
raise CredentialError("Windows 中已保存的 OpenAPI 凭据格式无效,请运行 setup modify --mode openapi 重新配置。") from None
|
|
146
|
+
return data if _valid(data) else None
|
|
46
147
|
if provider == "macOS Keychain":
|
|
47
148
|
command = ["security", "find-generic-password", "-s", SERVICE, "-w"]
|
|
48
149
|
elif provider == "Secret Service":
|
|
49
150
|
command = ["secret-tool", "lookup", "service", SERVICE]
|
|
50
151
|
else:
|
|
51
152
|
return None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
153
|
+
try:
|
|
154
|
+
completed = subprocess.run(
|
|
155
|
+
command, text=True, stdout=subprocess.PIPE,
|
|
156
|
+
stderr=subprocess.DEVNULL, check=False, timeout=30,
|
|
157
|
+
)
|
|
158
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
159
|
+
raise CredentialError(f"无法读取 {provider};请检查密钥环服务是否运行并已解锁。") from None
|
|
59
160
|
if completed.returncode != 0 or not completed.stdout.strip():
|
|
60
161
|
return None
|
|
61
162
|
try:
|
|
@@ -79,14 +180,17 @@ def store_openapi_credentials(
|
|
|
79
180
|
user: str, access_key: str, secret_key: str
|
|
80
181
|
) -> str:
|
|
81
182
|
data = {
|
|
82
|
-
"user": user,
|
|
83
|
-
"access_key": access_key,
|
|
84
|
-
"secret_key": secret_key,
|
|
183
|
+
"user": normalize_credential(user, "平台用户名"),
|
|
184
|
+
"access_key": normalize_credential(access_key, "AccessKey"),
|
|
185
|
+
"secret_key": normalize_credential(secret_key, "SecretKey"),
|
|
85
186
|
}
|
|
86
187
|
if not _valid(data):
|
|
87
188
|
raise CredentialError("OpenAPI credentials are incomplete")
|
|
88
189
|
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
89
190
|
provider = secure_store_name()
|
|
191
|
+
if provider == "Windows Credential Manager":
|
|
192
|
+
_windows_credential("write", payload, data["user"])
|
|
193
|
+
return provider
|
|
90
194
|
if provider == "macOS Keychain":
|
|
91
195
|
command = [
|
|
92
196
|
"security",
|
|
@@ -99,7 +203,7 @@ def store_openapi_credentials(
|
|
|
99
203
|
"-w",
|
|
100
204
|
payload,
|
|
101
205
|
]
|
|
102
|
-
completed =
|
|
206
|
+
completed = _credential_command(
|
|
103
207
|
command,
|
|
104
208
|
stdout=subprocess.DEVNULL,
|
|
105
209
|
stderr=subprocess.PIPE,
|
|
@@ -116,7 +220,7 @@ def store_openapi_credentials(
|
|
|
116
220
|
"user",
|
|
117
221
|
user,
|
|
118
222
|
]
|
|
119
|
-
completed =
|
|
223
|
+
completed = _credential_command(
|
|
120
224
|
command,
|
|
121
225
|
input=payload,
|
|
122
226
|
stdout=subprocess.DEVNULL,
|
|
@@ -131,20 +235,22 @@ def store_openapi_credentials(
|
|
|
131
235
|
)
|
|
132
236
|
if completed.returncode != 0:
|
|
133
237
|
raise CredentialError(
|
|
134
|
-
|
|
238
|
+
f"无法写入 {provider}(exit={completed.returncode});请检查密钥环服务及解锁状态。"
|
|
135
239
|
)
|
|
136
240
|
return str(provider)
|
|
137
241
|
|
|
138
242
|
|
|
139
243
|
def delete_openapi_credentials() -> tuple[bool, str | None]:
|
|
140
244
|
provider = secure_store_name()
|
|
245
|
+
if provider == "Windows Credential Manager":
|
|
246
|
+
return bool(_windows_credential("delete")), provider
|
|
141
247
|
if provider == "macOS Keychain":
|
|
142
248
|
command = ["security", "delete-generic-password", "-s", SERVICE]
|
|
143
249
|
elif provider == "Secret Service":
|
|
144
250
|
command = ["secret-tool", "clear", "service", SERVICE]
|
|
145
251
|
else:
|
|
146
252
|
return False, None
|
|
147
|
-
completed =
|
|
253
|
+
completed = _credential_command(
|
|
148
254
|
command,
|
|
149
255
|
stdout=subprocess.DEVNULL,
|
|
150
256
|
stderr=subprocess.PIPE,
|
|
@@ -156,5 +262,5 @@ def delete_openapi_credentials() -> tuple[bool, str | None]:
|
|
|
156
262
|
if load_secure_credentials() is None:
|
|
157
263
|
return False, provider
|
|
158
264
|
raise CredentialError(
|
|
159
|
-
|
|
265
|
+
f"无法清除 {provider}(exit={completed.returncode}),请检查解锁状态。"
|
|
160
266
|
)
|
|
@@ -14,7 +14,7 @@ from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
|
14
14
|
from urllib.request import Request, urlopen
|
|
15
15
|
|
|
16
16
|
from scnet_backends.base import BackendContext, BackendError
|
|
17
|
-
from scnet_credentials import load_openapi_credentials
|
|
17
|
+
from scnet_credentials import CredentialError, load_openapi_credentials
|
|
18
18
|
from scnet_version import VERSION
|
|
19
19
|
|
|
20
20
|
|
|
@@ -215,16 +215,13 @@ class SCNetClient:
|
|
|
215
215
|
]
|
|
216
216
|
return self._regions_cache
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
secret_key = self.env("SCNET_OPENAPI_SECRET_KEY") or (
|
|
226
|
-
credentials.get("secret_key") if credentials else None
|
|
227
|
-
)
|
|
218
|
+
try:
|
|
219
|
+
credentials, _ = load_openapi_credentials()
|
|
220
|
+
except CredentialError as exc:
|
|
221
|
+
raise BackendError(str(exc)) from None
|
|
222
|
+
user = credentials.get("user") if credentials else None
|
|
223
|
+
access_key = credentials.get("access_key") if credentials else None
|
|
224
|
+
secret_key = credentials.get("secret_key") if credentials else None
|
|
228
225
|
missing = [
|
|
229
226
|
name
|
|
230
227
|
for name, value in (
|
|
@@ -48,6 +48,7 @@ if [ -z "$USER_NAME" ] && [ -n "${KEY_NAME_MARKER:-}" ]; then
|
|
|
48
48
|
esac
|
|
49
49
|
fi
|
|
50
50
|
[ -n "$USER_NAME" ] || die "无法从文件名推断用户名,请作为最后一个参数传入"
|
|
51
|
+
[[ "$USER_NAME" =~ ^[A-Za-z0-9._-]+$ ]] || die "远端用户名包含空白或不安全字符"
|
|
51
52
|
|
|
52
53
|
echo "==> 集群: ${CLUSTER_ID} (${CLUSTER_DESC:-$SSH_HOST})"
|
|
53
54
|
echo "==> 用户名: ${USER_NAME}"
|
|
@@ -75,12 +76,12 @@ if [ "$(uname -s)" = "Darwin" ]; then
|
|
|
75
76
|
fi
|
|
76
77
|
info "$KEY_DST (600)"
|
|
77
78
|
|
|
78
|
-
if ssh-keygen -y -f "$KEY_DST" > "$KEY_DST.pub" 2>/dev/null; then
|
|
79
|
+
if ssh-keygen -y -P '' -f "$KEY_DST" > "$KEY_DST.pub" 2>/dev/null; then
|
|
79
80
|
chmod 644 "$KEY_DST.pub"
|
|
80
81
|
info "$(ssh-keygen -lf "$KEY_DST" 2>/dev/null | awk '{print $1, $2, $4}')"
|
|
81
82
|
else
|
|
82
83
|
rm -f "$KEY_DST.pub"
|
|
83
|
-
info "
|
|
84
|
+
info "无法用空口令解析私钥:如有口令,请先运行 ssh-add;如未设置口令,请检查文件是否损坏。"
|
|
84
85
|
fi
|
|
85
86
|
|
|
86
87
|
# ---------------------------------------------------------------- config
|
|
@@ -166,6 +167,9 @@ EOF
|
|
|
166
167
|
fi
|
|
167
168
|
|
|
168
169
|
# ---------------------------------------------------------------- 验证
|
|
170
|
+
echo "==> 实际生效的 SSH 参数(来自 ssh -G;不显示私钥内容)"
|
|
171
|
+
ssh -G "$CLUSTER_ID" 2>/dev/null \
|
|
172
|
+
| awk '$1 == "hostname" || $1 == "user" || $1 == "port" || $1 == "identityfile" { print " " $0 }'
|
|
169
173
|
echo "==> 测试连接"
|
|
170
174
|
if out=$(ssh -o BatchMode=yes -o ConnectTimeout=20 \
|
|
171
175
|
-o StrictHostKeyChecking=accept-new \
|
|
@@ -365,6 +365,11 @@ case "$(uname -s)" in
|
|
|
365
365
|
printf '%s\n' "Linux:使用 ↑/↓ 和 Enter,也可输入编号;Ctrl+C 可取消。" >&2
|
|
366
366
|
fi
|
|
367
367
|
;;
|
|
368
|
+
MINGW*|MSYS*|CYGWIN*)
|
|
369
|
+
printf '%s\n' \
|
|
370
|
+
"Windows:OpenAPI 可在 PowerShell 中运行 python scripts/scnet.py setup,并使用系统凭据管理器。" \
|
|
371
|
+
"SSH 自动安装请在 WSL2 内运行;WSL2 与 Windows OpenSSH 的私钥目录互相独立。" >&2
|
|
372
|
+
;;
|
|
368
373
|
esac
|
|
369
374
|
printf '\n' >&2
|
|
370
375
|
|
|
@@ -478,8 +483,16 @@ if [ "$BACKEND" = both ]; then
|
|
|
478
483
|
fi
|
|
479
484
|
|
|
480
485
|
if [ "$BACKEND" = ssh ] || [ "$BACKEND" = both ]; then
|
|
486
|
+
printf '%s\n' \
|
|
487
|
+
"SSH 使用从目标集群控制台下载的私钥文件,不是 AK/SK 授权码,也不是 .pub 公钥。" \
|
|
488
|
+
"用户名应为集群/Linux 用户名;输入私钥文件路径,不要粘贴文件正文。" \
|
|
489
|
+
"将安装到 $HOME/.ssh/id_rsa_${CLUSTER_ID},配置 $HOME/.ssh/config;不同旧私钥会备份。" \
|
|
490
|
+
"已有非本工具管理的 Host 块会保留;口令私钥请先 ssh-add,过期密钥需重新下载。" >&2
|
|
481
491
|
if [ "$SKIP_CONNECT" = no ]; then
|
|
482
492
|
SSH_KEY="${SSH_KEY:-$(ask "私钥文件路径")}"
|
|
493
|
+
case "$SSH_KEY" in
|
|
494
|
+
\"*\"|\'*\') SSH_KEY="${SSH_KEY:1:${#SSH_KEY}-2}" ;;
|
|
495
|
+
esac
|
|
483
496
|
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
|
484
497
|
[ -f "$SSH_KEY" ] || die "找不到私钥文件: $SSH_KEY"
|
|
485
498
|
fi
|