dsh-vpn 0.1.4 → 0.1.5

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
@@ -38,6 +38,14 @@ dsh plugin --profile web add dsh-vpn
38
38
  3. 在「节点二维码」里点「刷新二维码」,用手机或电脑上的客户端扫码导入。
39
39
  4. 服务器防火墙的端口由插件自动放行,不用手动操作;安装完成后提示框会列出放行了哪些端口。
40
40
 
41
+ ## 语言
42
+
43
+ 整个插件的语言跟随 dsh 的界面语言(「设置 → 通用设置 → 语言」),目前支持 dsh 提供的中文和 English:
44
+
45
+ - 设置页切换语言后立即生效。
46
+ - agent 安装、卸载前的审批提示,以及 agent 收到的提示,也使用同一种语言。浏览器端会把 dsh 当前的界面语言告诉插件,插件把它保存下来,dsh 重启后仍然沿用。
47
+ - 只有从来没有打开过 dsh 网页界面时(例如纯命令行运行),才临时使用电脑的系统语言。
48
+
41
49
  ## 防火墙
42
50
 
43
51
  安装时插件会检测 VPS 上启用的防火墙,自动放行所选协议的端口(使用 IP 证书时还有 TCP 80):
@@ -76,7 +84,7 @@ IP 证书由 sing-box 1.14 内置的 ACME 向 Let's Encrypt 申请([2026 年
76
84
  | 接口鉴权 | 设置页的接口挂在 dsh 自己的 `/api` 下,复用 dsh 的登录 cookie 和防跨站、防 DNS rebinding 检查 |
77
85
  | 主机指纹 | 首次连接时记录 SSH 主机指纹,之后指纹变化就拒绝连接,防止中间人截获密码 |
78
86
  | sudo | 非 root 用户用 `sudo -S`:密码只经 stdin 传入,不会出现在命令行,也不会传给脚本 |
79
- | agent 操作 | agent 安装或卸载前会弹出审批,提示语言跟随电脑系统语言,并写明目标服务器、协议,以及是否重新生成密钥 |
87
+ | agent 操作 | agent 安装或卸载前会弹出审批,写明目标服务器、协议,以及是否重新生成密钥 |
80
88
  | 证书 | 推荐用 IP 证书,所有协议都正常校验;自签模式下 Hysteria2 锁定指纹,TUIC 和 AnyTLS 会标注“需跳过证书校验” |
81
89
 
82
90
  ## 组成
package/lib/client.js CHANGED
@@ -55,7 +55,9 @@ var vpnApi = {
55
55
  status: () => post("/status"),
56
56
  links: () => post("/links"),
57
57
  install: (regenerate) => post("/install", { regenerate }),
58
- uninstall: () => post("/uninstall")
58
+ uninstall: () => post("/uninstall"),
59
+ /** Tell the host which language dsh's UI shows (approval prompts follow it). */
60
+ reportUiLocale: (locale) => post("/ui-locale", { locale })
59
61
  };
60
62
 
61
63
  // src/client/locales.ts
@@ -1400,6 +1402,17 @@ function apply(ctx) {
1400
1402
  style.remove();
1401
1403
  };
1402
1404
  }, "dsh-vpn: styles");
1405
+ const reportLocale = (locale, attempt = 0) => {
1406
+ void vpnApi.reportUiLocale(locale).then((r) => {
1407
+ if (!r.ok && attempt < 3) setTimeout(() => {
1408
+ reportLocale(locale, attempt + 1);
1409
+ }, 2e3 * (attempt + 1));
1410
+ });
1411
+ };
1412
+ reportLocale(ctx.locale.getSnapshot().active);
1413
+ ctx.on("locale/change", (snapshot) => {
1414
+ reportLocale(snapshot.active);
1415
+ });
1403
1416
  const t = ctx.locale.bind(NS);
