@milian/linker-cli 0.0.0-stage → 0.17.3
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 +117 -2
- package/bin/linker-cli.js +24 -0
- package/package.json +19 -4
- package/src/api.js +154 -0
- package/src/cli.js +222 -0
- package/src/commands/api-create.js +478 -0
- package/src/commands/api-list.js +247 -0
- package/src/commands/api-publish.js +288 -0
- package/src/commands/auth.js +242 -0
- package/src/commands/groups.js +109 -0
- package/src/commands/mcp.js +485 -0
- package/src/commands/platform-create.js +279 -0
- package/src/commands/platforms.js +206 -0
- package/src/commands/request.js +55 -0
- package/src/commands/session.js +119 -0
- package/src/config.js +227 -0
- package/src/crypto.js +175 -0
- package/src/login-page.js +175 -0
- package/src/schema.js +582 -0
- package/src/ui.js +57 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
// 允许用环境变量把配置/凭证放到别处(多环境切换、测试隔离时有用)
|
|
8
|
+
const HOME_DIR = process.env.LINKER_CONFIG_DIR
|
|
9
|
+
? path.resolve(process.env.LINKER_CONFIG_DIR)
|
|
10
|
+
: path.join(os.homedir(), '.linker-cli');
|
|
11
|
+
const CONFIG_PATH = path.join(HOME_DIR, 'config.json');
|
|
12
|
+
const CRED_PATH = path.join(HOME_DIR, 'credentials.json');
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
// 平台地址。**不设默认值**:首次登录时由用户在登录页填写,成功后写回此配置。
|
|
16
|
+
// 需要非交互场景(CI/脚本)时用 LINKER_BASE_URL 或 `config set baseUrl <地址>`。
|
|
17
|
+
baseUrl: '',
|
|
18
|
+
// 网关上下文前缀。平台所有接口都在 /index 之下,缺了它会落到前端页面(HTTP 200 但内容是 HTML)
|
|
19
|
+
apiPrefix: '/index',
|
|
20
|
+
loginPath: '/index/sys/api/v1/auth/login',
|
|
21
|
+
|
|
22
|
+
// 加密方式: rsa | aes | plain | cmd
|
|
23
|
+
cipher: 'rsa',
|
|
24
|
+
// rsa 填充: pkcs1 | oaep(前端 JSEncrypt 默认 PKCS#1 v1.5)
|
|
25
|
+
rsaPadding: 'pkcs1',
|
|
26
|
+
// RSA 公钥:支持 PEM 或裸 Base64(SPKI DER)。此为平台前端硬编码的公开公钥,非敏感信息。
|
|
27
|
+
publicKey:
|
|
28
|
+
'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCO/dd9T3CBFuHphAnPnAF6H4l24txvh20R1hdeMQWLLnOQj/owVFbvmYK2AMn29w1+4QhWgQkX76+uuOg9BSQ+ui9wgZIRAIleqJaUA2oept4KI2fVOHs7tjVIRfWnNJ0HvBxYbp+EPw2D0/QfxcCr15MqqCqf4mk4XMAlVvemIwIDAQAB',
|
|
29
|
+
// aes 参数(cipher=aes 时)
|
|
30
|
+
aesMode: 'cbc',
|
|
31
|
+
aesKey: '',
|
|
32
|
+
aesIv: '',
|
|
33
|
+
aesKeyEncoding: 'base64',
|
|
34
|
+
aesIvEncoding: 'base64',
|
|
35
|
+
// 外部命令加密(cipher=cmd 时),通过 stdin 收到 JSON,stdout 输出 encodeStr
|
|
36
|
+
cmdCommand: '',
|
|
37
|
+
|
|
38
|
+
// 明文格式: json(默认,对齐前端 JSON.stringify) | template(用下面的模板)
|
|
39
|
+
plainFormat: 'json',
|
|
40
|
+
rememberMe: true,
|
|
41
|
+
// template 模式下的拼接模板,支持 {username} {password}
|
|
42
|
+
encodeTemplate: '{username}:{password}',
|
|
43
|
+
|
|
44
|
+
tokenHeader: 'Authorization',
|
|
45
|
+
tokenPrefix: '',
|
|
46
|
+
requestTimeoutMs: 15000,
|
|
47
|
+
// 本地登录页总等待时长,需小于 WorkBuddy 的 5 分钟
|
|
48
|
+
authTimeoutMs: 240000
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function ensureHomeDir() {
|
|
52
|
+
if (!fs.existsSync(HOME_DIR)) {
|
|
53
|
+
fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readJson(file, fallback) {
|
|
58
|
+
try {
|
|
59
|
+
if (!fs.existsSync(file)) return fallback;
|
|
60
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
61
|
+
if (!raw.trim()) return fallback;
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`无法解析配置文件 ${file}: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 环境变量 -> 配置项。优先级:环境变量 > 用户配置 > 默认值
|
|
69
|
+
const ENV_MAP = {
|
|
70
|
+
LINKER_BASE_URL: 'baseUrl',
|
|
71
|
+
LINKER_API_PREFIX: 'apiPrefix',
|
|
72
|
+
LINKER_LOGIN_PATH: 'loginPath',
|
|
73
|
+
LINKER_TOKEN_HEADER: 'tokenHeader',
|
|
74
|
+
LINKER_TOKEN_PREFIX: 'tokenPrefix',
|
|
75
|
+
LINKER_PUBLIC_KEY: 'publicKey'
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function applyEnv(cfg) {
|
|
79
|
+
const hit = [];
|
|
80
|
+
for (const [envKey, cfgKey] of Object.entries(ENV_MAP)) {
|
|
81
|
+
const v = process.env[envKey];
|
|
82
|
+
if (v !== undefined && v !== '') {
|
|
83
|
+
cfg[cfgKey] = v;
|
|
84
|
+
hit.push(`${envKey}=${cfgKey === 'publicKey' ? '(已设置)' : v}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
Object.defineProperty(cfg, '_envApplied', { value: hit, enumerable: false, writable: true });
|
|
88
|
+
return cfg;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadConfig() {
|
|
92
|
+
const userCfg = readJson(CONFIG_PATH, {});
|
|
93
|
+
const cfg = Object.assign({}, DEFAULTS, userCfg);
|
|
94
|
+
applyEnv(cfg);
|
|
95
|
+
|
|
96
|
+
// 向后兼容:旧版本内置了默认地址,用户可能只登录过、没写过 config.json。
|
|
97
|
+
// 这类机器的凭证里记录了签发地址,这里回填一次并落盘,避免升级后被迫重新登录。
|
|
98
|
+
// 注意只在「配置里根本没有 baseUrl 这个键」时回填 —— 若用户显式写了 baseUrl:"",
|
|
99
|
+
// 说明是他主动清空的,不能用凭证偷偷补回来。
|
|
100
|
+
const explicitBaseUrl = Object.prototype.hasOwnProperty.call(userCfg, 'baseUrl');
|
|
101
|
+
if (!cfg.baseUrl && !explicitBaseUrl && !isBaseUrlFromEnv(cfg)) {
|
|
102
|
+
const cred = loadCredentials();
|
|
103
|
+
if (cred && cred.baseUrl) {
|
|
104
|
+
cfg.baseUrl = cred.baseUrl;
|
|
105
|
+
try {
|
|
106
|
+
saveConfig({ baseUrl: cred.baseUrl });
|
|
107
|
+
} catch {
|
|
108
|
+
/* 只读环境或权限不足时忽略,本次运行仍用内存值 */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return cfg;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveConfig(patch) {
|
|
117
|
+
ensureHomeDir();
|
|
118
|
+
const current = readJson(CONFIG_PATH, {});
|
|
119
|
+
const next = Object.assign({}, current, patch);
|
|
120
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
121
|
+
return next;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function loadCredentials() {
|
|
125
|
+
try {
|
|
126
|
+
return readJson(CRED_PATH, null);
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function saveCredentials(cred) {
|
|
133
|
+
ensureHomeDir();
|
|
134
|
+
const payload = Object.assign({}, cred, { savedAt: new Date().toISOString() });
|
|
135
|
+
fs.writeFileSync(CRED_PATH, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 });
|
|
136
|
+
try {
|
|
137
|
+
fs.chmodSync(CRED_PATH, 0o600);
|
|
138
|
+
} catch {
|
|
139
|
+
/* Windows 上 chmod 无效,忽略 */
|
|
140
|
+
}
|
|
141
|
+
return payload;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function clearCredentials() {
|
|
145
|
+
try {
|
|
146
|
+
if (fs.existsSync(CRED_PATH)) fs.unlinkSync(CRED_PATH);
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 凭证是否仍对当前 baseUrl 有效。
|
|
155
|
+
* 平台地址一换,旧 token 就作废,必须重新登录——否则会拿到 401 却不知道为什么。
|
|
156
|
+
*/
|
|
157
|
+
function credMatchesBaseUrl(cred, cfg) {
|
|
158
|
+
if (!cred || !cred.tokenValue) return false;
|
|
159
|
+
if (!cred.baseUrl) return true; // 老版本凭证没记地址,不阻断
|
|
160
|
+
return String(cred.baseUrl).replace(/\/+$/, '') === String(cfg.baseUrl || '').replace(/\/+$/, '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 规范化用户输入的平台地址。
|
|
165
|
+
*
|
|
166
|
+
* 用户可能填的形态(都应当能用):
|
|
167
|
+
* http://10.0.0.10:8082
|
|
168
|
+
* 10.0.0.10:8082 → 补 http://
|
|
169
|
+
* http://10.0.0.10:8082/ → 去尾斜杠
|
|
170
|
+
* http://10.0.0.10:8082/index/sys/... → 剥掉网关前缀与后续路径
|
|
171
|
+
* https://platform.example.com → 保留 https
|
|
172
|
+
*
|
|
173
|
+
* baseUrl 只承载「协议 + 主机 + 端口 + 可选子路径」,网关前缀由 apiPrefix 单独表达,
|
|
174
|
+
* 因此这里要把用户从浏览器地址栏复制来的 /index/... 尾巴剥掉,否则会拼出双前缀。
|
|
175
|
+
*
|
|
176
|
+
* @returns {{ok: boolean, value: string, reason: string}}
|
|
177
|
+
* reason: '' | 'empty' | 'invalid' | 'protocol'
|
|
178
|
+
*/
|
|
179
|
+
function normalizeBaseUrl(raw) {
|
|
180
|
+
let s = String(raw == null ? '' : raw).trim();
|
|
181
|
+
if (!s) return { ok: false, value: '', reason: 'empty' };
|
|
182
|
+
|
|
183
|
+
// 未写协议时默认 http(内网平台绝大多数是 http)。
|
|
184
|
+
// 注意:不能先去掉末尾斜杠再补协议,否则 "http://" 会被削成 "http:" 再被补成 "http://http:"。
|
|
185
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = 'http://' + s;
|
|
186
|
+
|
|
187
|
+
let u;
|
|
188
|
+
try {
|
|
189
|
+
u = new URL(s);
|
|
190
|
+
} catch {
|
|
191
|
+
return { ok: false, value: '', reason: 'invalid' };
|
|
192
|
+
}
|
|
193
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
194
|
+
return { ok: false, value: '', reason: 'protocol' };
|
|
195
|
+
}
|
|
196
|
+
if (!u.hostname) return { ok: false, value: '', reason: 'invalid' };
|
|
197
|
+
|
|
198
|
+
// 剥掉尾部斜杠与 /index 及其后续(用户从登录页地址栏复制很容易带上)
|
|
199
|
+
let pathname = u.pathname.replace(/\/+$/, '');
|
|
200
|
+
pathname = pathname.replace(/\/index(\/.*)?$/i, '');
|
|
201
|
+
pathname = pathname.replace(/\/+$/, '');
|
|
202
|
+
|
|
203
|
+
// query / hash 不属于 baseUrl,丢弃
|
|
204
|
+
return { ok: true, value: u.origin + pathname, reason: '' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** baseUrl 是否被环境变量锁定(锁定时登录页不允许改,因为改了下次也不生效) */
|
|
208
|
+
function isBaseUrlFromEnv(cfg) {
|
|
209
|
+
const hit = (cfg && cfg._envApplied) || [];
|
|
210
|
+
return hit.some((item) => String(item).startsWith('LINKER_BASE_URL='));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
HOME_DIR,
|
|
215
|
+
CONFIG_PATH,
|
|
216
|
+
CRED_PATH,
|
|
217
|
+
DEFAULTS,
|
|
218
|
+
ENV_MAP,
|
|
219
|
+
loadConfig,
|
|
220
|
+
saveConfig,
|
|
221
|
+
loadCredentials,
|
|
222
|
+
saveCredentials,
|
|
223
|
+
clearCredentials,
|
|
224
|
+
credMatchesBaseUrl,
|
|
225
|
+
normalizeBaseUrl,
|
|
226
|
+
isBaseUrlFromEnv
|
|
227
|
+
};
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
|
|
6
|
+
function renderTemplate(tpl, vars) {
|
|
7
|
+
return String(tpl).replace(/\{(username|password)\}/g, (_, key) => {
|
|
8
|
+
const v = vars[key];
|
|
9
|
+
if (v === undefined || v === null) return '';
|
|
10
|
+
return String(v);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function decodeByEncoding(value, encoding) {
|
|
15
|
+
const enc = String(encoding || 'base64').toLowerCase();
|
|
16
|
+
if (enc === 'base64') return Buffer.from(value, 'base64');
|
|
17
|
+
if (enc === 'hex') return Buffer.from(value, 'hex');
|
|
18
|
+
return Buffer.from(value, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toKeyObject(publicKey) {
|
|
22
|
+
const raw = String(publicKey || '').trim();
|
|
23
|
+
if (!raw) {
|
|
24
|
+
throw new Error('未配置 RSA 公钥。请执行: wb config set publicKey "<公钥>"');
|
|
25
|
+
}
|
|
26
|
+
if (raw.includes('BEGIN PUBLIC KEY')) {
|
|
27
|
+
return crypto.createPublicKey(raw);
|
|
28
|
+
}
|
|
29
|
+
// 前端常把公钥写成裸 Base64(SPKI DER),这里直接按 DER 解析
|
|
30
|
+
const der = Buffer.from(raw.replace(/\s+/g, ''), 'base64');
|
|
31
|
+
return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rsaEncrypt(plain, publicKeyPem, padding) {
|
|
35
|
+
const keyObject = toKeyObject(publicKeyPem);
|
|
36
|
+
|
|
37
|
+
const pad =
|
|
38
|
+
String(padding || 'pkcs1').toLowerCase() === 'oaep'
|
|
39
|
+
? crypto.constants.RSA_PKCS1_OAEP_PADDING
|
|
40
|
+
: crypto.constants.RSA_PKCS1_PADDING;
|
|
41
|
+
|
|
42
|
+
const data = Buffer.from(plain, 'utf8');
|
|
43
|
+
const modulusBits = (keyObject.asymmetricKeyDetails && keyObject.asymmetricKeyDetails.modulusLength) || 1024;
|
|
44
|
+
const padLen = pad === crypto.constants.RSA_PKCS1_OAEP_PADDING ? 2 * 20 + 2 : 11;
|
|
45
|
+
const maxBytes = Math.floor(modulusBits / 8) - padLen;
|
|
46
|
+
|
|
47
|
+
if (data.length > maxBytes) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`待加密明文 ${data.length} 字节,超出当前密钥上限 ${maxBytes} 字节。请缩短明文或更换更长密钥。`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const out = crypto.publicEncrypt({ key: keyObject, padding: pad }, data);
|
|
54
|
+
return out.toString('base64');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function aesEncrypt(plain, cfg) {
|
|
58
|
+
const key = decodeByEncoding(cfg.aesKey, cfg.aesKeyEncoding);
|
|
59
|
+
if (![16, 24, 32].includes(key.length)) {
|
|
60
|
+
throw new Error(`AES 密钥长度非法: ${key.length} 字节,应为 16/24/32`);
|
|
61
|
+
}
|
|
62
|
+
const mode = String(cfg.aesMode || 'cbc').toLowerCase();
|
|
63
|
+
|
|
64
|
+
if (mode === 'ecb') {
|
|
65
|
+
const cipher = crypto.createCipheriv(`aes-${key.length * 8}-ecb`, key, null);
|
|
66
|
+
cipher.setAutoPadding(true);
|
|
67
|
+
return Buffer.concat([cipher.update(Buffer.from(plain, 'utf8')), cipher.final()]).toString('base64');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const iv = decodeByEncoding(cfg.aesIv, cfg.aesIvEncoding);
|
|
71
|
+
if (iv.length !== 16) {
|
|
72
|
+
throw new Error(`AES IV 长度非法: ${iv.length} 字节,应为 16`);
|
|
73
|
+
}
|
|
74
|
+
const cipher = crypto.createCipheriv(`aes-${key.length * 8}-cbc`, key, iv);
|
|
75
|
+
cipher.setAutoPadding(true);
|
|
76
|
+
return Buffer.concat([cipher.update(Buffer.from(plain, 'utf8')), cipher.final()]).toString('base64');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cmdEncrypt(plain, cfg, vars) {
|
|
80
|
+
if (!cfg.cmdCommand) {
|
|
81
|
+
throw new Error('cipher=cmd 但未配置 cmdCommand');
|
|
82
|
+
}
|
|
83
|
+
const payload = JSON.stringify({ plain, username: vars.username, password: vars.password });
|
|
84
|
+
const out = execFileSync('/bin/sh', ['-c', cfg.cmdCommand], {
|
|
85
|
+
input: payload,
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
timeout: 15000
|
|
88
|
+
});
|
|
89
|
+
const trimmed = String(out).trim();
|
|
90
|
+
if (!trimmed) throw new Error('外部加密命令未返回内容');
|
|
91
|
+
return trimmed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 生成待加密明文。
|
|
96
|
+
* json 模式严格对齐前端: JSON.stringify({username, password, rememberMe})
|
|
97
|
+
* 注意字段顺序与 rememberMe 的布尔类型必须保持一致,否则服务端校验会失败。
|
|
98
|
+
*/
|
|
99
|
+
function buildPlain(vars, cfg) {
|
|
100
|
+
const fmt = String(cfg.plainFormat || 'json').toLowerCase();
|
|
101
|
+
if (fmt === 'template') {
|
|
102
|
+
return renderTemplate(cfg.encodeTemplate, vars);
|
|
103
|
+
}
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
username: vars.username,
|
|
106
|
+
password: vars.password,
|
|
107
|
+
rememberMe: cfg.rememberMe !== false
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 根据配置生成登录接口所需的 encodeStr
|
|
113
|
+
* @param {{username:string, password:string}} vars
|
|
114
|
+
* @param {object} cfg
|
|
115
|
+
*/
|
|
116
|
+
function buildEncodeStr(vars, cfg) {
|
|
117
|
+
const plain = buildPlain(vars, cfg);
|
|
118
|
+
const cipher = String(cfg.cipher || 'rsa').toLowerCase();
|
|
119
|
+
|
|
120
|
+
if (cipher === 'plain') return plain;
|
|
121
|
+
if (cipher === 'rsa') return rsaEncrypt(plain, cfg.publicKey, cfg.rsaPadding);
|
|
122
|
+
if (cipher === 'aes') return aesEncrypt(plain, cfg);
|
|
123
|
+
if (cipher === 'cmd') return cmdEncrypt(plain, cfg, vars);
|
|
124
|
+
throw new Error(`不支持的 cipher: ${cfg.cipher}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function describeCipher(cfg) {
|
|
128
|
+
const cipher = String(cfg.cipher || 'rsa').toLowerCase();
|
|
129
|
+
const lines = [];
|
|
130
|
+
lines.push('cipher = ' + cipher);
|
|
131
|
+
if (cipher === 'rsa') {
|
|
132
|
+
lines.push('rsaPadding = ' + cfg.rsaPadding);
|
|
133
|
+
const hasKey = Boolean(cfg.publicKey);
|
|
134
|
+
lines.push('publicKey = ' + (hasKey ? '已配置' : '未配置'));
|
|
135
|
+
if (hasKey) {
|
|
136
|
+
try {
|
|
137
|
+
const keyObject = toKeyObject(cfg.publicKey);
|
|
138
|
+
const det = keyObject.asymmetricKeyDetails || {};
|
|
139
|
+
lines.push('modulusLength = ' + det.modulusLength + ' bit');
|
|
140
|
+
lines.push('publicExponent = ' + String(det.publicExponent));
|
|
141
|
+
const maxBytes = Math.floor((det.modulusLength || 1024) / 8) - 11;
|
|
142
|
+
lines.push('明文上限 = ' + maxBytes + ' 字节(PKCS#1 v1.5)');
|
|
143
|
+
} catch (e) {
|
|
144
|
+
lines.push('公钥解析 = 失败: ' + e.message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (cipher === 'aes') {
|
|
149
|
+
lines.push('aesMode = ' + cfg.aesMode);
|
|
150
|
+
lines.push('aesKey = ' + (cfg.aesKey ? '已配置' : '未配置'));
|
|
151
|
+
lines.push('aesIv = ' + (cfg.aesIv ? '已配置' : '未配置'));
|
|
152
|
+
}
|
|
153
|
+
if (cipher === 'cmd') {
|
|
154
|
+
lines.push('cmdCommand = ' + (cfg.cmdCommand || '未配置'));
|
|
155
|
+
}
|
|
156
|
+
lines.push('plainFormat = ' + (cfg.plainFormat || 'json'));
|
|
157
|
+
if (String(cfg.plainFormat || 'json').toLowerCase() === 'template') {
|
|
158
|
+
lines.push('encodeTemplate = ' + cfg.encodeTemplate);
|
|
159
|
+
}
|
|
160
|
+
lines.push('');
|
|
161
|
+
const sample = buildPlain({ username: 'admin', password: '******' }, cfg);
|
|
162
|
+
lines.push('待加密明文样例 = ' + sample);
|
|
163
|
+
lines.push('样例字节数 = ' + Buffer.byteLength(sample, 'utf8'));
|
|
164
|
+
return lines.join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
renderTemplate,
|
|
169
|
+
toKeyObject,
|
|
170
|
+
rsaEncrypt,
|
|
171
|
+
aesEncrypt,
|
|
172
|
+
buildPlain,
|
|
173
|
+
buildEncodeStr,
|
|
174
|
+
describeCipher
|
|
175
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 本地登录页 HTML。
|
|
5
|
+
* 表单通过同源 POST /login 提交,由 CLI 进程内部持有账号密码并调用原系统登录接口,
|
|
6
|
+
* 密码不落盘、不写日志,仅在内存中用于生成 encodeStr。
|
|
7
|
+
*
|
|
8
|
+
* 平台地址可在此页直接修改:用户从浏览器地址栏复制的地址往往带 /index/... 尾巴,
|
|
9
|
+
* 服务端会调用 normalizeBaseUrl 规范化,成功后写回配置。
|
|
10
|
+
*/
|
|
11
|
+
function renderLoginPage(opts) {
|
|
12
|
+
const o = opts || {};
|
|
13
|
+
const baseUrl = o.baseUrl ? String(o.baseUrl) : '';
|
|
14
|
+
const loginSuffix = o.loginSuffix ? String(o.loginSuffix) : '';
|
|
15
|
+
const editable = o.editable !== false;
|
|
16
|
+
const lockedReason = o.lockedReason ? String(o.lockedReason) : '';
|
|
17
|
+
const error = o.error ? String(o.error) : '';
|
|
18
|
+
|
|
19
|
+
return `<!DOCTYPE html>
|
|
20
|
+
<html lang="zh-CN">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8">
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
24
|
+
<title>登录 幂链iPaaS集成管理平台</title>
|
|
25
|
+
<style>
|
|
26
|
+
:root { color-scheme: light; }
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
body {
|
|
29
|
+
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
30
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
31
|
+
background: #f5f6f8; color: #1f2328;
|
|
32
|
+
}
|
|
33
|
+
.card {
|
|
34
|
+
width: 380px; background: #fff; border: 1px solid #e3e5e8; border-radius: 12px;
|
|
35
|
+
padding: 28px 26px 24px; box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
|
36
|
+
}
|
|
37
|
+
h1 { margin: 0 0 4px; font-size: 18px; font-weight: 500; }
|
|
38
|
+
.sub { margin: 0 0 20px; font-size: 12px; color: #6b7280; word-break: break-all; }
|
|
39
|
+
label { display: block; font-size: 12px; color: #4b5563; margin: 0 0 6px; }
|
|
40
|
+
input {
|
|
41
|
+
width: 100%; height: 38px; padding: 0 10px; margin-bottom: 6px;
|
|
42
|
+
border: 1px solid #d6d9dd; border-radius: 8px; font-size: 14px; outline: none;
|
|
43
|
+
}
|
|
44
|
+
input:focus { border-color: #185FA5; }
|
|
45
|
+
input[readonly] { background: #f7f8f9; color: #6b7280; }
|
|
46
|
+
.field { margin-bottom: 14px; }
|
|
47
|
+
.field-hint { font-size: 11px; color: #9ca3af; line-height: 1.5; word-break: break-all; }
|
|
48
|
+
.field-hint.warn { color: #854F0B; }
|
|
49
|
+
button {
|
|
50
|
+
width: 100%; height: 40px; margin-top: 4px; border: 0; border-radius: 8px;
|
|
51
|
+
background: #185FA5; color: #fff; font-size: 14px; cursor: pointer;
|
|
52
|
+
}
|
|
53
|
+
button:disabled { opacity: .6; cursor: default; }
|
|
54
|
+
.msg { margin-top: 14px; font-size: 13px; min-height: 18px; }
|
|
55
|
+
.msg.err { color: #A32D2D; }
|
|
56
|
+
.msg.ok { color: #0F6E56; }
|
|
57
|
+
.hint { margin-top: 18px; font-size: 11px; color: #9ca3af; line-height: 1.6; }
|
|
58
|
+
.done { text-align: center; padding: 12px 0 4px; }
|
|
59
|
+
.done .check {
|
|
60
|
+
width: 40px; height: 40px; margin: 0 auto 14px; border-radius: 50%;
|
|
61
|
+
background: #E1F5EE; color: #0F6E56; font-size: 22px; line-height: 40px;
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<div class="card" id="card">
|
|
67
|
+
<h1>登录 幂链iPaaS集成管理平台</h1>
|
|
68
|
+
<p class="sub">${baseUrl ? escapeHtml(baseUrl) : '首次使用,请填写平台地址'}</p>
|
|
69
|
+
<form id="form" autocomplete="on">
|
|
70
|
+
<div class="field">
|
|
71
|
+
<label for="b">平台地址</label>
|
|
72
|
+
<input id="b" name="baseUrl" value="${escapeHtml(baseUrl)}" placeholder="http://主机:端口" ${editable ? 'autofocus' : 'readonly'} spellcheck="false">
|
|
73
|
+
<div class="field-hint${editable ? '' : ' warn'}" id="hint">${
|
|
74
|
+
editable
|
|
75
|
+
? '填到端口即可,形如 http://主机:端口'
|
|
76
|
+
: escapeHtml(lockedReason || '当前地址由环境变量指定,不可在此修改')
|
|
77
|
+
}</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="field">
|
|
80
|
+
<label for="u">用户名</label>
|
|
81
|
+
<input id="u" name="username" autocomplete="username" required ${editable ? '' : 'autofocus'}>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="field">
|
|
84
|
+
<label for="p">密码</label>
|
|
85
|
+
<input id="p" name="password" type="password" autocomplete="current-password" required>
|
|
86
|
+
</div>
|
|
87
|
+
<button type="submit" id="btn">登录</button>
|
|
88
|
+
</form>
|
|
89
|
+
<div class="msg err" id="msg">${escapeHtml(error)}</div>
|
|
90
|
+
<div class="hint">凭据仅用于本次登录,不会写入日志或本地文件。登录成功后可直接关闭本页面。</div>
|
|
91
|
+
</div>
|
|
92
|
+
<script>
|
|
93
|
+
var SUFFIX = ${JSON.stringify(loginSuffix)};
|
|
94
|
+
var form = document.getElementById('form');
|
|
95
|
+
var btn = document.getElementById('btn');
|
|
96
|
+
var msg = document.getElementById('msg');
|
|
97
|
+
var baseInput = document.getElementById('b');
|
|
98
|
+
var hint = document.getElementById('hint');
|
|
99
|
+
var EDITABLE = ${editable ? 'true' : 'false'};
|
|
100
|
+
var DEFAULT_HINT = hint.textContent;
|
|
101
|
+
|
|
102
|
+
function composeLoginUrl() {
|
|
103
|
+
var b = (baseInput.value || '').trim().replace(/\\/+$/, '');
|
|
104
|
+
if (!b) return '';
|
|
105
|
+
if (!/^https?:\\/\\//i.test(b)) b = 'http://' + b;
|
|
106
|
+
return b + SUFFIX;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function refreshHint() {
|
|
110
|
+
if (!EDITABLE) return;
|
|
111
|
+
var preview = composeLoginUrl();
|
|
112
|
+
hint.textContent = preview ? '将请求:' + preview : DEFAULT_HINT;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
baseInput.addEventListener('input', refreshHint);
|
|
116
|
+
refreshHint();
|
|
117
|
+
|
|
118
|
+
form.addEventListener('submit', function (e) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
msg.className = 'msg';
|
|
121
|
+
msg.textContent = '正在登录…';
|
|
122
|
+
btn.disabled = true;
|
|
123
|
+
|
|
124
|
+
fetch('/login', {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
username: document.getElementById('u').value,
|
|
129
|
+
password: document.getElementById('p').value,
|
|
130
|
+
baseUrl: EDITABLE ? baseInput.value : ''
|
|
131
|
+
})
|
|
132
|
+
}).then(function (r) {
|
|
133
|
+
return r.json().then(function (j) { return { ok: r.ok, body: j }; });
|
|
134
|
+
}).then(function (res) {
|
|
135
|
+
if (res.ok && res.body && res.body.ok) {
|
|
136
|
+
document.getElementById('card').innerHTML =
|
|
137
|
+
'<div class="done"><div class="check">✓</div>' +
|
|
138
|
+
'<h1>登录成功</h1>' +
|
|
139
|
+
'<p class="sub">' + (res.body.nickName || '') + '</p>' +
|
|
140
|
+
'<p class="sub">' + (res.body.baseUrl || '') + '</p>' +
|
|
141
|
+
'<div class="hint">授权信息已写入本地,可以关闭此页面返回 WorkBuddy。</div></div>';
|
|
142
|
+
setTimeout(function () { window.close(); }, 1200);
|
|
143
|
+
} else {
|
|
144
|
+
msg.className = 'msg err';
|
|
145
|
+
msg.textContent = (res.body && res.body.message) || '登录失败';
|
|
146
|
+
btn.disabled = false;
|
|
147
|
+
}
|
|
148
|
+
}).catch(function (err) {
|
|
149
|
+
msg.className = 'msg err';
|
|
150
|
+
msg.textContent = '请求失败:' + err.message;
|
|
151
|
+
btn.disabled = false;
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
</script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderDonePage(nickName) {
|
|
160
|
+
return `<!DOCTYPE html>
|
|
161
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>登录成功</title>
|
|
162
|
+
<style>body{font-family:-apple-system,"PingFang SC",sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f6f8;color:#1f2328}
|
|
163
|
+
.b{text-align:center}.t{font-size:18px;margin:12px 0 4px}.s{font-size:13px;color:#6b7280}</style></head>
|
|
164
|
+
<body><div class="b"><div style="font-size:36px;color:#0F6E56">✓</div>
|
|
165
|
+
<div class="t">登录成功</div><div class="s">${escapeHtml(nickName || '')}</div>
|
|
166
|
+
<div class="s" style="margin-top:10px">可以关闭此页面返回 WorkBuddy</div></div></body></html>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escapeHtml(str) {
|
|
170
|
+
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
|
171
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch];
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { renderLoginPage, renderDonePage, escapeHtml };
|