dsh-vpn 0.1.11 → 0.1.13

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 CHANGED
@@ -35,7 +35,7 @@ dsh plugin --profile web add dsh-vpn
35
35
 
36
36
  1. 填写服务器 IP、SSH 端口、用户名、密码,点「保存」和「测试连接」。服务器只允许密钥登录的,见下面的「密钥登录」。
37
37
  2. 勾选要安装的协议,选择证书类型(推荐「IP 证书」),保存后点「安装 / 更新」。
38
- 3. 在「节点二维码」里点「刷新二维码」,用手机或电脑上的客户端扫码导入。
38
+ 3. 在「节点二维码」里点「刷新二维码」,用手机或电脑上的客户端扫码导入。用 **sing-box 官方客户端**(Mac 版 SFM、iPhone 版 SFI、Android 版 SFA、Windows 版 SFW,1.14 及以上)的,点二维码上方的「下载配置文件」,在客户端里「新建配置 → 导入本地文件」,一次导入全部节点。官方客户端不能扫单条链接的二维码,用这个配置文件即可;配置里 TUIC 和 AnyTLS 已写入证书锁定,不需要跳过证书校验,默认自动选择延迟最低的节点。
39
39
  4. 服务器防火墙的端口由插件自动放行,不用手动操作;安装完成后提示框会列出放行了哪些端口。
40
40
 
41
41
  **密钥登录**:服务器只允许密钥登录时,把「登录方式」选成「密钥」。插件按这个顺序尝试登录,哪把被服务器认可就用哪把:
@@ -44,7 +44,7 @@ dsh plugin --profile web add dsh-vpn
44
44
  2. 本机已有的钥匙:`~/.ssh/config`(含 `config.d`)里为这台服务器配置的 `IdentityFile`,dsh-vps-manager 的 `~/.ssh/dsh_vps_ed25519`,以及 `~/.ssh/id_ed25519`、`id_ecdsa`、`id_rsa`;
45
45
  3. 插件的专用钥匙(纯 JS 生成,不需要本机的 ssh-keygen)。
46
46
 
47
- **已经用 dsh-vps-manager 管理的服务器不需要再放任何公钥**:它的钥匙已经在服务器上,直接点「检查已有安装」或「测试连接」即可,页面会列出本机找到的钥匙,测试连接成功后会说明用的是哪一把。本机钥匙都不被认可时,再把页面上显示的专用钥匙公钥放到服务器上(服务商后台的「SSH 密钥」,或在能登录的服务器上执行页面给出的一行命令)。私钥只在本机 dsh 里读取,不会发给页面;带密码短语的本机私钥会跳过。用非 root 用户时,「sudo 密码」一栏填该用户的 sudo 密码;root 或免密 sudo 不用填。
47
+ **已经用 dsh-vps-manager 管理的服务器不需要再放任何公钥**:它的钥匙已经在服务器上。填好 IP,「登录方式」选「密钥」,页面会列出本机找到的钥匙,点这张卡片右上角的「使用」就登录了(不用先保存,登录成功后自动保存)。卡片随后一直显示「已登录:用哪把钥匙登录 用户@服务器」,之后安装、检查和刷新二维码都优先用这把钥匙;改了 IP、端口或用户名,登录状态自动作废。本机钥匙都不被认可时,再把页面上显示的专用钥匙公钥放到服务器上(服务商后台的「SSH 密钥」,或在能登录的服务器上执行页面给出的一行命令)。私钥只在本机 dsh 里读取,不会发给页面;带密码短语的本机私钥会跳过。用非 root 用户时,「sudo 密码」一栏填该用户的 sudo 密码;root 或免密 sudo 不用填。
48
48
 
49
49
  **找回已有节点**:重装过 dsh 或 dsh-vpn 后,本地保存的设置会丢失,但 VPS 上的节点还在运行。在 VPN 设置里填好服务器 IP、端口、用户名和密码(或选密钥登录),**不用先保存**,直接点登录方式下面的「检查已有安装」:插件登录服务器读取仍在运行的节点,显示原来的二维码(链接和以前完全一样,已经导入手机或电脑客户端的配置继续可用),按服务器上的实际情况勾选协议和证书类型,并自动保存设置。登录失败时什么都不保存,填的内容也不会被清掉。
50
50
 