1404
1417
  ctx.slots.inject("settings.section", () => ctx.slots.register({
1405
1418
  name: "settings.section",
package/lib/index.js CHANGED
@@ -20264,8 +20264,13 @@ function json(status, value) {
20264
20264
  headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
20265
20265
  });
20266
20266
  }
20267
- function apiHandlers(service) {
20267
+ function apiHandlers(service, uiLang, onUiLang) {
20268
20268
  return {
20269
+ "/ui-locale": async (body) => {
20270
+ if (typeof body.locale !== "string" || body.locale.length > 35) return { ok: false, error: "bad_request", message: "locale must be a string" };
20271
+ if (uiLang?.set(body.locale)) onUiLang?.();
20272
+ return { ok: true, lang: uiLang?.get() };
20273
+ },
20269
20274
  "/settings/get": async () => ({ ok: true, settings: publicView(await service.store.get()) }),
20270
20275
  "/settings/save": async (body) => {
20271
20276
  let saved;
@@ -20305,8 +20310,8 @@ async function handle(handler, request) {
20305
20310
  return json(500, { ok: false, error: "internal", message: err instanceof Error ? err.message : String(err) });
20306
20311
  }
20307
20312
  }
20308
- function registerApi(ctx, service) {
20309
- for (const [path, handler] of Object.entries(apiHandlers(service))) {
20313
+ function registerApi(ctx, service, uiLang, onUiLang) {
20314
+ for (const [path, handler] of Object.entries(apiHandlers(service, uiLang, onUiLang))) {
20310
20315
  ctx.effect(() => ctx.connection.fetch.register({
20311
20316
  path: `${API_BASE}${path}`,
20312
20317
  methods: ["POST"],
@@ -20672,6 +20677,15 @@ var CredentialSettingsStore = class {
20672
20677
  });
20673
20678
  }
20674
20679
  };
20680
+ var UI_LANG_KEY = credentialKey("dsh-vpn", "ui-locale");
20681
+ async function readUiLang(ctx) {
20682
+ const record = await ctx.credentials.readRecord(UI_LANG_KEY);
20683
+ const lang = record?.kind === "grant" ? record.payload?.lang : void 0;
20684
+ return lang === "zh" || lang === "en" ? lang : void 0;
20685
+ }
20686
+ async function writeUiLang(ctx, lang) {
20687
+ await ctx.credentials.modifyRecord(UI_LANG_KEY, async () => ({ kind: "grant", payload: { lang } }));
20688
+ }
20675
20689
 
20676
20690
  // src/i18n.ts
20677
20691
  import { execFileSync } from "node:child_process";
@@ -20696,6 +20710,26 @@ var cached;
20696
20710
  function osLang() {
20697
20711
  return cached ??= detectOsLang();
20698
20712
  }
20713
+ var UiLang = class {
20714
+ current;
20715
+ constructor(initial) {
20716
+ this.current = initial;
20717
+ }
20718
+ /** @returns true when the value changed (worth persisting). */
20719
+ set(tag) {
20720
+ const next = fromTag(tag) ?? "en";
20721
+ if (next === this.current) return false;
20722
+ this.current = next;
20723
+ return true;
20724
+ }
20725
+ get() {
20726
+ return this.current ?? osLang();
20727
+ }
20728
+ /** Explicitly reported (not the OS fallback). */
20729
+ get reported() {
20730
+ return this.current;
20731
+ }
20732
+ };
20699
20733
  var protocolNames = (ids, sep) => ids.map((id) => PROTOCOLS.find((p) => p.id === id)?.name ?? id).join(sep);
20700
20734
  var TLS_PROTOCOLS = ["hy2", "tuic", "anytls"];
20701
20735
  function installPrompt(lang, p) {
@@ -20738,8 +20772,8 @@ var OUTPUT = {
20738
20772
  ].join("\n")
20739
20773
  }]
20740
20774
  };
20741
- function toToolValue(r) {
20742
- const message = r.error === "not_configured" ? notConfiguredMessage(osLang()) : r.message;
20775
+ function toToolValue(r, uiLang) {
20776
+ const message = r.error === "not_configured" ? notConfiguredMessage(uiLang.get()) : r.message;
20743
20777
  return {
20744
20778
  ok: r.ok,
20745
20779
  ...r.error ? { error: r.error } : {},
@@ -20749,6 +20783,16 @@ function toToolValue(r) {
20749
20783
  }
20750
20784
  function apply(ctx, config) {
20751
20785
  const service = new VpnService(new CredentialSettingsStore(ctx), { timeoutMs: config.timeoutMs, acmeProvider: config.acmeProvider });
20786
+ const uiLang = new UiLang();
20787
+ readUiLang(ctx).then((lang) => {
20788
+ if (lang && !uiLang.reported) uiLang.set(lang);
20789
+ }, () => {
20790
+ });
20791
+ const persistUiLang = () => {
20792
+ const lang = uiLang.reported;
20793
+ if (lang) writeUiLang(ctx, lang).catch(() => {
20794
+ });
20795
+ };
20752
20796
  ctx.tools.register(defineTool({
20753
20797
  name: "vpn_install",
20754
20798
  description: `Install or reconfigure sing-box on the VPS configured in dsh Settings \u2192 VPN, then return share links. Opens the ports in the VPS firewall automatically (result.firewall). Idempotent: rerunning keeps existing keys and ports unless \`regenerate\` is set. Protocols: ${PROTOCOL_IDS.join(", ")}; omit to use the ones selected in settings.`,
@@ -20762,7 +20806,7 @@ function apply(ctx, config) {
20762
20806
  },
20763
20807
  output: OUTPUT,
20764
20808
  async execute(args, exec) {
20765
- return toToolValue(await service.install({ protocols: args.protocols, regenerate: args.regenerate }, exec.signal));
20809
+ return toToolValue(await service.install({ protocols: args.protocols, regenerate: args.regenerate }, exec.signal), uiLang);
20766
20810
  }
20767
20811
  }));
20768
20812
  ctx.tools.register(defineTool({
@@ -20770,21 +20814,21 @@ function apply(ctx, config) {
20770
20814
  description: "Report whether sing-box is installed, running and listening on the configured VPS.",
20771
20815
  parameters: {},
20772
20816
  output: OUTPUT,
20773
- execute: async (_args, exec) => toToolValue(await service.status(exec.signal))
20817
+ execute: async (_args, exec) => toToolValue(await service.status(exec.signal), uiLang)
20774
20818
  }));
20775
20819
  ctx.tools.register(defineTool({
20776
20820
  name: "vpn_links",
20777
20821
  description: "Return the share links (vless://, hysteria2://, \u2026) of the installed server. Tell the user they can scan QR codes in dsh Settings \u2192 VPN (\u5237\u65B0\u4E8C\u7EF4\u7801).",
20778
20822
  parameters: {},
20779
20823
  output: OUTPUT,
20780
- execute: async (_args, exec) => toToolValue(await service.links(exec.signal))
20824
+ execute: async (_args, exec) => toToolValue(await service.links(exec.signal), uiLang)
20781
20825
  }));
20782
20826
  ctx.tools.register(defineTool({
20783
20827
  name: "vpn_uninstall",
20784
20828
  description: "Stop and remove sing-box, its config and credentials from the configured VPS.",
20785
20829
  parameters: {},
20786
20830
  output: OUTPUT,
20787
- execute: async (_args, exec) => toToolValue(await service.uninstall(exec.signal))
20831
+ execute: async (_args, exec) => toToolValue(await service.uninstall(exec.signal), uiLang)
20788
20832
  }));
20789
20833
  if (config.confirm) {
20790
20834
  ctx.on("tools/pre-execute", async (exec, next) => {
@@ -20792,7 +20836,7 @@ function apply(ctx, config) {
20792
20836
  if (decision.kind !== "allow" || exec.name !== "vpn_install" && exec.name !== "vpn_uninstall") return decision;
20793
20837
  const s = await service.store.get();
20794
20838
  if (!s) return decision;
20795
- const lang = osLang();
20839
+ const lang = uiLang.get();
20796
20840
  const target = `${s.host}:${s.port}`;
20797
20841
  if (exec.name === "vpn_uninstall") return { kind: "ask", reason: uninstallPrompt(lang, target) };
20798
20842
  const args = exec.arguments;
@@ -20809,7 +20853,7 @@ function apply(ctx, config) {
20809
20853
  });
20810
20854
  }
20811
20855
  ctx.inject(["connection"], (webCtx) => {
20812
- registerApi(webCtx, service);
20856
+ registerApi(webCtx, service, uiLang, persistUiLang);
20813
20857
  });
20814
20858
  }
20815
20859
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vpn",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",