@lifeaitools/clauth 1.30.26 → 2.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/.clauth-skill/references/keys-guide.md +270 -270
- package/README.md +2 -0
- package/cli/api.js +238 -238
- package/cli/commands/agent-pool.js +51 -15
- package/cli/commands/install.js +396 -396
- package/cli/commands/login.js +135 -0
- package/cli/commands/login.test.js +73 -0
- package/cli/commands/serve.js +290 -1555
- package/cli/commands/uninstall.js +164 -164
- package/cli/index.js +102 -1
- package/cli/supervisor-registry.js +106 -5
- package/cli/supervisor-registry.test.js +151 -0
- package/cli/webdav-service.js +339 -339
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +4 -5
- package/scripts/bootstrap.cjs +121 -121
- package/scripts/build.mjs +66 -0
- package/scripts/build.sh +5 -45
- package/scripts/postinstall.js +189 -189
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
package/cli/webdav-service.js
CHANGED
|
@@ -1,339 +1,339 @@
|
|
|
1
|
-
// cli/webdav-service.js
|
|
2
|
-
// WebDAV child-process supervisor — rclone serve webdav
|
|
3
|
-
//
|
|
4
|
-
// ISOLATION: This module has zero coupling to existing fs_* tool handlers.
|
|
5
|
-
// Rollback: set webdav.enabled=false in the config block (or remove it)
|
|
6
|
-
// + delete this file + remove the two-line import in serve.js.
|
|
7
|
-
//
|
|
8
|
-
// Design: lazy-start. The rclone child spawns on the first fs_dav_setup call.
|
|
9
|
-
// Subsequent calls return the running child's status without restarting.
|
|
10
|
-
// On child crash: exponential-backoff restart loop (2s → 5s → 15s → 60s).
|
|
11
|
-
|
|
12
|
-
import { spawn, execSync } from "child_process";
|
|
13
|
-
import { writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from "fs";
|
|
14
|
-
import { tmpdir, homedir } from "os";
|
|
15
|
-
import { join } from "path";
|
|
16
|
-
import crypto from "crypto";
|
|
17
|
-
import * as api from "./api.js";
|
|
18
|
-
import { deriveToken } from "./fingerprint.js";
|
|
19
|
-
|
|
20
|
-
const WEBDAV_PORT = 8620;
|
|
21
|
-
const WEBDAV_USER = "claude";
|
|
22
|
-
const CRED_NAME = "webdav-claude";
|
|
23
|
-
const RESTART_DELAYS = [2000, 5000, 15000, 60000];
|
|
24
|
-
|
|
25
|
-
const APPDATA = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
26
|
-
const CONFIG_PATH = join(APPDATA, "clauth", "webdav-config.json");
|
|
27
|
-
|
|
28
|
-
const DEFAULT_CONFIG = {
|
|
29
|
-
enabled: true,
|
|
30
|
-
port: WEBDAV_PORT,
|
|
31
|
-
credential: CRED_NAME,
|
|
32
|
-
upstreams: [],
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
// ── Config persistence ──────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
export function loadConfig() {
|
|
38
|
-
try {
|
|
39
|
-
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
40
|
-
const cfg = JSON.parse(raw);
|
|
41
|
-
if (!Array.isArray(cfg.upstreams)) cfg.upstreams = [];
|
|
42
|
-
return { ...DEFAULT_CONFIG, ...cfg };
|
|
43
|
-
} catch {
|
|
44
|
-
return { ...DEFAULT_CONFIG };
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function saveConfig(cfg) {
|
|
49
|
-
const dir = join(APPDATA, "clauth");
|
|
50
|
-
mkdirSync(dir, { recursive: true });
|
|
51
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
|
|
52
|
-
_fsCfgCache = null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function addMount(name, mountPath) {
|
|
56
|
-
const cfg = loadConfig();
|
|
57
|
-
if (cfg.upstreams.some(u => u.name === name)) return { error: `Mount '${name}' already exists` };
|
|
58
|
-
cfg.upstreams.push({ name, path: mountPath });
|
|
59
|
-
saveConfig(cfg);
|
|
60
|
-
return { ok: true, upstreams: cfg.upstreams };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function removeMount(name) {
|
|
64
|
-
const cfg = loadConfig();
|
|
65
|
-
const before = cfg.upstreams.length;
|
|
66
|
-
cfg.upstreams = cfg.upstreams.filter(u => u.name !== name);
|
|
67
|
-
if (cfg.upstreams.length === before) return { error: `Mount '${name}' not found` };
|
|
68
|
-
saveConfig(cfg);
|
|
69
|
-
return { ok: true, upstreams: cfg.upstreams };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function updateMount(name, updates) {
|
|
73
|
-
const cfg = loadConfig();
|
|
74
|
-
const mount = cfg.upstreams.find(u => u.name === name);
|
|
75
|
-
if (!mount) return { error: `Mount '${name}' not found` };
|
|
76
|
-
if (updates.credential !== undefined) cfg.credential = updates.credential;
|
|
77
|
-
saveConfig(cfg);
|
|
78
|
-
return { ok: true, upstreams: cfg.upstreams };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
let _fsCfgCache = null;
|
|
82
|
-
|
|
83
|
-
// ── Module state ────────────────────────────────────────────────
|
|
84
|
-
let _child = null;
|
|
85
|
-
let _configPath = null;
|
|
86
|
-
let _status = "stopped"; // stopped | starting | running | error
|
|
87
|
-
let _error = null;
|
|
88
|
-
let _restartCount = 0;
|
|
89
|
-
let _startedAt = null;
|
|
90
|
-
let _vaultFn = null; // () => { password, machineHash }
|
|
91
|
-
let _cfg = null;
|
|
92
|
-
|
|
93
|
-
// ── Public API ───────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Ensure the rclone WebDAV child is running.
|
|
97
|
-
* Idempotent — safe to call multiple times.
|
|
98
|
-
* Returns { ok: true } or { error: string }.
|
|
99
|
-
*/
|
|
100
|
-
export async function ensureRunning(vaultFn, config) {
|
|
101
|
-
if (vaultFn) _vaultFn = vaultFn;
|
|
102
|
-
if (config) _cfg = config;
|
|
103
|
-
if (!_cfg) _cfg = loadConfig();
|
|
104
|
-
|
|
105
|
-
if (_status === "running" && _child) return { ok: true };
|
|
106
|
-
if (_status === "starting") return { ok: true, message: "still starting" };
|
|
107
|
-
|
|
108
|
-
return _startChild();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Current supervisor status — safe to call at any time. */
|
|
112
|
-
export function getStatus() {
|
|
113
|
-
return {
|
|
114
|
-
status: _status,
|
|
115
|
-
port: WEBDAV_PORT,
|
|
116
|
-
url: _status === "running" ? `http://127.0.0.1:${WEBDAV_PORT}` : null,
|
|
117
|
-
error: _error || null,
|
|
118
|
-
restart_count: _restartCount,
|
|
119
|
-
started_at: _startedAt || null,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Kill the child and clean up the temp config file. */
|
|
124
|
-
export function shutdown() {
|
|
125
|
-
if (_child) {
|
|
126
|
-
_child.removeAllListeners();
|
|
127
|
-
_child.kill("SIGTERM");
|
|
128
|
-
_child = null;
|
|
129
|
-
}
|
|
130
|
-
_cleanupConfig();
|
|
131
|
-
_status = "stopped";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* rclone-compatible AES-CTR "obscure" — same algorithm as `rclone obscure`.
|
|
136
|
-
* Used by fs_dav_setup to embed a non-plaintext credential in the mount script.
|
|
137
|
-
* The result can be passed as RCLONE_CONFIG_xxx_PASS.
|
|
138
|
-
*/
|
|
139
|
-
export function rcloneObscure(password) {
|
|
140
|
-
// Key from rclone source: lib/obscure/obscure.go cryptKey
|
|
141
|
-
const key = Buffer.from([
|
|
142
|
-
0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
|
|
143
|
-
0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
|
|
144
|
-
0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
|
|
145
|
-
0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
|
|
146
|
-
]);
|
|
147
|
-
const iv = crypto.randomBytes(16);
|
|
148
|
-
const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
|
|
149
|
-
const ct = Buffer.concat([cipher.update(Buffer.from(password, "utf8")), cipher.final()]);
|
|
150
|
-
return Buffer.concat([iv, ct]).toString("base64url");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── Internal ─────────────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
async function _startChild() {
|
|
156
|
-
_status = "starting";
|
|
157
|
-
_error = null;
|
|
158
|
-
|
|
159
|
-
if (!_cfg?.upstreams?.length) {
|
|
160
|
-
_status = "error";
|
|
161
|
-
_error = "No mount points configured — add them in the clauth dashboard";
|
|
162
|
-
return { error: _error };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 1. Resolve credential from vault
|
|
166
|
-
let pass;
|
|
167
|
-
try {
|
|
168
|
-
const vault = _vaultFn?.();
|
|
169
|
-
if (!vault?.password) {
|
|
170
|
-
_status = "error";
|
|
171
|
-
_error = "Vault is locked — unlock clauth first";
|
|
172
|
-
return { error: _error };
|
|
173
|
-
}
|
|
174
|
-
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
175
|
-
const credName = _cfg.credential || CRED_NAME;
|
|
176
|
-
const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, credName);
|
|
177
|
-
if (!result.value) {
|
|
178
|
-
_status = "error";
|
|
179
|
-
_error = `Credential '${credName}' not found — register it in the vault first`;
|
|
180
|
-
return { error: _error };
|
|
181
|
-
}
|
|
182
|
-
pass = result.value;
|
|
183
|
-
} catch (err) {
|
|
184
|
-
_status = "error";
|
|
185
|
-
_error = `Credential fetch failed: ${err.message}`;
|
|
186
|
-
return { error: _error };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 2. Locate rclone
|
|
190
|
-
const rclonePath = _findRclone();
|
|
191
|
-
if (!rclonePath) {
|
|
192
|
-
_status = "error";
|
|
193
|
-
_error = "rclone not found — install with: winget install Rclone.Rclone";
|
|
194
|
-
return { error: _error };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// 3. Write rclone config — combine backend with quoted entries for paths with spaces.
|
|
198
|
-
// Per rclone docs: "dir=remote:path with space" (quote the whole entry).
|
|
199
|
-
const mounts = _cfg.upstreams || [];
|
|
200
|
-
const combineUpstreams = mounts
|
|
201
|
-
.map(u => `"${u.name}=${u.path.replace(/\\/g, "/")}"`)
|
|
202
|
-
.join(" ");
|
|
203
|
-
const configContent = `[srv]\ntype = combine\nupstreams = ${combineUpstreams}\n`;
|
|
204
|
-
_cleanupConfig();
|
|
205
|
-
_configPath = join(tmpdir(), `.rclone-webdav-${process.pid}.conf`);
|
|
206
|
-
try {
|
|
207
|
-
writeFileSync(_configPath, configContent, { mode: 0o600 });
|
|
208
|
-
} catch (err) {
|
|
209
|
-
_status = "error";
|
|
210
|
-
_error = `Config write failed: ${err.message}`;
|
|
211
|
-
return { error: _error };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// 4. Spawn rclone
|
|
215
|
-
return new Promise(resolve => {
|
|
216
|
-
let settled = false;
|
|
217
|
-
const settle = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
_child = spawn(rclonePath, [
|
|
221
|
-
"serve", "webdav", "srv:",
|
|
222
|
-
"--config", _configPath,
|
|
223
|
-
"--addr", `127.0.0.1:${WEBDAV_PORT}`,
|
|
224
|
-
"--user", WEBDAV_USER,
|
|
225
|
-
"--pass", pass,
|
|
226
|
-
"--no-modtime",
|
|
227
|
-
"--vfs-cache-mode", "full",
|
|
228
|
-
"--vfs-write-back", "0s",
|
|
229
|
-
"--ignore-size",
|
|
230
|
-
"--cache-dir", join(process.env.TEMP || tmpdir(), "rclone-dav-cache"),
|
|
231
|
-
"--transfers", "4",
|
|
232
|
-
], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
233
|
-
|
|
234
|
-
} catch (spawnErr) {
|
|
235
|
-
_child = null;
|
|
236
|
-
_status = "error";
|
|
237
|
-
_error = spawnErr.message;
|
|
238
|
-
_cleanupConfig();
|
|
239
|
-
settle({ error: spawnErr.message });
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
_child.stdout.on("data", () => {});
|
|
244
|
-
|
|
245
|
-
_child.stderr.on("data", (chunk) => {
|
|
246
|
-
const line = chunk.toString().trim();
|
|
247
|
-
if (line.includes("Serving WebDAV") || line.includes("Listening on")) {
|
|
248
|
-
_status = "running";
|
|
249
|
-
_startedAt = new Date().toISOString();
|
|
250
|
-
settle({ ok: true });
|
|
251
|
-
}
|
|
252
|
-
// Surface errors but don't stop on every stderr line — rclone logs normally to stderr
|
|
253
|
-
if (/\berror\b/i.test(line) || /\bfatal\b/i.test(line)) {
|
|
254
|
-
_error = line.slice(0, 300);
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
_child.on("error", (err) => {
|
|
259
|
-
_child = null;
|
|
260
|
-
_status = "error";
|
|
261
|
-
_error = err.message;
|
|
262
|
-
_cleanupConfig();
|
|
263
|
-
settle({ error: err.message });
|
|
264
|
-
_scheduleRestart();
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
_child.on("exit", (code, signal) => {
|
|
268
|
-
_child = null;
|
|
269
|
-
if (signal === "SIGTERM" || signal === "SIGKILL" || code === 0) {
|
|
270
|
-
_status = "stopped";
|
|
271
|
-
_cleanupConfig();
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
_status = "error";
|
|
275
|
-
_error = `rclone exited unexpectedly (code=${code} signal=${signal})`;
|
|
276
|
-
_cleanupConfig();
|
|
277
|
-
settle({ error: _error });
|
|
278
|
-
_scheduleRestart();
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// Optimistic fallback: if rclone doesn't print "Serving WebDAV" within 2s, assume running
|
|
282
|
-
setTimeout(() => {
|
|
283
|
-
if (_status === "starting") {
|
|
284
|
-
_status = "running";
|
|
285
|
-
_startedAt = _startedAt || new Date().toISOString();
|
|
286
|
-
}
|
|
287
|
-
settle({ ok: true });
|
|
288
|
-
}, 2000);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function _scheduleRestart() {
|
|
293
|
-
const delay = RESTART_DELAYS[Math.min(_restartCount, RESTART_DELAYS.length - 1)];
|
|
294
|
-
_restartCount++;
|
|
295
|
-
setTimeout(() => {
|
|
296
|
-
if (_status === "stopped") return; // intentionally stopped, don't restart
|
|
297
|
-
_startChild();
|
|
298
|
-
}, delay);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function _cleanupConfig() {
|
|
302
|
-
if (_configPath) {
|
|
303
|
-
try { unlinkSync(_configPath); } catch {}
|
|
304
|
-
_configPath = null;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function _findRclone() {
|
|
309
|
-
const home = homedir();
|
|
310
|
-
const wingetBase = process.env.LOCALAPPDATA
|
|
311
|
-
? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Packages")
|
|
312
|
-
: null;
|
|
313
|
-
const execOpts = { stdio: "pipe", timeout: 4000, windowsHide: true };
|
|
314
|
-
|
|
315
|
-
const candidates = [
|
|
316
|
-
"rclone",
|
|
317
|
-
"C:\\Program Files\\rclone\\rclone.exe",
|
|
318
|
-
join(home, "scoop", "apps", "rclone", "current", "rclone.exe"),
|
|
319
|
-
"C:\\ProgramData\\scoop\\apps\\rclone\\current\\rclone.exe",
|
|
320
|
-
];
|
|
321
|
-
|
|
322
|
-
// Winget installs into a version-stamped subdirectory — glob for it
|
|
323
|
-
if (wingetBase) {
|
|
324
|
-
const wingetPkg = join(wingetBase, "Rclone.Rclone_Microsoft.Winget.Source_8wekyb3d8bbwe");
|
|
325
|
-
try {
|
|
326
|
-
for (const d of readdirSync(wingetPkg)) {
|
|
327
|
-
if (d.startsWith("rclone-")) candidates.push(join(wingetPkg, d, "rclone.exe"));
|
|
328
|
-
}
|
|
329
|
-
} catch {}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
for (const c of candidates) {
|
|
333
|
-
try {
|
|
334
|
-
execSync(`"${c}" version`, execOpts);
|
|
335
|
-
return c;
|
|
336
|
-
} catch {}
|
|
337
|
-
}
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
1
|
+
// cli/webdav-service.js
|
|
2
|
+
// WebDAV child-process supervisor — rclone serve webdav
|
|
3
|
+
//
|
|
4
|
+
// ISOLATION: This module has zero coupling to existing fs_* tool handlers.
|
|
5
|
+
// Rollback: set webdav.enabled=false in the config block (or remove it)
|
|
6
|
+
// + delete this file + remove the two-line import in serve.js.
|
|
7
|
+
//
|
|
8
|
+
// Design: lazy-start. The rclone child spawns on the first fs_dav_setup call.
|
|
9
|
+
// Subsequent calls return the running child's status without restarting.
|
|
10
|
+
// On child crash: exponential-backoff restart loop (2s → 5s → 15s → 60s).
|
|
11
|
+
|
|
12
|
+
import { spawn, execSync } from "child_process";
|
|
13
|
+
import { writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from "fs";
|
|
14
|
+
import { tmpdir, homedir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import crypto from "crypto";
|
|
17
|
+
import * as api from "./api.js";
|
|
18
|
+
import { deriveToken } from "./fingerprint.js";
|
|
19
|
+
|
|
20
|
+
const WEBDAV_PORT = 8620;
|
|
21
|
+
const WEBDAV_USER = "claude";
|
|
22
|
+
const CRED_NAME = "webdav-claude";
|
|
23
|
+
const RESTART_DELAYS = [2000, 5000, 15000, 60000];
|
|
24
|
+
|
|
25
|
+
const APPDATA = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
26
|
+
const CONFIG_PATH = join(APPDATA, "clauth", "webdav-config.json");
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CONFIG = {
|
|
29
|
+
enabled: true,
|
|
30
|
+
port: WEBDAV_PORT,
|
|
31
|
+
credential: CRED_NAME,
|
|
32
|
+
upstreams: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── Config persistence ──────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export function loadConfig() {
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
40
|
+
const cfg = JSON.parse(raw);
|
|
41
|
+
if (!Array.isArray(cfg.upstreams)) cfg.upstreams = [];
|
|
42
|
+
return { ...DEFAULT_CONFIG, ...cfg };
|
|
43
|
+
} catch {
|
|
44
|
+
return { ...DEFAULT_CONFIG };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveConfig(cfg) {
|
|
49
|
+
const dir = join(APPDATA, "clauth");
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
|
|
52
|
+
_fsCfgCache = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function addMount(name, mountPath) {
|
|
56
|
+
const cfg = loadConfig();
|
|
57
|
+
if (cfg.upstreams.some(u => u.name === name)) return { error: `Mount '${name}' already exists` };
|
|
58
|
+
cfg.upstreams.push({ name, path: mountPath });
|
|
59
|
+
saveConfig(cfg);
|
|
60
|
+
return { ok: true, upstreams: cfg.upstreams };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function removeMount(name) {
|
|
64
|
+
const cfg = loadConfig();
|
|
65
|
+
const before = cfg.upstreams.length;
|
|
66
|
+
cfg.upstreams = cfg.upstreams.filter(u => u.name !== name);
|
|
67
|
+
if (cfg.upstreams.length === before) return { error: `Mount '${name}' not found` };
|
|
68
|
+
saveConfig(cfg);
|
|
69
|
+
return { ok: true, upstreams: cfg.upstreams };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function updateMount(name, updates) {
|
|
73
|
+
const cfg = loadConfig();
|
|
74
|
+
const mount = cfg.upstreams.find(u => u.name === name);
|
|
75
|
+
if (!mount) return { error: `Mount '${name}' not found` };
|
|
76
|
+
if (updates.credential !== undefined) cfg.credential = updates.credential;
|
|
77
|
+
saveConfig(cfg);
|
|
78
|
+
return { ok: true, upstreams: cfg.upstreams };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let _fsCfgCache = null;
|
|
82
|
+
|
|
83
|
+
// ── Module state ────────────────────────────────────────────────
|
|
84
|
+
let _child = null;
|
|
85
|
+
let _configPath = null;
|
|
86
|
+
let _status = "stopped"; // stopped | starting | running | error
|
|
87
|
+
let _error = null;
|
|
88
|
+
let _restartCount = 0;
|
|
89
|
+
let _startedAt = null;
|
|
90
|
+
let _vaultFn = null; // () => { password, machineHash }
|
|
91
|
+
let _cfg = null;
|
|
92
|
+
|
|
93
|
+
// ── Public API ───────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Ensure the rclone WebDAV child is running.
|
|
97
|
+
* Idempotent — safe to call multiple times.
|
|
98
|
+
* Returns { ok: true } or { error: string }.
|
|
99
|
+
*/
|
|
100
|
+
export async function ensureRunning(vaultFn, config) {
|
|
101
|
+
if (vaultFn) _vaultFn = vaultFn;
|
|
102
|
+
if (config) _cfg = config;
|
|
103
|
+
if (!_cfg) _cfg = loadConfig();
|
|
104
|
+
|
|
105
|
+
if (_status === "running" && _child) return { ok: true };
|
|
106
|
+
if (_status === "starting") return { ok: true, message: "still starting" };
|
|
107
|
+
|
|
108
|
+
return _startChild();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Current supervisor status — safe to call at any time. */
|
|
112
|
+
export function getStatus() {
|
|
113
|
+
return {
|
|
114
|
+
status: _status,
|
|
115
|
+
port: WEBDAV_PORT,
|
|
116
|
+
url: _status === "running" ? `http://127.0.0.1:${WEBDAV_PORT}` : null,
|
|
117
|
+
error: _error || null,
|
|
118
|
+
restart_count: _restartCount,
|
|
119
|
+
started_at: _startedAt || null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Kill the child and clean up the temp config file. */
|
|
124
|
+
export function shutdown() {
|
|
125
|
+
if (_child) {
|
|
126
|
+
_child.removeAllListeners();
|
|
127
|
+
_child.kill("SIGTERM");
|
|
128
|
+
_child = null;
|
|
129
|
+
}
|
|
130
|
+
_cleanupConfig();
|
|
131
|
+
_status = "stopped";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* rclone-compatible AES-CTR "obscure" — same algorithm as `rclone obscure`.
|
|
136
|
+
* Used by fs_dav_setup to embed a non-plaintext credential in the mount script.
|
|
137
|
+
* The result can be passed as RCLONE_CONFIG_xxx_PASS.
|
|
138
|
+
*/
|
|
139
|
+
export function rcloneObscure(password) {
|
|
140
|
+
// Key from rclone source: lib/obscure/obscure.go cryptKey
|
|
141
|
+
const key = Buffer.from([
|
|
142
|
+
0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
|
|
143
|
+
0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
|
|
144
|
+
0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
|
|
145
|
+
0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
|
|
146
|
+
]);
|
|
147
|
+
const iv = crypto.randomBytes(16);
|
|
148
|
+
const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
|
|
149
|
+
const ct = Buffer.concat([cipher.update(Buffer.from(password, "utf8")), cipher.final()]);
|
|
150
|
+
return Buffer.concat([iv, ct]).toString("base64url");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Internal ─────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async function _startChild() {
|
|
156
|
+
_status = "starting";
|
|
157
|
+
_error = null;
|
|
158
|
+
|
|
159
|
+
if (!_cfg?.upstreams?.length) {
|
|
160
|
+
_status = "error";
|
|
161
|
+
_error = "No mount points configured — add them in the clauth dashboard";
|
|
162
|
+
return { error: _error };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 1. Resolve credential from vault
|
|
166
|
+
let pass;
|
|
167
|
+
try {
|
|
168
|
+
const vault = _vaultFn?.();
|
|
169
|
+
if (!vault?.password) {
|
|
170
|
+
_status = "error";
|
|
171
|
+
_error = "Vault is locked — unlock clauth first";
|
|
172
|
+
return { error: _error };
|
|
173
|
+
}
|
|
174
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
175
|
+
const credName = _cfg.credential || CRED_NAME;
|
|
176
|
+
const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, credName);
|
|
177
|
+
if (!result.value) {
|
|
178
|
+
_status = "error";
|
|
179
|
+
_error = `Credential '${credName}' not found — register it in the vault first`;
|
|
180
|
+
return { error: _error };
|
|
181
|
+
}
|
|
182
|
+
pass = result.value;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
_status = "error";
|
|
185
|
+
_error = `Credential fetch failed: ${err.message}`;
|
|
186
|
+
return { error: _error };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 2. Locate rclone
|
|
190
|
+
const rclonePath = _findRclone();
|
|
191
|
+
if (!rclonePath) {
|
|
192
|
+
_status = "error";
|
|
193
|
+
_error = "rclone not found — install with: winget install Rclone.Rclone";
|
|
194
|
+
return { error: _error };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 3. Write rclone config — combine backend with quoted entries for paths with spaces.
|
|
198
|
+
// Per rclone docs: "dir=remote:path with space" (quote the whole entry).
|
|
199
|
+
const mounts = _cfg.upstreams || [];
|
|
200
|
+
const combineUpstreams = mounts
|
|
201
|
+
.map(u => `"${u.name}=${u.path.replace(/\\/g, "/")}"`)
|
|
202
|
+
.join(" ");
|
|
203
|
+
const configContent = `[srv]\ntype = combine\nupstreams = ${combineUpstreams}\n`;
|
|
204
|
+
_cleanupConfig();
|
|
205
|
+
_configPath = join(tmpdir(), `.rclone-webdav-${process.pid}.conf`);
|
|
206
|
+
try {
|
|
207
|
+
writeFileSync(_configPath, configContent, { mode: 0o600 });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
_status = "error";
|
|
210
|
+
_error = `Config write failed: ${err.message}`;
|
|
211
|
+
return { error: _error };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 4. Spawn rclone
|
|
215
|
+
return new Promise(resolve => {
|
|
216
|
+
let settled = false;
|
|
217
|
+
const settle = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
_child = spawn(rclonePath, [
|
|
221
|
+
"serve", "webdav", "srv:",
|
|
222
|
+
"--config", _configPath,
|
|
223
|
+
"--addr", `127.0.0.1:${WEBDAV_PORT}`,
|
|
224
|
+
"--user", WEBDAV_USER,
|
|
225
|
+
"--pass", pass,
|
|
226
|
+
"--no-modtime",
|
|
227
|
+
"--vfs-cache-mode", "full",
|
|
228
|
+
"--vfs-write-back", "0s",
|
|
229
|
+
"--ignore-size",
|
|
230
|
+
"--cache-dir", join(process.env.TEMP || tmpdir(), "rclone-dav-cache"),
|
|
231
|
+
"--transfers", "4",
|
|
232
|
+
], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
233
|
+
|
|
234
|
+
} catch (spawnErr) {
|
|
235
|
+
_child = null;
|
|
236
|
+
_status = "error";
|
|
237
|
+
_error = spawnErr.message;
|
|
238
|
+
_cleanupConfig();
|
|
239
|
+
settle({ error: spawnErr.message });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_child.stdout.on("data", () => {});
|
|
244
|
+
|
|
245
|
+
_child.stderr.on("data", (chunk) => {
|
|
246
|
+
const line = chunk.toString().trim();
|
|
247
|
+
if (line.includes("Serving WebDAV") || line.includes("Listening on")) {
|
|
248
|
+
_status = "running";
|
|
249
|
+
_startedAt = new Date().toISOString();
|
|
250
|
+
settle({ ok: true });
|
|
251
|
+
}
|
|
252
|
+
// Surface errors but don't stop on every stderr line — rclone logs normally to stderr
|
|
253
|
+
if (/\berror\b/i.test(line) || /\bfatal\b/i.test(line)) {
|
|
254
|
+
_error = line.slice(0, 300);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
_child.on("error", (err) => {
|
|
259
|
+
_child = null;
|
|
260
|
+
_status = "error";
|
|
261
|
+
_error = err.message;
|
|
262
|
+
_cleanupConfig();
|
|
263
|
+
settle({ error: err.message });
|
|
264
|
+
_scheduleRestart();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
_child.on("exit", (code, signal) => {
|
|
268
|
+
_child = null;
|
|
269
|
+
if (signal === "SIGTERM" || signal === "SIGKILL" || code === 0) {
|
|
270
|
+
_status = "stopped";
|
|
271
|
+
_cleanupConfig();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
_status = "error";
|
|
275
|
+
_error = `rclone exited unexpectedly (code=${code} signal=${signal})`;
|
|
276
|
+
_cleanupConfig();
|
|
277
|
+
settle({ error: _error });
|
|
278
|
+
_scheduleRestart();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Optimistic fallback: if rclone doesn't print "Serving WebDAV" within 2s, assume running
|
|
282
|
+
setTimeout(() => {
|
|
283
|
+
if (_status === "starting") {
|
|
284
|
+
_status = "running";
|
|
285
|
+
_startedAt = _startedAt || new Date().toISOString();
|
|
286
|
+
}
|
|
287
|
+
settle({ ok: true });
|
|
288
|
+
}, 2000);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _scheduleRestart() {
|
|
293
|
+
const delay = RESTART_DELAYS[Math.min(_restartCount, RESTART_DELAYS.length - 1)];
|
|
294
|
+
_restartCount++;
|
|
295
|
+
setTimeout(() => {
|
|
296
|
+
if (_status === "stopped") return; // intentionally stopped, don't restart
|
|
297
|
+
_startChild();
|
|
298
|
+
}, delay);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function _cleanupConfig() {
|
|
302
|
+
if (_configPath) {
|
|
303
|
+
try { unlinkSync(_configPath); } catch {}
|
|
304
|
+
_configPath = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function _findRclone() {
|
|
309
|
+
const home = homedir();
|
|
310
|
+
const wingetBase = process.env.LOCALAPPDATA
|
|
311
|
+
? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Packages")
|
|
312
|
+
: null;
|
|
313
|
+
const execOpts = { stdio: "pipe", timeout: 4000, windowsHide: true };
|
|
314
|
+
|
|
315
|
+
const candidates = [
|
|
316
|
+
"rclone",
|
|
317
|
+
"C:\\Program Files\\rclone\\rclone.exe",
|
|
318
|
+
join(home, "scoop", "apps", "rclone", "current", "rclone.exe"),
|
|
319
|
+
"C:\\ProgramData\\scoop\\apps\\rclone\\current\\rclone.exe",
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
// Winget installs into a version-stamped subdirectory — glob for it
|
|
323
|
+
if (wingetBase) {
|
|
324
|
+
const wingetPkg = join(wingetBase, "Rclone.Rclone_Microsoft.Winget.Source_8wekyb3d8bbwe");
|
|
325
|
+
try {
|
|
326
|
+
for (const d of readdirSync(wingetPkg)) {
|
|
327
|
+
if (d.startsWith("rclone-")) candidates.push(join(wingetPkg, d, "rclone.exe"));
|
|
328
|
+
}
|
|
329
|
+
} catch {}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const c of candidates) {
|
|
333
|
+
try {
|
|
334
|
+
execSync(`"${c}" version`, execOpts);
|
|
335
|
+
return c;
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|