package/lib/client.js CHANGED
@@ -54,6 +54,8 @@ var vpnApi = {
54
54
  sshKey: (host = "") => post("/ssh-key", { host }),
55
55
  forgetHostKey: () => post("/host-key/forget"),
56
56
  test: () => post("/test"),
57
+ /** Log in with keys (local ones first) using the form as typed; saved once it works. */
58
+ login: (form) => post("/login", form),
57
59
  status: () => post("/status"),
58
60
  links: () => post("/links"),
59
61
  /** Find an earlier installation on the server and adopt its protocols and certificate mode. */
@@ -99,7 +101,11 @@ var zh = {
99
101
  keyIntro: "\u7528\u63D2\u4EF6\u7684\u4E13\u7528\u94A5\u5319\u767B\u5F55\u3002\u628A\u4E0B\u9762\u7684\u516C\u94A5\u653E\u5230\u670D\u52A1\u5668\u4E0A\uFF0C\u4EFB\u9009\u4E00\u79CD\u529E\u6CD5\uFF1A",
100
102
  keyIntroFallback: "\u5982\u679C\u670D\u52A1\u5668\u4E0D\u8BA4\u4E0A\u9762\u7684\u94A5\u5319\uFF0C\u518D\u7528\u63D2\u4EF6\u7684\u4E13\u7528\u94A5\u5319\uFF1A\u628A\u4E0B\u9762\u7684\u516C\u94A5\u653E\u5230\u670D\u52A1\u5668\u4E0A\uFF0C\u4EFB\u9009\u4E00\u79CD\u529E\u6CD5\uFF1A",
101
103
  localKeysFound: "\u672C\u673A\u5DF2\u6709\u7684\u94A5\u5319\uFF08\u4F1A\u5148\u7528\u5B83\u4EEC\u767B\u5F55\uFF09",
102
- localKeysHint: "\u5305\u62EC ~/.ssh/config \u91CC\u4E3A\u8FD9\u53F0\u670D\u52A1\u5668\u914D\u7F6E\u7684\u94A5\u5319\u3001dsh-vps-manager \u7684 ~/.ssh/dsh_vps_ed25519 \u548C ~/.ssh/id_*\u3002\u670D\u52A1\u5668\u8BA4\u53EF\u5176\u4E2D\u4EFB\u4F55\u4E00\u628A\uFF0C\u5C31\u4E0D\u7528\u518D\u653E\u516C\u94A5\uFF0C\u76F4\u63A5\u70B9\u300C\u68C0\u67E5\u5DF2\u6709\u5B89\u88C5\u300D\u6216\u300C\u6D4B\u8BD5\u8FDE\u63A5\u300D\u3002\u79C1\u94A5\u53EA\u5728\u672C\u673A dsh \u91CC\u8BFB\u53D6\uFF0C\u4E0D\u4F1A\u53D1\u7ED9\u9875\u9762\u3002",
104
+ useKey: "\u4F7F\u7528",
105
+ relogin: "\u91CD\u65B0\u767B\u5F55",
106
+ loggingIn: "\u767B\u5F55\u4E2D\u2026",
107
+ loggedIn: "\u2713 \u5DF2\u767B\u5F55\uFF1A\u7528 {key} \u767B\u5F55 {user}@{host}\uFF08{time}\uFF09\u3002\u4E4B\u540E\u5B89\u88C5\u3001\u68C0\u67E5\u548C\u5237\u65B0\u4E8C\u7EF4\u7801\u90FD\u4F1A\u5148\u7528\u8FD9\u628A\u94A5\u5319\u3002",
108
+ localKeysHint: "\u5305\u62EC ~/.ssh/config \u91CC\u4E3A\u8FD9\u53F0\u670D\u52A1\u5668\u914D\u7F6E\u7684\u94A5\u5319\u3001dsh-vps-manager \u7684 ~/.ssh/dsh_vps_ed25519 \u548C ~/.ssh/id_*\u3002\u670D\u52A1\u5668\u8BA4\u53EF\u5176\u4E2D\u4EFB\u4F55\u4E00\u628A\uFF0C\u5C31\u4E0D\u7528\u518D\u653E\u516C\u94A5\uFF0C\u70B9\u53F3\u4E0A\u89D2\u300C\u4F7F\u7528\u300D\u5373\u53EF\u767B\u5F55\u3002\u79C1\u94A5\u53EA\u5728\u672C\u673A dsh \u91CC\u8BFB\u53D6\uFF0C\u4E0D\u4F1A\u53D1\u7ED9\u9875\u9762\u3002",
103
109
  testOkKey: "\uFF08\u7528 {key} \u767B\u5F55\uFF09",
104
110
  dedicatedKey: "\u63D2\u4EF6\u4E13\u7528\u94A5\u5319",
105
111
  keyWayConsole: "A. \u670D\u52A1\u5546\u540E\u53F0\u6DFB\u52A0\uFF08\u65B0\u673A\u5668\u63A8\u8350\uFF09",
@@ -160,7 +166,11 @@ var zh = {
160
166
  uninstalled: "\u5DF2\u5378\u8F7D\u3002",
161
167
  working: "\u6267\u884C\u4E2D\u2026",
162
168
  qrSection: "\u8282\u70B9\u4E8C\u7EF4\u7801",
163
- qrHint: "\u7528 v2rayN\u3001NekoBox\u3001Shadowrocket\u3001sing-box\u3001Clash Meta \u7B49\u5BA2\u6237\u7AEF\u626B\u63CF\u5BFC\u5165\u3002\u4E8C\u7EF4\u7801\u7B49\u540C\u4E8E\u5BC6\u7801\uFF0C\u8BF7\u52FF\u5916\u4F20\u3002",
169
+ qrHint: "\u7528 Shadowrocket\uFF08\u5C0F\u706B\u7BAD\uFF09\u3001v2rayN\u3001NekoBox\u3001Clash Meta \u7B49\u5BA2\u6237\u7AEF\u626B\u63CF\u5BFC\u5165\u3002sing-box \u5B98\u65B9\u5BA2\u6237\u7AEF\u8BF7\u7528\u4E0B\u9762\u7684\u914D\u7F6E\u6587\u4EF6\u3002\u4E8C\u7EF4\u7801\u548C\u914D\u7F6E\u6587\u4EF6\u90FD\u7B49\u540C\u4E8E\u5BC6\u7801\uFF0C\u8BF7\u52FF\u5916\u4F20\u3002",
170
+ profileTitle: "sing-box \u5B98\u65B9\u5BA2\u6237\u7AEF\u914D\u7F6E",
171
+ profileDownload: "\u4E0B\u8F7D\u914D\u7F6E\u6587\u4EF6",
172
+ profileCopy: "\u590D\u5236\u914D\u7F6E",
173
+ profileHint: "\u4E00\u6B21\u5BFC\u5165\u5168\u90E8\u8282\u70B9\uFF0C\u9002\u7528\u4E8E sing-box \u5B98\u65B9\u5BA2\u6237\u7AEF 1.14 \u53CA\u4EE5\u4E0A\uFF08Mac \u7248 SFM\u3001iPhone \u7248 SFI\u3001Android \u7248 SFA\u3001Windows \u7248 SFW\uFF09\u3002\u5728\u5BA2\u6237\u7AEF\u91CC\u300C\u65B0\u5EFA\u914D\u7F6E \u2192 \u5BFC\u5165\u672C\u5730\u6587\u4EF6\u300D\u9009\u62E9 {file}\uFF08\u624B\u673A\u53EF\u4EE5\u7528\u9694\u7A7A\u6295\u9001\u6216\u6587\u4EF6 App \u4F20\u8FC7\u53BB\uFF09\u3002TUIC \u548C AnyTLS \u5DF2\u5199\u5165\u8BC1\u4E66\u9501\u5B9A\uFF0C\u4E0D\u9700\u8981\u8DF3\u8FC7\u8BC1\u4E66\u6821\u9A8C\uFF1B\u9ED8\u8BA4\u81EA\u52A8\u9009\u62E9\u5EF6\u8FDF\u6700\u4F4E\u7684\u8282\u70B9\u3002",
164
174
  detect: "\u68C0\u67E5\u5DF2\u6709\u5B89\u88C5",
165
175
  detecting: "\u6B63\u5728\u8BFB\u53D6\u670D\u52A1\u5668\u2026",
166
176
  detectHint: "\u91CD\u88C5\u8FC7 dsh \u6216\u672C\u63D2\u4EF6\uFF1F\u586B\u597D\u4E0A\u9762\u7684\u767B\u5F55\u4FE1\u606F\u76F4\u63A5\u70B9\u8FD9\u91CC\uFF08\u4E0D\u7528\u5148\u4FDD\u5B58\uFF09\uFF0C\u4F1A\u767B\u5F55\u670D\u52A1\u5668\u8BFB\u53D6\u4ECD\u5728\u8FD0\u884C\u7684\u8282\u70B9\uFF0C\u663E\u793A\u539F\u6765\u7684\u4E8C\u7EF4\u7801\uFF0C\u5E76\u81EA\u52A8\u4FDD\u5B58\u8BBE\u7F6E\u3002",
@@ -235,7 +245,11 @@ var en = {
235
245
  keyIntro: "Log in with the plugin's dedicated key. Put the public key below on the server, either way:",
236
246
  keyIntroFallback: "If the server accepts none of the keys above, use the plugin's dedicated key: put the public key below on the server, either way:",
237
247
  localKeysFound: "Keys already on this computer (tried first)",
238
- localKeysHint: "The keys ~/.ssh/config names for this server, dsh-vps-manager's ~/.ssh/dsh_vps_ed25519 and ~/.ssh/id_*. If the server accepts any of them, nothing needs to be added: click Find existing install or Test connection. Private keys are read only by dsh on this computer and never sent to the page.",
248
+ useKey: "Use",
249
+ relogin: "Log in again",
250
+ loggingIn: "Logging in\u2026",
251
+ loggedIn: "\u2713 Logged in: {user}@{host} with {key} ({time}). Install, find and refresh will use this key first.",
252
+ localKeysHint: "The keys ~/.ssh/config names for this server, dsh-vps-manager's ~/.ssh/dsh_vps_ed25519 and ~/.ssh/id_*. If the server accepts any of them, nothing needs to be added: click Use at the top right to log in. Private keys are read only by dsh on this computer and never sent to the page.",
239
253
  testOkKey: " (logged in with {key})",
240
254
  dedicatedKey: "the plugin's dedicated key",
241
255
  keyWayConsole: "A. Add it in your provider's console (recommended for new machines)",
@@ -296,7 +310,11 @@ var en = {
296
310
  uninstalled: "Uninstalled.",
297
311
  working: "Working\u2026",
298
312
  qrSection: "Node QR codes",
299
- qrHint: "Scan with v2rayN, NekoBox, Shadowrocket, sing-box, Clash Meta and similar apps. A QR code is as secret as a password.",
313
+ qrHint: "Scan with Shadowrocket, v2rayN, NekoBox, Clash Meta and similar apps. For the official sing-box apps use the profile below. QR codes and the profile are as secret as a password.",
314
+ profileTitle: "Profile for the official sing-box apps",
315
+ profileDownload: "Download profile",
316
+ profileCopy: "Copy profile",
317
+ profileHint: "All nodes in one import, for the official sing-box apps 1.14+ (SFM on Mac, SFI on iPhone, SFA on Android, SFW on Windows). In the app choose New Profile \u2192 Import local file and pick {file} (send it to a phone with AirDrop or the Files app). TUIC and AnyTLS have their certificates pinned, so nothing skips verification; the fastest node is chosen automatically.",
300
318
  detect: "Find existing install",
301
319
  detecting: "Reading the server\u2026",
302
320
  detectHint: "Reinstalled dsh or this plugin? Fill in the login details above and click here (no need to save first): it logs in, reads the node that is still running, shows its QR codes and saves the settings.",
@@ -395,6 +413,9 @@ var STYLES = `
395
413
  color: inherit; background: transparent; border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3)); }
396
414
  .dshvpn-detect { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding-top: 4px; }
397
415
  .dshvpn-detect small { flex: 1; min-width: 220px; }
416
+ .dshvpn-keycard-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
417
+ .dshvpn-loggedin { font-size: 13px; color: var(--dsw-alias-state-success-primary, #2f855a); }
418
+ .dshvpn-profile { margin: 10px 0 4px; }
398
419
  .dshvpn-section { display: flex; flex-direction: column; gap: 16px; }
399
420
  .dshvpn-section-head h2 { margin: 0; font-size: 18px; font-weight: 600; }
400
421
  .dshvpn-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 4px; }
@@ -1106,6 +1127,106 @@ function renderSVG(data, options = {}) {
1106
1127
  return svg;
1107
1128
  }
1108
1129
 
1130
+ // src/client-profile.ts
1131
+ var NAMES = {
1132
+ reality: "VLESS Reality",
1133
+ hy2: "Hysteria2",
1134
+ tuic: "TUIC v5",
1135
+ anytls: "AnyTLS",
1136
+ ss: "Shadowsocks 2022"
1137
+ };
1138
+ function base64UrlDecode(value) {
1139
+ const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
1140
+ const bin = atob(b64 + "=".repeat((4 - b64.length % 4) % 4));
1141
+ return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
1142
+ }
1143
+ function outboundOf(item) {
1144
+ const u = new URL(item.link);
1145
+ const q = Object.fromEntries(u.searchParams);
1146
+ const server = u.hostname.replace(/^\[|\]$/g, "");
1147
+ const port = Number(u.port);
1148
+ const user = decodeURIComponent(u.username);
1149
+ const tls = (alpn) => ({
1150
+ enabled: true,
1151
+ server_name: q.sni,
1152
+ ...alpn ? { alpn } : {},
1153
+ ...item.pubkey_sha256 ? { certificate_public_key_sha256: [item.pubkey_sha256] } : {}
1154
+ });
1155
+ switch (item.id) {
1156
+ case "reality":
1157
+ return {
1158
+ type: "vless",
1159
+ server,
1160
+ server_port: port,
1161
+ uuid: user,
1162
+ flow: q.flow,
1163
+ tls: { enabled: true, server_name: q.sni, utls: { enabled: true, fingerprint: q.fp }, reality: { enabled: true, public_key: q.pbk, short_id: q.sid } }
1164
+ };
1165
+ case "hy2":
1166
+ return { type: "hysteria2", server, server_port: port, password: user, tls: tls(["h3"]) };
1167
+ case "tuic":
1168
+ return {
1169
+ type: "tuic",
1170
+ server,
1171
+ server_port: port,
1172
+ uuid: user,
1173
+ password: decodeURIComponent(u.password),
1174
+ congestion_control: "bbr",
1175
+ tls: tls(["h3"])
1176
+ };
1177
+ case "anytls":
1178
+ return { type: "anytls", server, server_port: port, password: user, tls: tls() };
1179
+ case "ss": {
1180
+ const [method, ...rest] = base64UrlDecode(user).split(":");
1181
+ return { type: "shadowsocks", server, server_port: port, method, password: rest.join(":") };
1182
+ }
1183
+ }
1184
+ }
1185
+ var AUTO_TAG = "\u81EA\u52A8\u9009\u62E9 Auto";
1186
+ var SELECT_TAG = "\u8282\u70B9\u9009\u62E9 Select";
1187
+ function singBoxProfile(links) {
1188
+ const nodes = links.items.map((item) => ({ ...outboundOf(item), tag: `${NAMES[item.id]} \xB7 dsh-vpn` }));
1189
+ const tags = nodes.map((n) => n.tag);
1190
+ return {
1191
+ log: { level: "warn" },
1192
+ dns: {
1193
+ servers: [
1194
+ { type: "tls", tag: "dns-remote", server: "1.1.1.1", detour: SELECT_TAG },
1195
+ { type: "local", tag: "dns-local" }
1196
+ ],
1197
+ final: "dns-remote"
1198
+ },
1199
+ inbounds: [{
1200
+ type: "tun",
1201
+ tag: "tun-in",
1202
+ address: ["172.19.0.1/30", "fdfe:dcba:9876::1/126"],
1203
+ auto_route: true,
1204
+ strict_route: true,
1205
+ stack: "mixed"
1206
+ }],
1207
+ outbounds: [
1208
+ { type: "selector", tag: SELECT_TAG, outbounds: [AUTO_TAG, ...tags], default: AUTO_TAG },
1209
+ { type: "urltest", tag: AUTO_TAG, outbounds: tags },
1210
+ ...nodes,
1211
+ { type: "direct", tag: "direct" }
1212
+ ],
1213
+ route: {
1214
+ rules: [
1215
+ { action: "sniff" },
1216
+ { protocol: "dns", action: "hijack-dns" },
1217
+ { ip_is_private: true, outbound: "direct" }
1218
+ ],
1219
+ final: SELECT_TAG,
1220
+ auto_detect_interface: true,
1221
+ default_domain_resolver: "dns-local"
1222
+ },
1223
+ experimental: { cache_file: { enabled: true } }
1224
+ };
1225
+ }
1226
+ function profileFileName(links) {
1227
+ return `dsh-vpn-${links.server.replace(/[^0-9a-z.-]/gi, "_")}.json`;
1228
+ }
1229
+
1109
1230
  // src/protocols.ts
1110
1231
  var PROTOCOLS = [
1111
1232
  { id: "reality", name: "VLESS Reality", transport: "TCP", insecureLink: false, defaultSelected: true },
@@ -1180,7 +1301,19 @@ function Copyable({ text, label, t }) {
1180
1301
  }, children: copied ? t("copied") : label })
1181
1302
  ] });
1182
1303
  }
1183
- function KeyPanel({ t, api, form, saved, set, setForm }) {
1304
+ function KeyPanel({ t, api, form, saved, set, setForm, disabled, onLoggedIn }) {
1305
+ const [logging, setLogging] = (0, import_react.useState)(false);
1306
+ const [loginError, setLoginError] = (0, import_react.useState)(null);
1307
+ const loggedIn = saved.loginKey !== null && saved.host === form.host.trim() && saved.port === form.port && saved.username === form.username.trim();
1308
+ const keyName = (label) => label === "own" ? t("ownKey") : label === "dsh-vpn" ? t("dedicatedKey") : label;
1309
+ const login = async () => {
1310
+ setLogging(true);
1311
+ setLoginError(null);
1312
+ const r = await api.login(form);
1313
+ setLogging(false);
1314
+ if (r.ok) onLoggedIn(r.settings);
1315
+ else setLoginError(errorText(t, r));
1316
+ };
1184
1317
  const [key, setKey] = (0, import_react.useState)(null);
1185
1318
  const ownStored = saved.hasPrivateKey && !form.clearPrivateKey;
1186
1319
  const [own, setOwn] = (0, import_react.useState)(ownStored);
@@ -1199,12 +1332,32 @@ function KeyPanel({ t, api, form, saved, set, setForm }) {
1199
1332
  const local = key?.localKeys ?? [];
1200
1333
  const preparing = t("working");
1201
1334
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keypanel dshvpn-wide", children: [
1202
- local.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard dshvpn-success", children: [
1203
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("localKeysFound") }),
1204
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: local.join("\n") }),
1205
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("localKeysHint") })
1335
+ (local.length > 0 || loggedIn) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard dshvpn-success", children: [
1336
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard-head", children: [
1337
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("localKeysFound") }),
1338
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1339
+ import_dsh_client_ui_primitives.Button,
1340
+ {
1341
+ size: "sm",
1342
+ variant: loggedIn ? "outline" : "primary",
1343
+ disabled: disabled || logging || form.host.trim() === "",
1344
+ onClick: () => {
1345
+ void login();
1346
+ },
1347
+ children: logging ? t("loggingIn") : loggedIn ? t("relogin") : t("useKey")
1348
+ }
1349
+ )
1350
+ ] }),
1351
+ loggedIn ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-loggedin", children: t("loggedIn", {
1352
+ key: keyName(saved.loginKey),
1353
+ user: saved.username,
1354
+ host: saved.host,
1355
+ time: saved.loggedInAt ? new Date(saved.loggedInAt).toLocaleString() : ""
1356
+ }) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: local.join("\n") }),
1357
+ loginError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-notice dshvpn-error", children: loginError }),
1358
+ !loggedIn && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("localKeysHint") })
1206
1359
  ] }),
1207
- !own && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1360
+ !own && !loggedIn && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1208
1361
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dshvpn-muted", children: local.length > 0 ? t("keyIntroFallback") : t("keyIntro") }),
1209
1362
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard", children: [
1210
1363
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("keyWayConsole") }),
@@ -1262,6 +1415,39 @@ function KeyPanel({ t, api, form, saved, set, setForm }) {
1262
1415
  ] })
1263
1416
  ] });
1264
1417
  }
1418
+ function ProfileCard({ links, t }) {
1419
+ const [copied, setCopied] = (0, import_react.useState)(false);
1420
+ const text = (0, import_react.useMemo)(() => JSON.stringify(singBoxProfile(links), null, 2), [links]);
1421
+ const download = () => {
1422
+ const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
1423
+ const a = document.createElement("a");
1424
+ a.href = url;
1425
+ a.download = profileFileName(links);
1426
+ document.body.append(a);
1427
+ a.click();
1428
+ a.remove();
1429
+ setTimeout(() => {
1430
+ URL.revokeObjectURL(url);
1431
+ }, 1e4);
1432
+ };
1433
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard dshvpn-profile", children: [
1434
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-keycard-head", children: [
1435
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("profileTitle") }),
1436
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dshvpn-actions", children: [
1437
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "primary", onClick: download, children: t("profileDownload") }),
1438
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "outline", onClick: () => {
1439
+ void (0, import_dsh_client_ui_primitives.writeClipboard)(text).then(() => {
1440
+ setCopied(true);
1441
+ setTimeout(() => {
1442
+ setCopied(false);
1443
+ }, 1500);
1444
+ });
1445
+ }, children: copied ? t("copied") : t("profileCopy") })
1446
+ ] })
1447
+ ] }),
1448
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("profileHint", { file: profileFileName(links) }) })
1449
+ ] });
1450
+ }
1265
1451
  function QrCard({ item, t }) {
1266
1452
  const [copied, setCopied] = (0, import_react.useState)(false);
1267
1453
  const info = PROTOCOLS.find((p) => p.id === item.id);
@@ -1447,7 +1633,22 @@ ${t("cloudHint")}`;
1447
1633
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: m === "key" ? t("authKey") : t("authPassword") })
1448
1634
  ] }, m)) })
