pi-webdav-backup 1.0.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.
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/extensions/webdav-backup/audit-real.mjs +84 -0
- package/extensions/webdav-backup/auth-migrate.mjs +242 -0
- package/extensions/webdav-backup/backup.mjs +684 -0
- package/extensions/webdav-backup/cli.mjs +352 -0
- package/extensions/webdav-backup/collect.mjs +125 -0
- package/extensions/webdav-backup/config-paths.mjs +32 -0
- package/extensions/webdav-backup/config.mjs +199 -0
- package/extensions/webdav-backup/cron.mjs +90 -0
- package/extensions/webdav-backup/crypto.mjs +163 -0
- package/extensions/webdav-backup/dav-mock.mjs +145 -0
- package/extensions/webdav-backup/migrate.mjs +167 -0
- package/extensions/webdav-backup/restore.mjs +220 -0
- package/extensions/webdav-backup/run-tests.mjs +41 -0
- package/extensions/webdav-backup/secrets.mjs +404 -0
- package/extensions/webdav-backup/tar.mjs +111 -0
- package/extensions/webdav-backup/test-auth-migrate.mjs +187 -0
- package/extensions/webdav-backup/test-cli.mjs +331 -0
- package/extensions/webdav-backup/test-redact.mjs +271 -0
- package/extensions/webdav-backup/test-restore.mjs +219 -0
- package/extensions/webdav-backup/test-secrets.mjs +268 -0
- package/extensions/webdav-backup/test.mjs +442 -0
- package/extensions/webdav-backup/verify-load.mjs +89 -0
- package/extensions/webdav-backup/webdav-backup.example.json +38 -0
- package/extensions/webdav-backup/webdav.mjs +218 -0
- package/extensions/webdav-backup.ts +766 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ddldxiacan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# pi WebDAV 备份扩展
|
|
2
|
+
|
|
3
|
+
把 pi 的**插件、扩展、模型配置**自动备份到任意 WebDAV 服务(坚果云 / Nextcloud / 群晖 / 自建 rclone)。
|
|
4
|
+
|
|
5
|
+
零第三方依赖,只用 Node 内置模块。密钥默认不明文落盘(DPAPI / 环境变量 / 命令)。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
一行命令(pi 官方包机制):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install git:github.com/ddldxiacan/pi-webdav-backup@v1
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
只想试一下、不安装(仅本次运行生效):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi -e git:github.com/ddldxiacan/pi-webdav-backup
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
其他方式:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:pi-webdav-backup # 已发布到 npm 时
|
|
27
|
+
pi install /path/to/pi-webdav-backup # 本地目录(下载 ZIP 解压后)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
手动安装:把 `extensions/webdav-backup.ts` 与 `extensions/webdav-backup/` 拷到 `~/.pi/agent/extensions/`。
|
|
31
|
+
|
|
32
|
+
装好后在 pi 里运行 **`/reload`** 加载。
|
|
33
|
+
|
|
34
|
+
> 本包带 `pi-package` 关键字,会出现在 [pi.dev 包画廊](https://pi.dev/packages)。
|
|
35
|
+
|
|
36
|
+
运行时产生的文件:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
~/.pi/agent/webdav-backup.json ← 备份配置(/backup-setup 生成)
|
|
40
|
+
~/.pi/agent/secrets.dpapi.json ← DPAPI 加密的密钥(自动创建,0600)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 快速开始
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/backup-setup # 向导:填地址、账号、密码(默认 DPAPI 加密保存),自动测连接
|
|
49
|
+
/backup # 立即备份
|
|
50
|
+
/backup verify # 体检:密钥来源 / 明文告警 / 连接性
|
|
51
|
+
/backup check # 单独测连接
|
|
52
|
+
/backup list # 看远端已有备份
|
|
53
|
+
/backup restore # 选一个备份恢复到临时目录
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
配置写入 `~/.pi/agent/webdav-backup.json`。
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 命令一览
|
|
61
|
+
|
|
62
|
+
| 命令 | 作用 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `/backup` | 立即备份(同步执行,完成后通知) |
|
|
65
|
+
| `/backup check` | 测试 WebDAV 连通性与认证 |
|
|
66
|
+
| `/backup list` | 列出远端备份文件 |
|
|
67
|
+
| `/backup dry` | 试运行:只扫描统计,不上传 |
|
|
68
|
+
| `/backup status` | 查看上次备份时间 |
|
|
69
|
+
| `/backup prune` | 清理旧备份(保留 `keepVersions` 个) |
|
|
70
|
+
| `/backup restore` | 交互选择备份并恢复到临时目录 |
|
|
71
|
+
| `/backup verify` | 配置体检:密钥来源、明文告警、连接性 |
|
|
72
|
+
| `/backup keys list` | 列出已加密保存的密钥 |
|
|
73
|
+
| `/backup keys set <名称>` | 用 DPAPI 加密保存密钥 |
|
|
74
|
+
| `/backup keys rm <名称>` | 删除密钥 |
|
|
75
|
+
| `/backup keys migrate` | 把配置里的明文密钥迁成加密引用 |
|
|
76
|
+
| `/backup-setup` | 配置向导(默认走 DPAPI 加密) |
|
|
77
|
+
| `/backup-cron [HH:MM]` | 安装每日定时备份(Windows 计划任务) |
|
|
78
|
+
| `/backup-cron remove` | 移除定时任务 |
|
|
79
|
+
| `/backup-log` | 查看最近 20 行日志 |
|
|
80
|
+
|
|
81
|
+
命令行也可直接用:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node ~/.pi/agent/extensions/webdav-backup/cli.mjs backup --json
|
|
85
|
+
node ~/.pi/agent/extensions/webdav-backup/cli.mjs doctor --json
|
|
86
|
+
node ~/.pi/agent/extensions/webdav-backup/cli.mjs migrate --method dpapi
|
|
87
|
+
node ~/.pi/agent/extensions/webdav-backup/cli.mjs restore --list
|
|
88
|
+
node ~/.pi/agent/extensions/webdav-backup/cli.mjs restore --file pi-agent-20260924-120000.tar.gz --to D:\restore
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 密钥不明文落盘
|
|
94
|
+
|
|
95
|
+
`remote.password` 与 `encryptKey` 不直接写明文,而是写**引用**:
|
|
96
|
+
|
|
97
|
+
| 语法 | 说明 | 适用 |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `dpapi:名称` | **推荐**。Windows DPAPI 用户级加密存入 `secrets.dpapi.json`,本机本用户可解,**无需主密码** | Windows |
|
|
100
|
+
| `$ENV_VAR` / `${ENV_VAR}` | 环境变量(也支持 `$A-$B` 拼接,`$$` 转义) | 跨平台 |
|
|
101
|
+
| `file:路径` | 从文件读取(自动去换行;相对路径基于 `~/.pi/agent`) | 跨平台 |
|
|
102
|
+
| `!命令` | 执行命令取 stdout(可对接密码管理器 CLI) | 跨平台 |
|
|
103
|
+
| `plain:值` | 显式声明明文(会告警,仅调试用) | — |
|
|
104
|
+
| 其他值 | 视为明文(**会告警**) | — |
|
|
105
|
+
|
|
106
|
+
> 该语法与 pi 自身的 `resolve-config-value` 约定一致,`auth.json` 的 `key` 字段也支持 `$ENV_VAR` 与 `!命令`。
|
|
107
|
+
|
|
108
|
+
### 推荐做法
|
|
109
|
+
|
|
110
|
+
```jsonc
|
|
111
|
+
{
|
|
112
|
+
"remote": {
|
|
113
|
+
"url": "https://dav.jianguoyun.com/dav/",
|
|
114
|
+
"username": "you@example.com",
|
|
115
|
+
"password": "dpapi:webdav-password" // 不是明文
|
|
116
|
+
},
|
|
117
|
+
"encrypt": true,
|
|
118
|
+
"encryptKey": "dpapi:backup-key" // 不是明文
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
密钥本身这样存:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
/backup keys set webdav-password # 提示输入,DPAPI 加密入库
|
|
126
|
+
/backup keys set backup-key
|
|
127
|
+
/backup keys list # 只看名称与时间,不显示明文
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
已有明文配置?一条命令迁走(自动备份原文件):
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
/backup keys migrate
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
非 Windows 或偏好环境变量:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
node cli.mjs migrate --method env # 写入用户环境变量(setx)
|
|
140
|
+
node cli.mjs migrate --method file # 写入 0600 权限的 secrets/ 目录
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 体检
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
/backup verify
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
输出密钥来源、是否明文、DPAPI 是否可用、连接是否通过。有明文会直接标 ⚠️。
|
|
150
|
+
|
|
151
|
+
> **DPAPI 的边界**:防御的是「配置文件/备份被拷走或误传到云端」。不防御已经能以你的身份运行代码的本机恶意程序。若需更强隔离,用 `!命令` 对接外部密码管理器。
|
|
152
|
+
|
|
153
|
+
### 环境变量方式也支持
|
|
154
|
+
|
|
155
|
+
不想改配置文件时,仍可用环境变量(优先级低于配置里的值):
|
|
156
|
+
|
|
157
|
+
```powershell
|
|
158
|
+
setx PI_WEBDAV_BACKUP_PASSWORD "你的应用密码"
|
|
159
|
+
setx PI_WEBDAV_BACKUP_KEY "你的加密口令"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 备份什么
|
|
165
|
+
|
|
166
|
+
默认备份 `~/.pi/agent/` 下**除以下内容外**的所有文件:
|
|
167
|
+
|
|
168
|
+
| 排除项 | 原因 |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `sessions/**` | 会话历史,通常很大(可用 `includeSessions` 打开) |
|
|
171
|
+
| `tmp/**`、`web-search-cache/**` | 缓存 |
|
|
172
|
+
| `**/node_modules/**` | 可重装 |
|
|
173
|
+
| `**/*.log` | 日志 |
|
|
174
|
+
|
|
175
|
+
包含:`extensions/`(你写的扩展)、`models.json`、`models-store.json`、`settings.json`、`trust.json`、`auth.json`、`npm/package.json` 等。
|
|
176
|
+
|
|
177
|
+
### 敏感文件脱敏(重要)
|
|
178
|
+
|
|
179
|
+
**未加密**备份时,`auth.json` 与 `webdav-backup.json` 会先脱敏再上传:
|
|
180
|
+
|
|
181
|
+
- 按字段名匹配:`key` / `token` / `secret` / `password` / `credential` / `cookie` / `private` …
|
|
182
|
+
- **并按值的形状匹配**:`sk-…`、`ghp_…`、`AIza…`、JWT、32 位以上随机串
|
|
183
|
+
- 命中替换为 `__REDACTED__`,结构保留
|
|
184
|
+
- 解析失败时整文件替换为占位符,**绝不原样上传**
|
|
185
|
+
|
|
186
|
+
> 脱敏是「尽力而为」的防泄漏措施。真正的安全做法是开启 `encrypt`。
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 配置项
|
|
191
|
+
|
|
192
|
+
```jsonc
|
|
193
|
+
{
|
|
194
|
+
"enabled": true,
|
|
195
|
+
|
|
196
|
+
"remote": {
|
|
197
|
+
"url": "https://dav.jianguoyun.com/dav/", // 见下方各服务填法
|
|
198
|
+
"username": "you@example.com",
|
|
199
|
+
"password": "dpapi:webdav-password", // 密钥引用,见上文;留空则读 $PI_WEBDAV_BACKUP_PASSWORD
|
|
200
|
+
"remoteDir": "pi-backup", // 远端目录
|
|
201
|
+
"remoteName": "pi-agent" // 文件名前缀
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
"scope": ["."], // 相对 ~/.pi/agent 的路径,可写 ["extensions", "models.json"]
|
|
205
|
+
"exclude": [], // 额外 glob 排除规则
|
|
206
|
+
"includeSessions": false, // 是否备份会话历史
|
|
207
|
+
"snapshot": false, // 见下文「两种模式」
|
|
208
|
+
|
|
209
|
+
"encrypt": false, // AES-256-GCM 加密
|
|
210
|
+
"encryptKey": "dpapi:backup-key", // 密钥引用(≥16 位),或 $PI_WEBDAV_BACKUP_KEY
|
|
211
|
+
|
|
212
|
+
"backupOnExit": true, // 退出 pi 时自动备份
|
|
213
|
+
"backupOnExitMinIntervalMinutes": 30, // 两次自动备份最小间隔
|
|
214
|
+
"backupOnExitTimeoutMs": 600000, // 自动备份等待上限
|
|
215
|
+
|
|
216
|
+
"keepVersions": 10, // 保留最近 N 个归档(0 = 不清理)
|
|
217
|
+
"timeoutMs": 60000,
|
|
218
|
+
"insecureTls": false // 自签名证书时才设 true
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### 各服务 URL 填法
|
|
223
|
+
|
|
224
|
+
| 服务 | url | username | password |
|
|
225
|
+
|---|---|---|---|
|
|
226
|
+
| **坚果云** | `https://dav.jianguoyun.com/dav/` | 注册邮箱 | **应用密码**(账户信息 → 安全选项 → 添加应用密码) |
|
|
227
|
+
| **Nextcloud** | `https://云地址/remote.php/dav/files/<用户名>/` | 用户名 | 登录密码或应用密码 |
|
|
228
|
+
| **群晖 WebDAV** | `https://NAS地址:5006/<共享文件夹>` | DSM 账号 | DSM 密码 |
|
|
229
|
+
| **自建 rclone serve** | `http://主机:端口/` | `--user` 指定的值 | `--pass` 指定的值 |
|
|
230
|
+
|
|
231
|
+
> ⚠️ 密码建议用 `dpapi:` 引用而非明文,见下方「密钥不明文落盘」:
|
|
232
|
+
> ```
|
|
233
|
+
> /backup keys set webdav-password
|
|
234
|
+
> # 然后在配置里写 "password": "dpapi:webdav-password"
|
|
235
|
+
> ```
|
|
236
|
+
> 也可用环境变量:`setx PI_WEBDAV_BACKUP_PASSWORD "你的应用密码"`
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 两种备份模式
|
|
241
|
+
|
|
242
|
+
### 归档模式(默认,`snapshot: false`)
|
|
243
|
+
|
|
244
|
+
每次打包成一个 `pi-agent-<时间戳>.tar.gz`(可选 `.pibak` 加密)上传。
|
|
245
|
+
|
|
246
|
+
- ✅ 单个文件,下载/归档方便
|
|
247
|
+
- ✅ 天然有版本历史
|
|
248
|
+
- ⚠️ 每次全量上传
|
|
249
|
+
|
|
250
|
+
### 快照模式(`snapshot: true`)
|
|
251
|
+
|
|
252
|
+
逐文件上传到 `pi-backup/pi-agent/`,用远端 `manifest.json` 做增量比对。
|
|
253
|
+
|
|
254
|
+
- ✅ 只传变化的文件,第二次起非常快
|
|
255
|
+
- ✅ 能单独恢复某一个文件
|
|
256
|
+
- ⚠️ 只保留最新状态,无历史版本
|
|
257
|
+
|
|
258
|
+
**建议**:日常用**快照模式**(省流量),配合每周一次归档(可另配一个 `remoteName`)。
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 退出自动备份怎么工作
|
|
263
|
+
|
|
264
|
+
`session_shutdown`(`reason: quit`)触发时:
|
|
265
|
+
|
|
266
|
+
1. 检查 `enabled` / `backupOnExit`
|
|
267
|
+
2. 距上次自动备份不足 `backupOnExitMinIntervalMinutes` → 跳过(避免反复开关 pi 重复上传)
|
|
268
|
+
3. 否则 **spawn 一个分离子进程**执行上传,立刻返回
|
|
269
|
+
|
|
270
|
+
用分离进程的原因:pi 退出时若被 `SIGTERM`/关窗打断,留在主进程的上传会被中断;分离进程不受影响,且不会卡住退出。
|
|
271
|
+
|
|
272
|
+
日志写在 `~/.pi/agent/webdav-backup.log`。
|
|
273
|
+
|
|
274
|
+
### 每日定时备份
|
|
275
|
+
|
|
276
|
+
```powershell
|
|
277
|
+
# 在 pi 里:/backup-cron 03:00 或命令行:
|
|
278
|
+
node "$env:USERPROFILE\.pi\agent\extensions\webdav-backup\cli.mjs" install-cron --time 03:00
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
需要管理员权限(创建计划任务)。移除:`/backup-cron remove`。
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## 恢复
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
/backup restore # 选择备份 → 恢复到临时目录
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**默认恢复到临时目录,不会直接覆盖 `~/.pi/agent`** —— 因为恢复旧配置可能覆盖你当前的密钥。
|
|
292
|
+
|
|
293
|
+
恢复后自行拷贝需要的文件:
|
|
294
|
+
|
|
295
|
+
```powershell
|
|
296
|
+
# 归档恢复结果示例
|
|
297
|
+
# C:\Users\...\Temp\pi-restore-XXXX\restored\extensions\...
|
|
298
|
+
copy "$env:TEMP\pi-restore-XXXX\restored\extensions\*.ts" "$env:USERPROFILE\.pi\agent\extensions\"
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
指定目标目录(谨慎):
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
node cli.mjs restore --file pi-agent-20260924-120000.tar.gz --to D:\pi-restore
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### 加密备份的恢复
|
|
308
|
+
|
|
309
|
+
需要 `encryptKey` 一致(配置文件或 `$PI_WEBDAV_BACKUP_KEY`)。密钥不对会明确报错,不会写出损坏文件。
|
|
310
|
+
|
|
311
|
+
解包过程会**拒绝绝对路径与 `..` 穿越**,防止恶意归档写出到目标目录之外。
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## 测试
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
cd pi-webdav-backup
|
|
319
|
+
npm test
|
|
320
|
+
# 等价于:node extensions/webdav-backup/run-tests.mjs
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
扩展契约校验(模拟 pi 加载器导入入口文件):
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
node extensions/webdav-backup/verify-load.mjs
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
6 个测试文件、277 项断言,用内置的内存 WebDAV 服务端做真实 HTTP 往返:
|
|
330
|
+
|
|
331
|
+
| 文件 | 覆盖 |
|
|
332
|
+
|---|---|
|
|
333
|
+
| `test.mjs` | 收集/排除、归档、加密往返、快照增量、prune |
|
|
334
|
+
| `test-cli.mjs` | 真实子进程调用 CLI、退出备份、DPAPI 端到端、doctor、日志、状态 |
|
|
335
|
+
| `test-restore.mjs` | 归档/加密/快照恢复、目录穿越防护 |
|
|
336
|
+
| `test-redact.mjs` | 脱敏(含「字段名无关但值是密钥」的回归用例) |
|
|
337
|
+
| `test-secrets.mjs` | dpapi/$ENV/file/!命令/plain 各分支、明文告警、三种迁移方式 |
|
|
338
|
+
| `test-auth-migrate.mjs` | `auth.json` 明文密钥审计与迁移(迁移前自动备份、可回滚) |
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## 故障排查
|
|
343
|
+
|
|
344
|
+
| 现象 | 处理 |
|
|
345
|
+
|---|---|
|
|
346
|
+
| 401 认证失败 | 坚果云必须用**应用密码**;确认 `username` 是邮箱 |
|
|
347
|
+
| 404 路径不存在 | 检查 `url` 结尾斜杠与路径;Nextcloud 要带 `/remote.php/dav/files/<用户名>/` |
|
|
348
|
+
| 403 拒绝访问 | 远端目录权限不足;坚果云需先在网页版建好目录 |
|
|
349
|
+
| TLS 证书错误 | 自签名证书才设 `insecureTls: true`(有中间人风险) |
|
|
350
|
+
| 备份很慢 | 改用 `snapshot: true`;或把 `includeSessions` 关掉 |
|
|
351
|
+
| 退出时没备份 | 看 `webdav-backup.log`;确认 `backupOnExit: true` 且距上次已过间隔 |
|
|
352
|
+
| 想立刻看到日志 | `/backup-log` |
|
|
353
|
+
| DPAPI 解密失败 | 密文只能在本机本用户下解;换机器/换用户需重新 `/backup keys set` |
|
|
354
|
+
| 提示「环境变量未设置」 | `/backup keys set` 改用 dpapi,或先设好该环境变量 |
|
|
355
|
+
| 配置体检 | `/backup verify` 一次看全部状态 |
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用真实 ~/.pi/agent 内容做一次 dry-run + 审计,确认:
|
|
3
|
+
* 1. 迁移后的 auth.json 引用形态能被正确处理
|
|
4
|
+
* 2. 明文审计通过
|
|
5
|
+
* 3. 迁移遗留的 auth.json.bak-* 不在待上传列表里
|
|
6
|
+
*/
|
|
7
|
+
import { loadConfig } from "./config.mjs";
|
|
8
|
+
import { runBackup, auditForPlaintext } from "./backup.mjs";
|
|
9
|
+
import { collectFiles } from "./collect.mjs";
|
|
10
|
+
import { resolveAgentDir } from "./config-paths.mjs";
|
|
11
|
+
|
|
12
|
+
const agentDir = resolveAgentDir();
|
|
13
|
+
console.log("agentDir:", agentDir);
|
|
14
|
+
|
|
15
|
+
const loaded = loadConfig();
|
|
16
|
+
console.log("配置文件存在:", loaded.ok || loaded.errors.every((e) => /不存在/.test(e)) ? "是" : "否");
|
|
17
|
+
if (loaded.config) {
|
|
18
|
+
for (const w of loaded.warnings) console.log(" 警告:", w);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 尚未创建配置时用默认值跑本地扫描(dry-run 不连网)
|
|
22
|
+
const cfg = loaded.config ?? {
|
|
23
|
+
enabled: true,
|
|
24
|
+
url: "https://example.invalid/dav",
|
|
25
|
+
username: "",
|
|
26
|
+
password: "",
|
|
27
|
+
remoteDir: "pi-backup",
|
|
28
|
+
remoteName: "pi-agent",
|
|
29
|
+
scope: ["."],
|
|
30
|
+
exclude: [
|
|
31
|
+
"tmp/**",
|
|
32
|
+
"web-search-cache/**",
|
|
33
|
+
"npm/node_modules/**",
|
|
34
|
+
"sessions/**",
|
|
35
|
+
"**/*.log",
|
|
36
|
+
"**/node_modules/**",
|
|
37
|
+
"secrets/**",
|
|
38
|
+
"secrets.dpapi.json",
|
|
39
|
+
"**/*.bak-*",
|
|
40
|
+
"**/*.bak",
|
|
41
|
+
"**/*.orig",
|
|
42
|
+
"**/*.old",
|
|
43
|
+
"**/*.save",
|
|
44
|
+
],
|
|
45
|
+
includeSessions: false,
|
|
46
|
+
snapshot: false,
|
|
47
|
+
encrypt: false,
|
|
48
|
+
encryptKey: null,
|
|
49
|
+
timeoutMs: 60000,
|
|
50
|
+
insecureTls: false,
|
|
51
|
+
keepVersions: 10,
|
|
52
|
+
};
|
|
53
|
+
console.log("(未创建配置文件,使用默认范围与排除规则)");
|
|
54
|
+
console.log("远端:", cfg.url);
|
|
55
|
+
|
|
56
|
+
const { files, skipped, totalSize } = collectFiles({
|
|
57
|
+
agentDir,
|
|
58
|
+
scope: cfg.scope,
|
|
59
|
+
exclude: [...cfg.exclude, ...(cfg.includeSessions ? [] : ["sessions/**"])],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
console.log(`\n待备份文件 ${files.length} 个,${(totalSize / 1024).toFixed(1)} KB,跳过 ${skipped} 项`);
|
|
63
|
+
console.log("文件列表:");
|
|
64
|
+
for (const f of files) console.log(" ", f.rel);
|
|
65
|
+
|
|
66
|
+
const baks = files.filter((f) => /\.bak/.test(f.rel));
|
|
67
|
+
console.log("\n待备份列表中的 .bak 文件:", baks.length === 0 ? "无 ✅" : baks.map((b) => b.rel).join(", "));
|
|
68
|
+
|
|
69
|
+
const audit = auditForPlaintext(files, { agentDir, config: cfg });
|
|
70
|
+
console.log("\n明文审计:", audit.clean ? "通过 ✅" : "发现问题 ❌");
|
|
71
|
+
for (const l of audit.leaks) console.log(" ", l.rel, "—", l.reason);
|
|
72
|
+
|
|
73
|
+
// 确认 auth.json 上传后确实不含原明文(对比注册表值)
|
|
74
|
+
const { readFileSync } = await import("node:fs");
|
|
75
|
+
const { join } = await import("node:path");
|
|
76
|
+
const { redactSensitive } = await import("./backup.mjs");
|
|
77
|
+
|
|
78
|
+
const authRel = files.find((f) => f.rel.endsWith("auth.json"));
|
|
79
|
+
if (authRel) {
|
|
80
|
+
const before = readFileSync(join(agentDir, authRel.rel), "utf8");
|
|
81
|
+
const after = redactSensitive(authRel.rel, Buffer.from(before), { aggressive: true }).toString("utf8");
|
|
82
|
+
console.log("\nauth.json 磁盘原文:", before.replace(/\s+/g, " ").slice(0, 120));
|
|
83
|
+
console.log("auth.json 上传形态:", after.replace(/\s+/g, " ").slice(0, 120));
|
|
84
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-migrate.mjs — 把 auth.json 里的明文 API 密钥改为安全引用
|
|
3
|
+
*
|
|
4
|
+
* pi 的 auth.json 结构:
|
|
5
|
+
* { "<provider>": { "type": "api_key", "key": "<明文 或 $ENV_VAR 或 !命令>" } }
|
|
6
|
+
*
|
|
7
|
+
* auth-storage.js 的 read() 会对 key 调用 resolveConfigValue,
|
|
8
|
+
* 因此 $ENV_VAR / !命令 / ${VAR} 引用是被官方支持的。
|
|
9
|
+
*
|
|
10
|
+
* 迁移策略(每项单独选择):
|
|
11
|
+
* env → $PI_PI_KEY_<PROVIDER> (默认,与其它 pi 用法一致,跨平台)
|
|
12
|
+
* dpapi → dpapi:auth-<provider> (Windows,无主密码)
|
|
13
|
+
* keep → 不动
|
|
14
|
+
*
|
|
15
|
+
* 重要:key 为空字符串时 pi 用它表达“未配置”,必须保留为空,不能改成引用,
|
|
16
|
+
* 否则会把“未登录”变成“引用一个不存在的变量”。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
chmodSync,
|
|
21
|
+
copyFileSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
import { getAgentDir } from "./config-paths.mjs";
|
|
29
|
+
import {
|
|
30
|
+
setDpapiSecret,
|
|
31
|
+
dpapiAvailable,
|
|
32
|
+
setUserEnvVar,
|
|
33
|
+
readUserEnvVar,
|
|
34
|
+
envVarVisible,
|
|
35
|
+
} from "./secrets.mjs";
|
|
36
|
+
|
|
37
|
+
export function authJsonPath() {
|
|
38
|
+
return join(getAgentDir(), "auth.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 判断 key 是否已是引用形式 */
|
|
42
|
+
function isReference(key) {
|
|
43
|
+
if (typeof key !== "string") return false;
|
|
44
|
+
const t = key.trim();
|
|
45
|
+
if (!t) return false;
|
|
46
|
+
if (t.startsWith("$") || t.startsWith("!") || t.startsWith("dpapi:") || t.startsWith("file:")) return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 由 provider id 生成合法环境变量名 */
|
|
51
|
+
export function envVarNameFor(providerId) {
|
|
52
|
+
const normalized = String(providerId)
|
|
53
|
+
.toUpperCase()
|
|
54
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
55
|
+
.replace(/^_+|_+$/g, "");
|
|
56
|
+
return `PI_PI_KEY_${normalized}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readAuth() {
|
|
60
|
+
const p = authJsonPath();
|
|
61
|
+
if (!existsSync(p)) return null;
|
|
62
|
+
try {
|
|
63
|
+
const raw = readFileSync(p, "utf8").replace(/^\uFEFF/, "").trim();
|
|
64
|
+
return raw ? JSON.parse(raw) : {};
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { __parseError: e instanceof Error ? e.message : String(e) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 扫描 auth.json,列出需要迁移的明文密钥。
|
|
72
|
+
*/
|
|
73
|
+
export function scanAuthSecrets() {
|
|
74
|
+
const data = readAuth();
|
|
75
|
+
if (data === null) {
|
|
76
|
+
return { ok: false, error: `auth.json 不存在:${authJsonPath()}`, items: [] };
|
|
77
|
+
}
|
|
78
|
+
if (data.__parseError) {
|
|
79
|
+
return { ok: false, error: `auth.json 不是合法 JSON:${data.__parseError}`, items: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const items = [];
|
|
83
|
+
for (const [providerId, cred] of Object.entries(data)) {
|
|
84
|
+
if (!cred || typeof cred !== "object") continue;
|
|
85
|
+
const key = cred.key;
|
|
86
|
+
if (typeof key !== "string") continue;
|
|
87
|
+
|
|
88
|
+
if (key.trim() === "") {
|
|
89
|
+
items.push({ provider: providerId, kind: "empty", keyLength: 0, action: "skip", reason: "空值表示未配置,保持原样" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (isReference(key)) {
|
|
93
|
+
items.push({ provider: providerId, kind: "reference", action: "skip", reason: "已是安全引用", current: key });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
items.push({
|
|
97
|
+
provider: providerId,
|
|
98
|
+
kind: "plaintext",
|
|
99
|
+
keyLength: key.length,
|
|
100
|
+
action: "migrate",
|
|
101
|
+
suggestedEnv: envVarNameFor(providerId),
|
|
102
|
+
suggestedDpapi: `dpapi:auth-${providerId}`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, items, path: authJsonPath() };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 执行迁移。
|
|
110
|
+
*
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {"env"|"dpapi"} opts.method
|
|
113
|
+
* @param {Record<string, "env"|"dpapi"|"keep">} [opts.perProvider]
|
|
114
|
+
* @param {(s: string) => void} [opts.log]
|
|
115
|
+
* @param {boolean} [opts.dryRun]
|
|
116
|
+
*/
|
|
117
|
+
export async function migrateAuthJson({ method = "env", perProvider = {}, log = () => {}, dryRun = false } = {}) {
|
|
118
|
+
const scan = scanAuthSecrets();
|
|
119
|
+
if (!scan.ok) return { ok: false, error: scan.error };
|
|
120
|
+
|
|
121
|
+
const targets = scan.items.filter((it) => it.kind === "plaintext");
|
|
122
|
+
if (targets.length === 0) {
|
|
123
|
+
return { ok: true, migrated: 0, message: "auth.json 中没有明文密钥,无需迁移", skipped: scan.items };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (dryRun) {
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
dryRun: true,
|
|
130
|
+
wouldMigrate: targets.map((t) => {
|
|
131
|
+
const m = perProvider[t.provider] ?? method;
|
|
132
|
+
return {
|
|
133
|
+
provider: t.provider,
|
|
134
|
+
method: m,
|
|
135
|
+
target: m === "dpapi" ? t.suggestedDpapi : `$${t.suggestedEnv}`,
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const data = readAuth();
|
|
142
|
+
const p = authJsonPath();
|
|
143
|
+
const backup = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
144
|
+
copyFileSync(p, backup);
|
|
145
|
+
log(`已备份 auth.json → ${backup}`);
|
|
146
|
+
|
|
147
|
+
const changes = [];
|
|
148
|
+
const warnings = [];
|
|
149
|
+
|
|
150
|
+
for (const item of targets) {
|
|
151
|
+
const chosen = perProvider[item.provider] ?? method;
|
|
152
|
+
if (chosen === "keep") {
|
|
153
|
+
log(`跳过 ${item.provider}(用户选择保留)`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const plaintext = data[item.provider].key;
|
|
158
|
+
|
|
159
|
+
if (chosen === "dpapi") {
|
|
160
|
+
if (!dpapiAvailable()) {
|
|
161
|
+
warnings.push(`${item.provider}:DPAPI 不可用,已跳过`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const name = `auth-${item.provider}`;
|
|
165
|
+
try {
|
|
166
|
+
setDpapiSecret(name, plaintext);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
warnings.push(`${item.provider}:DPAPI 保存失败 — ${e instanceof Error ? e.message : String(e)}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
data[item.provider].key = `dpapi:${name}`;
|
|
172
|
+
changes.push({ provider: item.provider, to: `dpapi:${name}`, method: "dpapi" });
|
|
173
|
+
log(`${item.provider} → dpapi:${name}`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// env(默认)
|
|
178
|
+
const envName = envVarNameFor(item.provider);
|
|
179
|
+
try {
|
|
180
|
+
const r = setUserEnvVar(envName, plaintext);
|
|
181
|
+
if (!r.ok) {
|
|
182
|
+
warnings.push(`${item.provider}:${r.note}`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
warnings.push(`${item.provider}:写入环境变量失败 — ${e instanceof Error ? e.message : String(e)}`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 写入注册表后当前进程看不到;从注册表读回确认
|
|
191
|
+
const readBack = readUserEnvVar(envName);
|
|
192
|
+
if (readBack === undefined) {
|
|
193
|
+
warnings.push(`${item.provider}:环境变量 ${envName} 写入后读回失败,已跳过以免丢失密钥`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (readBack !== plaintext) {
|
|
197
|
+
warnings.push(`${item.provider}:环境变量 ${envName} 读回值不一致,已跳过以免丢失密钥`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 让当前进程立刻可用(这样本次运行不需要重启也能用)
|
|
202
|
+
process.env[envName] = plaintext;
|
|
203
|
+
|
|
204
|
+
data[item.provider].key = `$${envName}`;
|
|
205
|
+
changes.push({ provider: item.provider, to: `$${envName}`, method: "env", envName });
|
|
206
|
+
log(`${item.provider} → $${envName}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (changes.length === 0) {
|
|
210
|
+
return { ok: false, error: "没有任何条目迁移成功", warnings, backup };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
writeFileSync(p, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
214
|
+
try {
|
|
215
|
+
chmodSync(p, 0o600);
|
|
216
|
+
} catch {
|
|
217
|
+
/* ignore */
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 校验:迁移后文件里不应再出现明文
|
|
221
|
+
const after = readFileSync(p, "utf8");
|
|
222
|
+
const stillPlain = scanAuthSecrets().items.filter((i) => i.kind === "plaintext");
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
ok: stillPlain.length === 0,
|
|
226
|
+
migrated: changes.length,
|
|
227
|
+
changes,
|
|
228
|
+
warnings,
|
|
229
|
+
backup,
|
|
230
|
+
remainingPlaintext: stillPlain.map((s) => s.provider),
|
|
231
|
+
envVarsNeedRestart: changes.filter((c) => c.method === "env").map((c) => c.envName),
|
|
232
|
+
fileNote: after.length > 0 ? undefined : undefined,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 还原:从备份恢复 auth.json */
|
|
237
|
+
export function restoreAuthJson(backupPath) {
|
|
238
|
+
const p = authJsonPath();
|
|
239
|
+
if (!existsSync(backupPath)) return { ok: false, error: `备份不存在:${backupPath}` };
|
|
240
|
+
copyFileSync(backupPath, p);
|
|
241
|
+
return { ok: true, restoredFrom: backupPath };
|
|
242
|
+
}
|