dsh-mobile 0.5.4 → 0.6.0

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.
@@ -2,33 +2,83 @@
2
2
 
3
3
  [中文指南](SELF_HOSTED_ORIGIN.md)
4
4
 
5
- > This provider ships with the package from **0.4.2** on.
5
+ > This provider ships from **0.4.2** onward.
6
6
 
7
- This provider appears under **Mobile Access → Remote → Self-hosted connection → Own reverse proxy**. It adds a separate authenticated private HTTP origin for an HTTPS reverse proxy you already own; it does not install a tunnel or manage your proxy, DNS, certificate, firewall, or router.
7
+ ## When to use it
8
8
 
9
- ## Setup
9
+ Use an existing Lucky, Nginx or Caddy HTTPS proxy rather than a tunnel. Under **Mobile Access → Remote → Self-hosted connection → Own reverse proxy**, DSH Mobile creates a separate private HTTP backend with device authentication:
10
10
 
11
- - Public origin: `https://phone.example.com:8815` (HTTPS only, optional custom port, no path/query/fragment/credentials).
12
- - Same computer: listen on `127.0.0.1:3444`, allow `127.0.0.0/8`.
13
- - Separate LAN proxy: listen on the DSH computer's private IPv4, e.g. `192.168.50.10:3444`, and allow the proxy's actual direct source, e.g. `192.168.50.1/32`.
14
- - Only explicit loopback/RFC1918 IPv4 binds are supported. Wildcard, public and IPv6 binds are rejected. The port must be 1024–65535 and may not be 3443 (the LAN gateway) or 3080 (DSH's own WebServer); note that 3444 is also the cloudflared named tunnel's default forward port. Source CIDRs must be canonical private/loopback networks, at most 16; prefer a single-host /32. Forwarded headers do not determine source authorization.
11
+ ```text
12
+ phone → https://phone.example.com:8815 (your proxy terminates TLS)
13
+ → http://192.168.50.10:3444 (private Mobile backend)
14
+ → current DSH WebServer
15
+ ```
15
16
 
16
- Select **Save and start backend**, then point the proxy at the displayed HTTP backend. **Never expose/port-forward that HTTP listener publicly**, and never bypass it by proxying to DSH's WebServer (default port 3080) or the LAN 3443 gateway. HTTP between separate devices is suitable only for a trusted private network. Pairing, authentication, normalized-exact Host/Origin enforcement, CSRF, Secure cookies and WebSocket path policy remain active. Do not open the plain HTTP backend in a browser to verify it: it only accepts requests proxied from the configured public HTTPS origin; direct access has the wrong Host, and its cookie/CSRF flow may fail.
17
+ Only the public HTTPS proxy may face the Internet. **Never port-forward the HTTP backend**, proxy directly to ordinary DSH (default 3080), or reuse the LAN HTTPS gateway (default 3443). HTTP between separate machines is suitable only for a trusted private network.
17
18
 
18
- ## Proxy contract
19
+ The backend still enforces device pairing, exact normalized Host/Origin, CSRF, Secure Cookies and WebSocket path policy. Do not test it by opening the private HTTP address in a browser: that has the wrong Host and may break authentication. The operator owns public TLS, renewal, DNS, routing and firewall rules; the plugin does not configure or probe the public route.
19
20
 
20
- Terminate public TLS with a trusted certificate on your proxy. Preserve the external **Host including the port** (`phone.example.com:8815`), Origin, cookies and authentication/CSRF headers; do not rewrite cookie security attributes or cache authentication responses. Forward WebSocket upgrades and bidirectional traffic.
21
+ ## Four settings
21
22
 
22
- [Lucky's Web module](https://lucky666.cn/docs/modules/web) supports WebSocket by default. Use **“使用请求Host” (request Host)** rather than **“使用目标地址Host” (target-address Host)**, and verify the external custom port survives. Certificates remain Lucky's responsibility. If the proxy is on another machine, configure routing and a source-restricted private firewall rule yourself; the plugin does not change either.
23
+ Replace these examples with your own addresses:
23
24
 
24
- ## Readiness, pairing and persistence
25
+ | Field | Proxy on the DSH computer | Proxy on another private-network host |
26
+ | --- | --- | --- |
27
+ | Public HTTPS origin | `https://phone.example.com:8815` | `https://phone.example.com:8815` |
28
+ | Private listen IPv4 | `127.0.0.1` | DSH computer's address, e.g. `192.168.50.10` |
29
+ | HTTP backend port | `3444` | `3444` |
30
+ | Allowed proxy source CIDR | `127.0.0.0/8` | Actual proxy source, e.g. `192.168.50.1/32` |
25
31
 
26
- **Backend listening is not public readiness.** The provider and diagnostics make no public probe: check your domain, certificate, public port, pairing and WebSocket from the phone's intended external network. Use the existing Android **Remote access** QR flow on app **0.4.0 or later** for user-owned domains and custom HTTPS ports. App 0.3.16 rejects these pairings; no APK change is required for this provider. Local real-socket HTTPS/HTTP/WebSocket tests are not a substitute for testing your own proxy and phone.
32
+ The public origin must be HTTPS and may include a custom port, but no path, credentials, query or fragment. Private/reserved public addresses, local names and IPv6 literals are rejected by this provider.
27
33
 
28
- Stopping retains settings and paired devices. **Clear proxy settings** stops only this listener and deletes only its configuration, after confirmation. The separate existing **reset remote devices** action removes shared remote pairings, but keeps proxy settings and LAN devices. Switching providers stops the previous remote provider without affecting LAN.
34
+ Bind to an explicit loopback or RFC1918 IPv4 already assigned to the DSH computer, not `0.0.0.0`, a hostname, public IP or IPv6. Choose an unused integer port from 1024–65535, excluding reserved 3080 and 3443. Default 3444 is also the named-cloudflared forward port: only one remote provider runs at a time, but verify the port is released during a switch.
29
35
 
30
- Under the default `$DSH_HOME/mobile-access/` root, settings live at `remote/origin/config/settings.json`, enabled state at `remote/origin/control.json`, and pairings continue to use `remote/devices.json`. Only the selected, enabled provider resumes on DSH restart. All addresses shown here are examples; supply your own configuration.
36
+ CIDRs match the proxy's **direct TCP peer**, not the phone or `X-Forwarded-For` / `X-Real-IP` / `Forwarded`. Docker or NAT may change that peer. Prefer the actual single-host /32, not an entire subnet. Up to 16 canonical private/loopback CIDRs are accepted, separated by spaces or commas; explicit private proxy sources are required for a LAN bind.
31
37
 
32
- ## More detail
38
+ Select **Save and start backend**. An active backend reloads its settings. The panel separates the public HTTPS origin from the actual listening HTTP backend; saved settings remain visible after stopping.
33
39
 
34
- The [Chinese guide](SELF_HOSTED_ORIGIN.md) carries the full field-by-field table, the Lucky checklist and a troubleshooting list; this English page is the condensed version.
40
+ ## Proxy requirements, including Lucky
41
+
42
+ 1. Terminate public TLS with a trusted certificate. Never ask the phone to ignore a public certificate error.
43
+ 2. Point the proxy at the displayed private HTTP backend, not the phone's public HTTPS address.
44
+ 3. Preserve the external **Host including non-default port**: the example requires `phone.example.com:8815`, not the private target or a hostname without its port.
45
+ 4. Preserve Origin, Cookie, Set-Cookie and authentication/CSRF headers. Do not rewrite Cookie domain/security attributes or cache authentication responses.
46
+ 5. Forward HTTP/1.1 WebSocket upgrades and bidirectional data. Approve any required third-party WebSocket paths separately in diagnostics.
47
+
48
+ [Lucky's official Web documentation](https://www.lucky666.cn/docs/modules/web/) describes WebSocket support. UI labels vary by version, but this backend requires the incoming external Host. Do not enable target-address Host; if a version offers custom Host, provide the full public hostname and port. Certificates belong on Lucky, not this HTTP backend.
49
+
50
+ For a separate proxy host, configure routing and a firewall rule limited to its actual private source yourself. This provider does not change the firewall.
51
+
52
+ ## State and phone verification
53
+
54
+ **Backend listening proves the local listener only, not public HTTPS, certificates or WebSocket connectivity.** This provider's diagnostics do not probe the public origin.
55
+
56
+ Generate a remote pairing QR code and use **Remote access** in Android app **0.4.0 or later**, not Local network. A publicly trusted origin also supports mobile-browser pairing. Verify from the intended external network:
57
+
58
+ - The hostname, public port and trusted certificate are correct.
59
+ - Pairing and DSH login succeed.
60
+ - A live conversation or other real-time operation keeps WebSockets working.
61
+ - Stopping the backend disables this remote entry without affecting LAN access.
62
+
63
+ Local real-socket HTTPS/HTTP/WebSocket tests do not replace your own proxy and phone acceptance.
64
+
65
+ ## Stop, remove settings or reset devices
66
+
67
+ | Action | Result |
68
+ | --- | --- |
69
+ | Disable remote access | Stops the backend; keeps settings and paired devices |
70
+ | Clear proxy settings, after confirmation | Stops this backend and deletes its settings; keeps remote pairings, LAN and other provider settings |
71
+ | Reset remote devices, after confirmation | Clears shared remote authorizations; remote devices must pair again, while proxy settings and LAN devices remain |
72
+ | Switch provider | Stops the previous remote provider; LAN is unaffected |
73
+
74
+ Under the default `$DSH_HOME/mobile-access/` root, settings are in `remote/origin/config/settings.json`, enabled state in `remote/origin/control.json`, and remote devices in shared `remote/devices.json`. Normal shutdown retains settings; only the selected, enabled provider resumes after DSH restarts.
75
+
76
+ ## Troubleshooting
77
+
78
+ | Symptom | First check |
79
+ | --- | --- |
80
+ | Port occupied | Choose an unused backend port and update the proxy target |
81
+ | Listen address unavailable | Use a private IPv4 assigned to the DSH computer, not the proxy host |
82
+ | Refused / HTTP 403 | Verify direct source CIDR, full external Host and HTTPS Origin; forwarded headers cannot bypass them |
83
+ | Page works but real-time operations fail | Check WebSocket forwarding, timeout and approved paths without disabling trust checks |
84
+ | Only Backend listening | Expected local state; verify public DNS, HTTPS routing, certificate and actual phone access |
@@ -46,7 +46,7 @@ CIDR 限制针对 TCP 连接的**直接来源地址**,不是手机 IP,也不
46
46
  4. 透传浏览器的 Origin、Cookie、Set-Cookie,以及认证/CSRF 相关头,不要改写 Cookie 的域或安全属性;不要缓存配对或认证响应。
47
47
  5. 透传 WebSocket 升级(HTTP/1.1、Upgrade、Connection)以及双向数据。已有的 WebSocket 路径白名单继续生效;第三方插件需要时仍在现有面板中单独允许其路径。
48
48
 
49
- 根据 [Lucky 官方 Web 模块文档](https://lucky666.cn/docs/modules/web),WebSocket 默认支持,不必寻找单独的开启开关。Host 自定义模式应使用 **“使用请求Host”**,不要选 **“使用目标地址Host”**,并核对非默认端口没有丢失。TLS/证书仍配置在 Lucky,不在这个 HTTP 后端上配置。
49
+ [Lucky 官方 Web 文档](https://www.lucky666.cn/docs/modules/web/)说明其支持 WebSocket。配置界面随版本变化,但本插件要求不变:保留外部请求 Host,不要启用“使用目标地址Host”;若版本提供自定义 Host,请填写完整公网主机名及端口。TLS/证书配置在 Lucky,不在这个 HTTP 后端上配置。
50
50
 
51
51
  若代理在另一台机器上,还需由管理员保证网络可达,并将主机防火墙入站范围限制到代理的实际私网来源;此功能不自动改防火墙。
52
52
 
@@ -75,3 +75,19 @@ These optional fields sit alongside `paths`; their defaults normally suffice:
75
75
  | `level` | `3` | Compression level (0–9); higher values may use more CPU |
76
76
 
77
77
  Restart DSH and reopen the phone page after a change. Developer tools can show whether the WebSocket handshake negotiated `permessage-deflate`; a client that does not negotiate it can still connect without compression. Compare actual channel transfer volume and CPU use for the same Session, rather than judging savings from the decompressed message lengths shown in developer tools. Set `paths` back to `[]` to disable it. Compression does not raise provider quotas or replace connection troubleshooting.
78
+
79
+ ## Manual compaction and long-running API requests (0.6.0)
80
+
81
+ Commands such as `/compact` sent from the phone traverse the Mobile gateway's HTTP API. Automatic compaction inside DSH does not traverse that gateway request. Older gateways used the default 30-second `upstreamTimeoutMs` for every proxied request, so manual compaction could return `502 upstream_unavailable` while the model was still producing the summary. [#171](https://github.com/saya-ch/dsh-mobile/issues/171) records this case.
82
+
83
+ The gateway now separates transport waits from authenticated API response waits. `upstreamTimeoutMs` still defaults to 30000 milliseconds (1000–300000) for uploads, static assets, boot resources, and WebSocket handshakes; incoming request headers retain their existing 10-second deadline. Only `/api` and `/api/…` HTTP requests that pass device authentication, Host/Origin checks, and any required CSRF check switch to `upstreamApiTimeoutMs` after the request body is sent to DSH. The new field defaults to `0`, disabling the API response idle timeout. To set a limit, use an integer from `1` through `2147483647` milliseconds; the upper limit is Node's timer representation limit. This is not a total execution deadline: arriving response data resets the idle timer.
84
+
85
+ Add the property below to the existing `mobile-access` entry's `config` and keep all other settings. This is a configuration fragment, not a complete profile patch. `0` is the default; this example sets a 10-minute API response idle timeout:
86
+
87
+ ```yaml
88
+ upstreamApiTimeoutMs: 600000
89
+ ```
90
+
91
+ Save and restart DSH. Device/Session expiry, revocation, caller disconnection, and gateway shutdown still abort the proxy request. Setting `0` does not disable upload sizes, upload deadlines, connection counts, concurrent-request limits, or CSRF checks. A tunnel, reverse proxy, or model provider may still end the request sooner. This field does not change the WebSocket heartbeat or initialization settings described above.
92
+
93
+ For ordinary HTTP proxy requests, the Mobile gateway's own upstream timeout returns `504 {"error":"upstream_timeout"}` before response headers are sent; ordinary connection failures remain `502 upstream_unavailable`. Once a stream has started, the gateway can only close the connection, not send a second HTTP status. It never automatically retries API requests. A timeout or disconnect means the caller stopped waiting; it does not guarantee that DSH or the model task stopped, and it does not reverse prior side effects. Check the actual Session result before retrying an operation.
@@ -75,3 +75,19 @@ websocketCompression:
75
75
  | `level` | `3` | 压缩等级(0–9);提高可能增加 CPU 开销 |
76
76
 
77
77
  修改后重启 DSH 并重新打开手机页面。可在开发者工具中查看 WebSocket 握手是否协商了 `permessage-deflate`;客户端没有协商时仍可无压缩连接。对比同一会话的实际通道传输量与 CPU 使用,不能仅以开发者工具显示的解压后消息长度判断节省比例。需要关闭时把 `paths` 改回 `[]`;压缩不能提高服务商限额,也不能替代连接故障排查。
78
+
79
+ ## 手动压缩会话与长时间 API 请求(0.6.0)
80
+
81
+ 手机发出的 `/compact` 等命令要通过移动网关的 HTTP API;DSH 内部触发的自动压缩不经过这次网关请求。旧版网关对所有上游代理请求都使用默认 30 秒的 `upstreamTimeoutMs`,因此模型仍在生成摘要时,手动压缩可能先收到 `502 upstream_unavailable`。[#171](https://github.com/saya-ch/dsh-mobile/issues/171) 记录了这一情况。
82
+
83
+ 当前网关区分传输等待和已鉴权 API 的响应等待。`upstreamTimeoutMs` 仍默认 30000 毫秒(1000–300000),用于上传、静态资源、启动资源和 WebSocket 握手的等待;请求头另有既有的 10 秒接收期限。只有完成设备鉴权、Host/Origin 与必要 CSRF 检查的 `/api` 和 `/api/…` HTTP 请求,在请求体发往 DSH 后切换到 `upstreamApiTimeoutMs`。新字段默认 `0`,表示不对 API 响应施加空闲超时;需要限制时,可配置为 `1–2147483647` 的整数毫秒,范围上限来自 Node 定时器的表示范围。这不是整个操作的总执行期限:持续到达的响应数据会重置空闲计时。
84
+
85
+ 以下属性放进现有 `mobile-access` 条目的 `config`,并保留其他配置;这是配置片段,不是完整的 profile 补丁。`0` 是默认值,示例为 API 响应设置 10 分钟的空闲等待期限:
86
+
87
+ ```yaml
88
+ upstreamApiTimeoutMs: 600000
89
+ ```
90
+
91
+ 保存后重启 DSH。设备/会话失效、撤销、调用方断开或网关关闭仍会中止代理请求;上传大小、上传等待、连接数、并发请求数和 CSRF 限制不因 `0` 而关闭。隧道、反向代理或模型服务商自己的期限仍可能提前结束请求;该字段也不会改变前文的 WebSocket 心跳或初始化设置。
92
+
93
+ 普通 HTTP 代理请求在尚未发送响应头时,移动网关自己的上游超时返回 `504 {"error":"upstream_timeout"}`;一般连接失败仍为 `502 upstream_unavailable`。若流已经开始,则只能关闭连接,不能再发送第二份 HTTP 状态。网关不会自动重试 API 请求。超时或断开仅表示调用方停止等待,不保证 DSH 或模型任务已经停止,也不会撤销已经发生的副作用;重试操作前先检查实际会话结果。
@@ -0,0 +1,26 @@
1
+ # Additional trusted networks
2
+
3
+ [中文](TRUSTED_NETWORKS.md) · [Back to the LAN guide](../README.en.md#local-network)
4
+
5
+ > DSH Mobile 0.6.0 provides this computer-side advanced control. Update the plugin and app together.
6
+
7
+ Default LAN setup permits the LAN source subnet of the selected interface. Explicitly append source CIDRs when an existing routed network you control, such as WireGuard, needs access. This does not create a VPN, route, IPv6 listener, or public entry.
8
+
9
+ ## Configure on the computer
10
+
11
+ 1. Open **Mobile Access → LAN → Additional trusted networks · Advanced**. This control supports only LAN setup managed by DSH Mobile. Failed or unsupported reads leave saving disabled.
12
+ 2. Enter controlled IPv4 / IPv6 CIDRs, one per line or separated by spaces or commas. Examples such as `10.80.0.0/24` and `fd12:3456:789a::/64` show the format; replace them with your actual networks.
13
+ 3. Select **Save networks**, then restart DSH to apply. Saving does not interrupt current connections or replace the selected interface, listen port, certificates, or pairing identity.
14
+ 4. Check routing and firewall access from that network, then connect through the normal HTTPS pairing flow.
15
+
16
+ The current LAN subnet is always combined with the extra list. When Wi-Fi, hotspot, or address changes, the default part follows the selected interface; explicitly saved extras remain. Clearing the extra list restores current-LAN-only access without deleting paired devices or remote settings.
17
+
18
+ CIDRs match the direct TCP source address, not `X-Forwarded-For`. Allowing a source subnet does not permit every network or bypass HTTPS, device pairing, exact Host / Origin, or CSRF checks. Add only the ranges actually needed, rather than allowing the entire internet to diagnose a connection.
19
+
20
+ ## Windows firewall is separate
21
+
22
+ DSH Mobile's default Windows HTTPS / Discovery inbound rules allow only `LocalSubnet`. Saving extra CIDRs **does not update or broaden those rules**. You or your administrator must separately allow the controlled sources to reach the actual Mobile HTTPS port in Windows firewall settings.
23
+
24
+ A routed network may not support LAN broadcast discovery. If the computer is not discovered, use its displayed pairing link or QR code; routing, firewall access, and the HTTPS entry must still work. Adding an IPv6 CIDR does not create an IPv6 listener.
25
+
26
+ This setting controls source access to the LAN Gateway, not source CIDRs for an external reverse proxy. Configure that separately with the [own HTTPS reverse proxy guide](SELF_HOSTED_ORIGIN.en.md). Do not expose the LAN Gateway or private HTTP backend directly to the public internet.
@@ -0,0 +1,26 @@
1
+ # 额外可信网段
2
+
3
+ [English](TRUSTED_NETWORKS.en.md) · [返回局域网指南](../README.md#局域网访问)
4
+
5
+ > DSH Mobile 0.6.0 提供电脑端高级设置;请同步更新插件与 App。
6
+
7
+ 默认局域网设置只允许当前所选网卡的 LAN 来源网段。已有受控的路由网络(例如 WireGuard 网段)需要连接时,可明确追加来源 CIDR;这不会自动建立 VPN、路由、IPv6 监听或公网入口。
8
+
9
+ ## 电脑端设置
10
+
11
+ 1. 打开 **移动访问 → 局域网 → 额外可信网段 · 高级**。此入口仅支持由 DSH Mobile 管理的局域网配置;无法读取或不支持时不能保存。
12
+ 2. 填入你确认可控的 IPv4 / IPv6 CIDR,每行一个,也可用空格或逗号分隔。例如 `10.80.0.0/24` 或 `fd12:3456:789a::/64`;这些只是格式示例,应替换为实际网络。
13
+ 3. 点击 **保存网段**,然后重启 DSH 生效。保存不会立即中断当前连接,也不会替换已选网卡、监听端口、证书或配对身份。
14
+ 4. 从该网络检查路由和防火墙,再用正常 HTTPS 配对流程连接。
15
+
16
+ 当前 LAN 网段始终与额外列表合并;切换 Wi-Fi、热点或地址后,默认部分继续跟随已选网卡,明确保存的额外部分保持不变。清空额外列表可回到仅允许当前 LAN 网段,不删除配对设备或远程设置。
17
+
18
+ CIDR 按直接 TCP 来源地址匹配,不依赖 `X-Forwarded-For`。允许一个来源网段不等于允许所有网络,也不跳过 HTTPS、设备配对、精确 Host / Origin 或 CSRF 检查。只追加实际需要的范围,不要为了排错直接放行整个互联网。
19
+
20
+ ## Windows 防火墙是另一项设置
21
+
22
+ DSH Mobile 的默认 Windows HTTPS / Discovery 入站规则只允许 `LocalSubnet`。保存额外 CIDR **不会修改或扩大这些规则**;需要由你或系统管理员在 Windows 防火墙中单独允许受控来源访问实际 Mobile HTTPS 端口。
23
+
24
+ 路由网络不一定支持局域网广播发现。发现列表没有电脑时,可使用电脑显示的配对链接或二维码,但路由、防火墙和 HTTPS 入口仍须可达。填写 IPv6 CIDR 也不会自动增加 IPv6 监听。
25
+
26
+ 此设置用于局域网 Gateway 的来源访问范围,不改变外部反向代理的来源 CIDR。自有代理请按[自有 HTTPS 反向代理指南](SELF_HOSTED_ORIGIN.md)配置;不要把局域网 Gateway 或私有 HTTP 后端映射到公网。
package/lib/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir, networkInterfaces } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
5
- import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
5
+ import { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
6
6
  import { execFile } from "node:child_process";
7
7
  import { generate } from "selfsigned";
8
8
  import "@deepseek-ai/cordis";
@@ -81,6 +81,26 @@ async function restrictPrivateFile(file, mode = 384) {
81
81
  timeout: 1e4
82
82
  });
83
83
  }
84
+ Object.freeze([
85
+ [0n, 8],
86
+ [167772160n, 8],
87
+ [1681915904n, 10],
88
+ [2130706432n, 8],
89
+ [2851995648n, 16],
90
+ [2886729728n, 12],
91
+ [3221225472n, 24],
92
+ [3221225984n, 24],
93
+ [3223307264n, 24],
94
+ [3224682752n, 24],
95
+ [3227017984n, 24],
96
+ [3232235520n, 16],
97
+ [3232706560n, 24],
98
+ [3323068416n, 15],
99
+ [3325256704n, 24],
100
+ [3405803776n, 24],
101
+ [3758096384n, 4],
102
+ [4026531840n, 4]
103
+ ]);
84
104
  //#endregion
85
105
  //#region src/managed-setup.ts
86
106
  const VIRTUAL_INTERFACE_MARKERS = [
@@ -229,10 +249,18 @@ async function atomicWrite(file, contents) {
229
249
  recursive: true,
230
250
  mode: 448
231
251
  });
232
- const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
233
- await writeFile(temporary, contents, { mode: 384 });
234
- await rename(temporary, file);
235
- await restrictPrivateFile(file);
252
+ const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
253
+ try {
254
+ await writeFile(temporary, contents, {
255
+ mode: 384,
256
+ flag: "wx"
257
+ });
258
+ await restrictPrivateFile(temporary);
259
+ await rename(temporary, file);
260
+ await restrictPrivateFile(file);
261
+ } finally {
262
+ await rm(temporary, { force: true });
263
+ }
236
264
  }
237
265
  /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
238
266
  async function ensureManagedCa(setup, legacy) {