1449
1635
  ] }),
1450
- form.auth === "key" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KeyPanel, { t, api, form, saved, set, setForm }),
1636
+ form.auth === "key" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1637
+ KeyPanel,
1638
+ {
1639
+ t,
1640
+ api,
1641
+ form,
1642
+ saved,
1643
+ set,
1644
+ setForm,
1645
+ disabled,
1646
+ onLoggedIn: (v) => {
1647
+ setSaved(v);
1648
+ setForm(formOf(v));
1649
+ }
1650
+ }
1651
+ ),
1451
1652
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-wide dshvpn-detect", children: [
1452
1653
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { variant: "primary", disabled: disabled || form.host.trim() === "", onClick: () => {
1453
1654
  void detect();
@@ -1639,7 +1840,10 @@ ${fw.backend === "none" ? t("fwNone") : t("fwStatus", { backend: fw.backend, cou
1639
1840
  ] })
1640
1841
  ] }),
1641
1842
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("small", { className: "dshvpn-muted", children: t("qrHint") }),
1642
- links ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-qr-grid", children: links.items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(QrCard, { item, t }, item.id)) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-qr-empty", children: [
1843
+ links ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1844
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProfileCard, { links, t }),
1845
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dshvpn-qr-grid", children: links.items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(QrCard, { item, t }, item.id)) })
1846
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dshvpn-qr-empty", children: [
1643
1847
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dshvpn-qr-empty-icon", "aria-hidden": "true", children: "\u25A6" }),
1644
1848
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: needsSave ? t("qrNeedsSave") : t("qrEmpty") }),
1645
1849
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dsh_client_ui_primitives.Button, { variant: "primary", className: "dshvpn-refresh dshvpn-refresh-lg", disabled: disabled || needsSave, onClick: () => {
package/lib/index.js CHANGED
@@ -20370,7 +20370,9 @@ var EMPTY_VIEW = {
20370
20370
  protocols: [...DEFAULT_PROTOCOLS],
20371
20371
  certMode: "self",
20372
20372
  linkAddress: "",
20373
- hostKey: null
20373
+ hostKey: null,
20374
+ loginKey: null,
20375
+ loggedInAt: null
20374
20376
  };
20375
20377
  function publicView(s) {
20376
20378
  if (!s) return EMPTY_VIEW;
@@ -20385,7 +20387,9 @@ function publicView(s) {
20385
20387
  protocols: s.protocols,
20386
20388
  certMode: s.certMode,
20387
20389
  linkAddress: s.linkAddress ?? "",
20388
- hostKey: s.hostKey ?? null
20390
+ hostKey: s.hostKey ?? null,
20391
+ loginKey: s.auth === "key" ? s.loginKey ?? null : null,
20392
+ loggedInAt: s.auth === "key" ? s.loggedInAt ?? null : null
20389
20393
  };
20390
20394
  }
20391
20395
  function applyInput(current, input) {
@@ -20436,7 +20440,9 @@ function applyInput(current, input) {
20436
20440
  protocols,
20437
20441
  certMode,
20438
20442
  linkAddress: linkAddress || void 0,
20439
- hostKey: sameMachine ? current.hostKey : void 0
20443
+ hostKey: sameMachine ? current.hostKey : void 0,
20444
+ // A login is remembered only for the same machine and user.
20445
+ ...sameMachine && current.username === username && auth === "key" && current.loginKey ? { loginKey: current.loginKey, loggedInAt: current.loggedInAt } : {}
20440
20446
  };
20441
20447
  }
20442
20448
  var MemoryKeyStore = class {
@@ -20500,6 +20506,12 @@ function apiHandlers(service, uiLang, onUiLang, updater) {
20500
20506
  return { ok: true, settings: publicView(await service.store.get()) };
20501
20507
  },
20502
20508
  "/test": (_body, signal) => service.test(signal),
20509
+ // 「使用」 on the local-keys card: key login with the form as typed; saved once it works.
20510
+ "/login": async (body, signal) => {
20511
+ const candidate = applyInput(await service.store.get(), { ...body, auth: "key" });
20512
+ const r = await service.test(signal, candidate);
20513
+ return r.ok ? { ...r, settings: publicView(await service.store.get()) } : r;
20514
+ },
20503
20515
  "/status": (_body, signal) => service.status(signal),
20504
20516
  "/links": (_body, signal) => service.links(signal),
20505
20517
  // With form fields: try them as they are (no save needed first); they are
@@ -20617,6 +20629,8 @@ async function targetOf(s, keys, local = () => []) {
20617
20629
  } catch {
20618
20630
  }
20619
20631
  list.push({ key: (await keys.get()).privateKey, label: "dsh-vpn" });
20632
+ const last = list.findIndex((k) => k.label === s.loginKey);
20633
+ if (last > 0) list.unshift(...list.splice(last, 1));
20620
20634
  return { ...base, keys: list };
20621
20635
  }
20622
20636
  function resolveProtocols(requested, fallback) {
@@ -20771,13 +20785,15 @@ var VpnService = class {
20771
20785
  }
20772
20786
  /**
20773
20787
  * Log in, report who we are and whether root is reachable. Pins the host key
20774
- * on success, so the settings page can show it for the user to compare.
20788
+ * and, for key login, remembers which key worked. With `candidate` (the
20789
+ * settings page's 「使用」 on a local key) the form is tried as it is and
20790
+ * stored only once the login works.
20775
20791
  */
20776
- test(signal) {
20792
+ test(signal, candidate) {
20777
20793
  return this.exclusive(async () => {
20778
20794
  let session;
20779
20795
  try {
20780
- const s = await this.settings();
20796
+ const s = candidate ?? await this.settings();
20781
20797
  session = await SshSession.open(await targetOf(s, this.keys, this.config.localKeys), signal);
20782
20798
  const asRoot = s.username === "root";
20783
20799
  const cmd = `echo "uid=$(${asRoot ? "" : `sudo -S -p '' `}id -u)"; echo "sys=$(uname -srm)"`;
@@ -20785,12 +20801,21 @@ var VpnService = class {
20785
20801
  `, signal);
20786
20802
  const uid = /^uid=(.*)$/m.exec(res.stdout)?.[1];
20787
20803
  const system = /^sys=(.*)$/m.exec(res.stdout)?.[1] ?? "";
20788
- await this.pin(s, session.hostKey);
20789
20804
  const root = uid === "0";
20805
+ const hostKey = session.hostKey;
20806
+ const login = s.auth === "key" && session.keyLabel ? { loginKey: session.keyLabel, loggedInAt: (/* @__PURE__ */ new Date()).toISOString() } : {};
20807
+ if (candidate) {
20808
+ if (root) await this.store.update(() => ({ ...candidate, hostKey: candidate.hostKey ?? (hostKey || void 0), ...login }));
20809
+ } else {
20810
+ await this.pin(s, hostKey);
20811
+ if (root && login.loginKey) {
20812
+ await this.store.update((cur) => cur && cur.host === s.host && cur.port === s.port && cur.username === s.username ? { ...cur, ...login } : void 0);
20813
+ }
20814
+ }
20790
20815
  return {
20791
20816
  ok: root,
20792
20817
  ...root ? {} : { error: "sudo_failed", message: `cannot become root as ${s.username}: ${res.stderr.trim()}` },
20793
- result: { hostKey: session.hostKey, system, root, ...session.keyLabel ? { key: session.keyLabel } : {} }
20818
+ result: { hostKey, system, root, ...session.keyLabel ? { key: session.keyLabel } : {} }
20794
20819
  };
20795
20820
  } catch (err) {
20796
20821
  return failure(err);
@@ -20822,7 +20847,9 @@ function parse(payload) {
20822
20847
  protocols: protocols.length > 0 ? protocols : ["reality", "hy2"],
20823
20848
  certMode: p.certMode === "ip" ? "ip" : "self",
20824
20849
  linkAddress: typeof p.linkAddress === "string" ? p.linkAddress : void 0,
20825
- hostKey: typeof p.hostKey === "string" ? p.hostKey : void 0
20850
+ hostKey: typeof p.hostKey === "string" ? p.hostKey : void 0,
20851
+ loginKey: typeof p.loginKey === "string" ? p.loginKey : void 0,
20852
+ loggedInAt: typeof p.loggedInAt === "string" ? p.loggedInAt : void 0
20826
20853
  };
20827
20854
  }
20828
20855
  var toPayload = (s) => JSON.parse(JSON.stringify(s));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vpn",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "DeepSeek Harness plugin: deploy an encrypted VPN (sing-box) to your own VPS in one click, to protect your connections and privacy on untrusted networks",
5
5
  "keywords": [
6
6
  "anytls",