@astralyn/sash 0.1.0 → 0.1.2
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/CHANGELOG.md +48 -0
- package/README.md +22 -7
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/dist/api.js +14 -45
- package/dist/app-state.js +99 -0
- package/dist/autostart/command.js +24 -0
- package/dist/autostart/context.js +43 -0
- package/dist/autostart/files.js +60 -0
- package/dist/autostart/installation.js +48 -0
- package/dist/autostart/start.js +47 -0
- package/dist/autostart/windows-registry.js +88 -0
- package/dist/autostart/windows.js +59 -0
- package/dist/autostart-contract.js +31 -0
- package/dist/autostart-entry.js +10 -0
- package/dist/autostart.js +69 -0
- package/dist/cli.js +11 -11
- package/dist/commands/auto.js +15 -0
- package/dist/commands/lifecycle.js +5 -31
- package/dist/commands/logs.js +11 -4
- package/dist/commands/shared.js +2 -4
- package/dist/commands/status.js +6 -7
- package/dist/commands/update.js +7 -3
- package/dist/commands/web.js +19 -27
- package/dist/contracts.js +171 -335
- package/dist/core-config-validation.js +22 -38
- package/dist/core-update.js +126 -762
- package/dist/core.js +20 -46
- package/dist/daemon/app.js +132 -113
- package/dist/daemon/context.js +13 -7
- package/dist/daemon/entry.js +21 -38
- package/dist/daemon/errors.js +6 -1
- package/dist/daemon/handlers/autostart.js +18 -0
- package/dist/daemon/handlers/core.js +32 -9
- package/dist/daemon/handlers/daemon.js +32 -6
- package/dist/daemon/handlers/profiles.js +12 -2
- package/dist/daemon/handlers/settings.js +9 -36
- package/dist/daemon/router.js +40 -27
- package/dist/daemon/scheduler.js +3 -1
- package/dist/daemon/server.js +6 -3
- package/dist/daemon/web-auth.js +52 -0
- package/dist/daemon-auth.js +2 -2
- package/dist/daemon-client.js +18 -2
- package/dist/daemon-http.js +7 -0
- package/dist/daemon-lifecycle.js +19 -196
- package/dist/github.js +7 -2
- package/dist/http.js +7 -5
- package/dist/log-follow.js +2 -1
- package/dist/mihomo-config.js +7 -9
- package/dist/paths.js +5 -7
- package/dist/profile-model.js +87 -0
- package/dist/profile-service.js +250 -587
- package/dist/profiles.js +43 -171
- package/dist/runtime-lifecycle.js +112 -194
- package/dist/runtime-owner.js +37 -76
- package/dist/sash-client.js +63 -21
- package/dist/settings-service.js +29 -228
- package/dist/settings.js +61 -285
- package/dist/status.js +24 -40
- package/dist/supervisor.js +1 -14
- package/dist/sysproxy/common.js +5 -45
- package/dist/sysproxy/factory.js +24 -65
- package/dist/sysproxy/snapshot.js +24 -257
- package/dist/sysproxy.js +1 -4
- package/dist/system-proxy-manager.js +37 -35
- package/dist/ui/assets/{ConnectionsView-DNGmZBSU.js → ConnectionsView-BgXQM6Z4.js} +1 -1
- package/dist/ui/assets/{LogsView-fSiaxQ13.js → LogsView-2f542P8z.js} +1 -1
- package/dist/ui/assets/{PaginationFooter-B3kHzRfB.js → PaginationFooter-cj1tcZej.js} +1 -1
- package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
- package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
- package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
- package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
- package/dist/ui/assets/{RulesView-D9vZBiJ1.js → RulesView-CtKvlckZ.js} +1 -1
- package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
- package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
- package/dist/ui/assets/{0be242294f7d791af850c6df38ac78a0-2cL6Ntwf.woff2 → e2a57555d97d0b02b45d9418eb6ee295-D9nhF3rM.woff2} +0 -0
- package/dist/ui/assets/index-CBInDvdJ.js +19 -0
- package/dist/ui/assets/index-mOLy7fkB.css +1 -0
- package/dist/ui/assets/{theme-BNq4FkXS.js → theme-D40MF8cQ.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/dist/web-bootstrap.js +113 -0
- package/docs/architecture-proposal.md +109 -0
- package/docs/autostart.md +101 -0
- package/docs/backend.md +92 -216
- package/docs/frontend.md +61 -97
- package/docs/usage.md +88 -186
- package/package.json +10 -6
- package/dist/commands/upgrade.js +0 -43
- package/dist/core-install-transaction.js +0 -114
- package/dist/core-update-coordination.js +0 -105
- package/dist/core-update-service.js +0 -245
- package/dist/managed-state-transaction.js +0 -377
- package/dist/offline-mutation.js +0 -58
- package/dist/profile-migration.js +0 -101
- package/dist/runtime-recovery.js +0 -31
- package/dist/sysproxy/darwin.js +0 -287
- package/dist/sysproxy/gnome.js +0 -181
- package/dist/sysproxy/legacy.js +0 -58
- package/dist/tun-guidance.js +0 -11
- package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
- package/dist/ui/assets/CodeEditorModal-CFEnWsyh.js +0 -14
- package/dist/ui/assets/ProfileEditorDialog-BydoZthX.js +0 -1
- package/dist/ui/assets/ProfilesView-Btc1DOxE.js +0 -2
- package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
- package/dist/ui/assets/SettingsFileDialog-CNFEAVs4.js +0 -1
- package/dist/ui/assets/SettingsView-BlDhZkXQ.js +0 -2
- package/dist/ui/assets/SettingsView-CngS3vBM.css +0 -1
- package/dist/ui/assets/index-B61V60w_.js +0 -19
- package/dist/ui/assets/index-bkyxJG8J.css +0 -1
package/dist/daemon-lifecycle.js
CHANGED
|
@@ -3,13 +3,12 @@ import fs from "node:fs";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { loadSettings } from "./app-state.js";
|
|
6
7
|
import { SashDaemonClient } from "./daemon-client.js";
|
|
7
8
|
import { isCanonicalIsoTimestamp, isPlainObject } from "./json-shape.js";
|
|
8
|
-
import { log } from "./log.js";
|
|
9
9
|
import { boundedLogTailSince, logTailCursor } from "./log-follow.js";
|
|
10
10
|
import { sashLayout } from "./paths.js";
|
|
11
|
-
import { buildSanitizedEnv,
|
|
12
|
-
import { loadSettings } from "./settings.js";
|
|
11
|
+
import { buildSanitizedEnv, isProcessAlive, killProcessGracefully, withPrivateAppendLogFds, } from "./process.js";
|
|
13
12
|
import { readStateLockRecord, withStateLock } from "./state-lock.js";
|
|
14
13
|
export function readDaemonPidRecord(layout = sashLayout()) {
|
|
15
14
|
try {
|
|
@@ -50,41 +49,11 @@ export async function evaluateDaemon(layout = sashLayout(), settings) {
|
|
|
50
49
|
lease = readStateLockRecord(layout.daemonLeaseFile);
|
|
51
50
|
}
|
|
52
51
|
catch {
|
|
53
|
-
return {
|
|
54
|
-
kind: "unhealthy",
|
|
55
|
-
running: true,
|
|
56
|
-
healthy: false,
|
|
57
|
-
...(record ? { stalePidFile: true } : {}),
|
|
58
|
-
};
|
|
52
|
+
return { kind: "unhealthy", running: true, healthy: false };
|
|
59
53
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const legacyClient = new SashDaemonClient(record.port || s.daemonPort, s.daemonSecret);
|
|
64
|
-
try {
|
|
65
|
-
const health = await legacyClient.health();
|
|
66
|
-
if (health.token === record.token && health.pid === record.pid) {
|
|
67
|
-
return {
|
|
68
|
-
kind: "healthy",
|
|
69
|
-
running: true,
|
|
70
|
-
healthy: true,
|
|
71
|
-
legacyOwnership: true,
|
|
72
|
-
pid: record.pid,
|
|
73
|
-
port: record.port,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
}
|
|
79
|
-
return {
|
|
80
|
-
kind: "unhealthy",
|
|
81
|
-
running: true,
|
|
82
|
-
healthy: false,
|
|
83
|
-
legacyOwnership: true,
|
|
84
|
-
pid: record.pid,
|
|
85
|
-
...(lease ? { staleLeaseFile: true } : {}),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
54
|
+
const liveLease = lease && isProcessAlive(lease.pid);
|
|
55
|
+
const liveRecord = record && isProcessAlive(record.pid);
|
|
56
|
+
if (!liveLease && !liveRecord)
|
|
88
57
|
return {
|
|
89
58
|
kind: "stopped",
|
|
90
59
|
running: false,
|
|
@@ -93,46 +62,19 @@ export async function evaluateDaemon(layout = sashLayout(), settings) {
|
|
|
93
62
|
...(record ? { stalePidFile: true } : {}),
|
|
94
63
|
...(lease ? { staleLeaseFile: true } : {}),
|
|
95
64
|
};
|
|
65
|
+
if (!lease || !record || !liveLease || !liveRecord || lease.pid !== record.pid) {
|
|
66
|
+
return { kind: "unhealthy", running: true, healthy: false, pid: lease?.pid ?? record?.pid };
|
|
96
67
|
}
|
|
97
|
-
if (!record || record.pid !== lease.pid || !isProcessAlive(record.pid)) {
|
|
98
|
-
return {
|
|
99
|
-
kind: "unhealthy",
|
|
100
|
-
running: true,
|
|
101
|
-
healthy: false,
|
|
102
|
-
pid: lease.pid,
|
|
103
|
-
...(record ? { stalePidFile: true } : {}),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
const s = settings ?? loadSettings(layout);
|
|
107
|
-
const client = new SashDaemonClient(record.port || s.daemonPort, s.daemonSecret);
|
|
108
68
|
try {
|
|
69
|
+
const client = new SashDaemonClient(record.port, (settings ?? loadSettings(layout)).daemonSecret);
|
|
109
70
|
const health = await client.health();
|
|
110
71
|
if (health.token === record.token && health.pid === record.pid) {
|
|
111
|
-
return {
|
|
112
|
-
kind: "healthy",
|
|
113
|
-
running: true,
|
|
114
|
-
pid: record.pid,
|
|
115
|
-
healthy: true,
|
|
116
|
-
port: record.port,
|
|
117
|
-
};
|
|
72
|
+
return { kind: "healthy", running: true, healthy: true, pid: record.pid, port: record.port };
|
|
118
73
|
}
|
|
119
|
-
return {
|
|
120
|
-
kind: "unhealthy",
|
|
121
|
-
running: true,
|
|
122
|
-
healthy: false,
|
|
123
|
-
stalePidFile: true,
|
|
124
|
-
pid: record.pid,
|
|
125
|
-
};
|
|
126
74
|
}
|
|
127
75
|
catch {
|
|
128
|
-
return {
|
|
129
|
-
kind: "unhealthy",
|
|
130
|
-
running: true,
|
|
131
|
-
pid: record.pid,
|
|
132
|
-
healthy: false,
|
|
133
|
-
port: record.port,
|
|
134
|
-
};
|
|
135
76
|
}
|
|
77
|
+
return { kind: "unhealthy", running: true, healthy: false, pid: record.pid, port: record.port };
|
|
136
78
|
}
|
|
137
79
|
function resolveDaemonEntryPath() {
|
|
138
80
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -242,137 +184,18 @@ export async function ensureDaemon(opts = {}) {
|
|
|
242
184
|
return;
|
|
243
185
|
await spawnDaemon({ layout, settings });
|
|
244
186
|
}
|
|
245
|
-
async function waitForDaemonExit(pid, timeoutMs) {
|
|
246
|
-
const deadline = Date.now() + timeoutMs;
|
|
247
|
-
while (Date.now() < deadline && isProcessAlive(pid))
|
|
248
|
-
await sleep(100);
|
|
249
|
-
if (isProcessAlive(pid)) {
|
|
250
|
-
throw new Error(`sashd PID ${pid} did not exit after maintenance shutdown`);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
export async function prepareDaemonMaintenance(layout, settings, purpose, deps = {}) {
|
|
254
|
-
const daemonState = await (deps.evaluateDaemon ?? evaluateDaemon)(layout, settings);
|
|
255
|
-
if (daemonState.kind === "unhealthy") {
|
|
256
|
-
throw new Error(`sashd is running but unresponsive; refusing a competing ${purpose}`);
|
|
257
|
-
}
|
|
258
|
-
if (daemonState.kind === "stopped") {
|
|
259
|
-
return { daemonWasRunning: false, legacyDaemon: false, coreWasRunning: false };
|
|
260
|
-
}
|
|
261
|
-
const client = (deps.clientFactory ?? ((port, secret) => new SashDaemonClient(port, secret)))(daemonState.port, settings.daemonSecret);
|
|
262
|
-
if (daemonState.legacyOwnership) {
|
|
263
|
-
if (!client.status || !client.shutdown) {
|
|
264
|
-
throw new Error("Legacy sashd maintenance requires status and shutdown support");
|
|
265
|
-
}
|
|
266
|
-
log.warn("legacy sashd detected; using the pre-maintenance compatibility path");
|
|
267
|
-
const coreWasRunning = (await client.status()).core.running;
|
|
268
|
-
await client.shutdown();
|
|
269
|
-
await (deps.waitForDaemonExit ?? waitForDaemonExit)(daemonState.pid, 20_000);
|
|
270
|
-
return { daemonWasRunning: true, legacyDaemon: true, coreWasRunning };
|
|
271
|
-
}
|
|
272
|
-
const snapshot = await client.maintenanceShutdown();
|
|
273
|
-
await (deps.waitForDaemonExit ?? waitForDaemonExit)(daemonState.pid, 20_000);
|
|
274
|
-
return { daemonWasRunning: true, legacyDaemon: false, coreWasRunning: snapshot.coreWasRunning };
|
|
275
|
-
}
|
|
276
187
|
export async function stopDaemonFromCli(opts = {}) {
|
|
277
188
|
const layout = opts.layout ?? sashLayout();
|
|
278
189
|
const settings = opts.settings ?? loadSettings(layout);
|
|
279
|
-
const
|
|
280
|
-
if (
|
|
281
|
-
try {
|
|
282
|
-
const lease = readStateLockRecord(layout.daemonLeaseFile);
|
|
283
|
-
if (!lease || !isProcessAlive(lease.pid))
|
|
284
|
-
return true;
|
|
285
|
-
log.warn(`sashd is still starting or unresponsive (PID=${lease.pid}); try stopping again.`);
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
log.warn("Refusing to stop sashd: singleton ownership record is corrupt.");
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
const pid = record.pid;
|
|
294
|
-
if (!isProcessAlive(pid))
|
|
190
|
+
const state = await evaluateDaemon(layout, settings);
|
|
191
|
+
if (state.kind === "stopped")
|
|
295
192
|
return true;
|
|
296
|
-
|
|
297
|
-
try {
|
|
298
|
-
lease = readStateLockRecord(layout.daemonLeaseFile);
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
log.warn("Refusing to stop sashd: singleton ownership record is corrupt.");
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
304
|
-
if (lease && (!isProcessAlive(lease.pid) || lease.pid !== pid)) {
|
|
305
|
-
log.warn(`Refusing to stop PID ${pid}: daemon PID metadata does not match singleton ownership.`);
|
|
193
|
+
if (state.kind !== "healthy")
|
|
306
194
|
return false;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const legacyOwnership = !lease;
|
|
310
|
-
const healthMatchesRecord = async () => {
|
|
311
|
-
try {
|
|
312
|
-
const health = await client.health();
|
|
313
|
-
return health.token === record.token && health.pid === pid;
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
return false;
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
if (!(await healthMatchesRecord())) {
|
|
320
|
-
log.warn(`Refusing to stop unverified sashd PID ${pid}.`);
|
|
321
|
-
return false;
|
|
322
|
-
}
|
|
323
|
-
try {
|
|
324
|
-
await client.shutdown();
|
|
325
|
-
}
|
|
326
|
-
catch {
|
|
327
|
-
}
|
|
195
|
+
const client = new SashDaemonClient(state.port, settings.daemonSecret);
|
|
196
|
+
await client.shutdown();
|
|
328
197
|
const deadline = Date.now() + (opts.timeoutMs ?? 20_000);
|
|
329
|
-
while (Date.now() < deadline
|
|
330
|
-
await sleep(
|
|
331
|
-
|
|
332
|
-
if (!isProcessAlive(pid))
|
|
333
|
-
return true;
|
|
334
|
-
if (legacyOwnership) {
|
|
335
|
-
log.warn(`Legacy sashd PID ${pid} did not stop gracefully; refusing force termination without a singleton lease.`);
|
|
336
|
-
return false;
|
|
337
|
-
}
|
|
338
|
-
return withStateLock(layout.mutationLockFile, { purpose: "verify forced sashd stop", timeoutMs: 10_000 }, async () => {
|
|
339
|
-
if (!isProcessAlive(pid))
|
|
340
|
-
return true;
|
|
341
|
-
try {
|
|
342
|
-
const status = await client.status(true);
|
|
343
|
-
const actual = status.systemProxy.actual;
|
|
344
|
-
if (status.daemon.pid !== pid ||
|
|
345
|
-
status.core.running ||
|
|
346
|
-
status.systemProxy.applied ||
|
|
347
|
-
!actual ||
|
|
348
|
-
actual.enabled ||
|
|
349
|
-
(actual.supported && Boolean(actual.details)) ||
|
|
350
|
-
fs.existsSync(layout.systemProxyStateFile) ||
|
|
351
|
-
fs.existsSync(`${layout.systemProxyStateFile}.lock`)) {
|
|
352
|
-
log.warn(`Refusing to force-terminate sashd PID ${pid}: managed runtime cleanup is incomplete or unknown.`);
|
|
353
|
-
return false;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
catch {
|
|
357
|
-
log.warn(`Refusing to force-terminate unresponsive sashd PID ${pid}: runtime cleanup cannot be verified.`);
|
|
358
|
-
return false;
|
|
359
|
-
}
|
|
360
|
-
const verifyDaemonIdentity = async () => {
|
|
361
|
-
if (!(await healthMatchesRecord()))
|
|
362
|
-
return "mismatch";
|
|
363
|
-
return commandLineContains(pid, "daemon-entry") ? "match" : "unknown";
|
|
364
|
-
};
|
|
365
|
-
if ((await verifyDaemonIdentity()) !== "match") {
|
|
366
|
-
log.warn(`Refusing to terminate PID ${pid}: process identity no longer matches sashd. Verify manually.`);
|
|
367
|
-
return false;
|
|
368
|
-
}
|
|
369
|
-
const killed = await killProcessGracefully(pid, {
|
|
370
|
-
timeoutMs: 4000,
|
|
371
|
-
verify: verifyDaemonIdentity,
|
|
372
|
-
});
|
|
373
|
-
if (killed)
|
|
374
|
-
return true;
|
|
375
|
-
log.error(`sashd process is still running after termination attempt (PID=${pid}).`);
|
|
376
|
-
return false;
|
|
377
|
-
});
|
|
198
|
+
while (isProcessAlive(state.pid) && Date.now() < deadline)
|
|
199
|
+
await sleep(100);
|
|
200
|
+
return !isProcessAlive(state.pid);
|
|
378
201
|
}
|
package/dist/github.js
CHANGED
|
@@ -21,10 +21,11 @@ function ghTokenHeaders() {
|
|
|
21
21
|
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
22
22
|
return token ? { authorization: `Bearer ${token}` } : {};
|
|
23
23
|
}
|
|
24
|
-
export async function resolveLatestTag(repo) {
|
|
24
|
+
export async function resolveLatestTag(repo, signal) {
|
|
25
25
|
const latestUrl = `https://github.com/${repo}/releases/latest`;
|
|
26
26
|
try {
|
|
27
27
|
const res = await fetchWithRetry(latestUrl, {
|
|
28
|
+
signal,
|
|
28
29
|
manualRedirect: true,
|
|
29
30
|
attempts: 2,
|
|
30
31
|
deadlineMs: 15_000,
|
|
@@ -50,6 +51,7 @@ export async function resolveLatestTag(repo) {
|
|
|
50
51
|
}
|
|
51
52
|
const apiUrl = `https://api.github.com/repos/${repo}/releases/latest`;
|
|
52
53
|
const res = await fetchWithRetry(apiUrl, {
|
|
54
|
+
signal,
|
|
53
55
|
headers: {
|
|
54
56
|
accept: "application/vnd.github+json",
|
|
55
57
|
...ghTokenHeaders(),
|
|
@@ -74,9 +76,10 @@ export async function resolveLatestTag(repo) {
|
|
|
74
76
|
}
|
|
75
77
|
return data.tag_name;
|
|
76
78
|
}
|
|
77
|
-
export async function listReleaseAssets(repo, tag) {
|
|
79
|
+
export async function listReleaseAssets(repo, tag, signal) {
|
|
78
80
|
const apiUrl = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
|
79
81
|
const res = await fetchWithRetry(apiUrl, {
|
|
82
|
+
signal,
|
|
80
83
|
headers: {
|
|
81
84
|
accept: "application/vnd.github+json",
|
|
82
85
|
...ghTokenHeaders(),
|
|
@@ -157,6 +160,7 @@ export async function downloadReleaseAsset(opts) {
|
|
|
157
160
|
const deadlineAt = Date.now() + deadlineMs;
|
|
158
161
|
let lastError;
|
|
159
162
|
for (const url of urls) {
|
|
163
|
+
opts.signal?.throwIfAborted();
|
|
160
164
|
const remainingMs = deadlineAt - Date.now();
|
|
161
165
|
if (remainingMs <= 0) {
|
|
162
166
|
lastError = new Error(`Core asset download deadline exceeded after ${deadlineMs}ms`);
|
|
@@ -164,6 +168,7 @@ export async function downloadReleaseAsset(opts) {
|
|
|
164
168
|
}
|
|
165
169
|
try {
|
|
166
170
|
await downloadToFile(url, opts.dest, {
|
|
171
|
+
signal: opts.signal,
|
|
167
172
|
allowedHosts: GITHUB_DOWNLOAD_HOSTS,
|
|
168
173
|
maxBytes: RELEASE_ASSET_SIZE_LIMIT,
|
|
169
174
|
requireHttps: true,
|
package/dist/http.js
CHANGED
|
@@ -87,6 +87,7 @@ export async function fetchWithRetry(url, opts = {}) {
|
|
|
87
87
|
const bodyInactivityTimeoutMs = positiveTimeout(opts.bodyInactivityTimeoutMs, 30_000, "bodyInactivityTimeoutMs");
|
|
88
88
|
const deadlineMs = positiveTimeout(opts.deadlineMs, 60_000, "deadlineMs");
|
|
89
89
|
const deadline = new AbortController();
|
|
90
|
+
const signal = opts.signal ? AbortSignal.any([deadline.signal, opts.signal]) : deadline.signal;
|
|
90
91
|
const deadlineTimer = setTimeout(() => {
|
|
91
92
|
deadline.abort(new Error(`HTTP request deadline exceeded after ${deadlineMs}ms`));
|
|
92
93
|
}, deadlineMs);
|
|
@@ -108,7 +109,7 @@ export async function fetchWithRetry(url, opts = {}) {
|
|
|
108
109
|
body: opts.body,
|
|
109
110
|
headersTimeout: headersTimeoutMs,
|
|
110
111
|
bodyTimeout: bodyInactivityTimeoutMs,
|
|
111
|
-
signal
|
|
112
|
+
signal,
|
|
112
113
|
dispatcher: pickDispatcher(opts),
|
|
113
114
|
});
|
|
114
115
|
if (RETRYABLE_STATUS.has(res.statusCode) && attempt < attempts) {
|
|
@@ -166,10 +167,10 @@ export async function fetchWithRetry(url, opts = {}) {
|
|
|
166
167
|
}
|
|
167
168
|
catch (err) {
|
|
168
169
|
lastErr = err;
|
|
169
|
-
if (
|
|
170
|
+
if (signal.aborted || attempt === attempts)
|
|
170
171
|
break;
|
|
171
|
-
await sleep(Math.min(4_000, 300 * 2 ** (attempt - 1)) + Math.random() * 200,
|
|
172
|
-
if (
|
|
172
|
+
await sleep(Math.min(4_000, 300 * 2 ** (attempt - 1)) + Math.random() * 200, signal);
|
|
173
|
+
if (signal.aborted)
|
|
173
174
|
break;
|
|
174
175
|
}
|
|
175
176
|
}
|
|
@@ -202,6 +203,7 @@ export async function downloadToFile(url, dest, opts) {
|
|
|
202
203
|
const deadlineMs = positiveTimeout(opts.deadlineMs, 15 * 60_000, "deadlineMs");
|
|
203
204
|
const maxRedirects = 5;
|
|
204
205
|
const deadline = new AbortController();
|
|
206
|
+
const signal = opts.signal ? AbortSignal.any([deadline.signal, opts.signal]) : deadline.signal;
|
|
205
207
|
const deadlineTimer = setTimeout(() => {
|
|
206
208
|
deadline.abort(new Error(`Download deadline exceeded after ${deadlineMs}ms`));
|
|
207
209
|
}, deadlineMs);
|
|
@@ -217,7 +219,7 @@ export async function downloadToFile(url, dest, opts) {
|
|
|
217
219
|
headers: { "user-agent": USER_AGENT, ...opts.headers },
|
|
218
220
|
headersTimeout: 30_000,
|
|
219
221
|
bodyTimeout: stallMs,
|
|
220
|
-
signal
|
|
222
|
+
signal,
|
|
221
223
|
dispatcher,
|
|
222
224
|
});
|
|
223
225
|
const isRedirect = res.statusCode >= 300 && res.statusCode < 400 && Boolean(res.headers.location);
|
package/dist/log-follow.js
CHANGED
|
@@ -193,7 +193,8 @@ export function followLogFile(file, options) {
|
|
|
193
193
|
if (watcher || stopped)
|
|
194
194
|
return;
|
|
195
195
|
try {
|
|
196
|
-
const
|
|
196
|
+
const directory = fs.realpathSync.native(path.dirname(file));
|
|
197
|
+
const watched = fs.watch(directory, (_event, filename) => {
|
|
197
198
|
if (filename === null || filename.toString() === path.basename(file))
|
|
198
199
|
scheduleDrain();
|
|
199
200
|
});
|
package/dist/mihomo-config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import YAML from "yaml";
|
|
3
3
|
import { ERROR_BODY_LIMIT, fetchWithRetry } from "./http.js";
|
|
4
|
+
import { isPlainObject } from "./json-shape.js";
|
|
4
5
|
export function buildDefaultConfig() {
|
|
5
6
|
return {
|
|
6
7
|
mode: "rule",
|
|
@@ -226,7 +227,7 @@ export function resolveSubscriptionRedirect(initial, current, location) {
|
|
|
226
227
|
}
|
|
227
228
|
return target;
|
|
228
229
|
}
|
|
229
|
-
export async function fetchSubscriptionProfile(url) {
|
|
230
|
+
export async function fetchSubscriptionProfile(url, signal) {
|
|
230
231
|
let initial;
|
|
231
232
|
try {
|
|
232
233
|
initial = new URL(url);
|
|
@@ -246,6 +247,7 @@ export async function fetchSubscriptionProfile(url) {
|
|
|
246
247
|
throw new Error(`Subscription fetch deadline exceeded after ${PROFILE_FETCH_DEADLINE_MS}ms`);
|
|
247
248
|
}
|
|
248
249
|
res = await fetchWithRetry(current.href, {
|
|
250
|
+
signal,
|
|
249
251
|
attempts: 3,
|
|
250
252
|
deadlineMs: remainingDeadlineMs,
|
|
251
253
|
manualRedirect: true,
|
|
@@ -312,14 +314,10 @@ export function overlayManagedKeys(base, settings) {
|
|
|
312
314
|
out["allow-lan"] = settings.allowLan;
|
|
313
315
|
out["external-controller"] = settings.controller;
|
|
314
316
|
out.secret = settings.secret;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
"auto-route": true,
|
|
320
|
-
"auto-detect-interface": true,
|
|
321
|
-
"dns-hijack": ["any:53"],
|
|
322
|
-
};
|
|
317
|
+
out.tun = { enable: false };
|
|
318
|
+
if (Array.isArray(out.listeners) &&
|
|
319
|
+
out.listeners.some((listener) => isPlainObject(listener) && listener.type === "tun")) {
|
|
320
|
+
throw new Error("TUN listeners are unavailable in this release");
|
|
323
321
|
}
|
|
324
322
|
return out;
|
|
325
323
|
}
|
package/dist/paths.js
CHANGED
|
@@ -24,27 +24,25 @@ export function sashRoot() {
|
|
|
24
24
|
}
|
|
25
25
|
export function sashLayout(root = sashRoot()) {
|
|
26
26
|
const exeName = process.platform === "win32" ? "mihomo.exe" : "mihomo";
|
|
27
|
+
const userControlDir = process.platform === "win32"
|
|
28
|
+
? path.join(envPathOr(path.join(os.homedir(), "AppData", "Local"), process.env.LOCALAPPDATA), "Sash")
|
|
29
|
+
: path.join(root, "state");
|
|
27
30
|
return {
|
|
28
31
|
root,
|
|
29
32
|
binDir: path.join(root, "bin"),
|
|
30
33
|
coreExe: path.join(root, "bin", exeName),
|
|
31
|
-
configFile: path.join(root, "config.yaml"),
|
|
34
|
+
configFile: path.join(root, "runtime", "config.yaml"),
|
|
32
35
|
settingsFile: path.join(root, "sash.json"),
|
|
33
36
|
profilesDir: path.join(root, "profiles"),
|
|
34
|
-
profilesIndexFile: path.join(root, "profiles", "index.json"),
|
|
35
37
|
uiDir: path.join(root, "ui"),
|
|
36
38
|
stateDir: path.join(root, "state"),
|
|
37
39
|
pidFile: path.join(root, "state", "sash.pid"),
|
|
38
40
|
daemonPidFile: path.join(root, "state", "sashd.pid"),
|
|
39
41
|
daemonLeaseFile: path.join(root, "state", "sashd.lock"),
|
|
40
42
|
daemonStartLockFile: path.join(root, "state", "sashd-start.lock"),
|
|
41
|
-
runtimeOperationLockFile: path.join(root, "state", "runtime.lock"),
|
|
42
|
-
mutationLockFile: path.join(root, "state", "mutation.lock"),
|
|
43
|
-
settingsLockFile: path.join(root, "state", "settings.lock"),
|
|
44
43
|
systemProxyStateFile: path.join(root, "state", "system-proxy.json"),
|
|
45
|
-
|
|
44
|
+
systemProxyLockFile: path.join(userControlDir, "system-proxy.lock"),
|
|
46
45
|
installFile: path.join(root, "state", "install.json"),
|
|
47
|
-
coreInstallTransactionFile: path.join(root, "state", "core-install-transaction.json"),
|
|
48
46
|
coreUpdateTransactionFile: path.join(root, "state", "core-update-transaction.json"),
|
|
49
47
|
logsDir: path.join(root, "logs"),
|
|
50
48
|
coreLogFile: path.join(root, "logs", "mihomo.log"),
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isCanonicalIsoTimestamp, isPlainObject } from "./json-shape.js";
|
|
2
|
+
export const DEFAULT_PROFILE_INTERVAL_HOURS = 24;
|
|
3
|
+
export const MAX_PROFILE_INTERVAL_HOURS = 24 * 365;
|
|
4
|
+
export function parseProfilesIndex(value) {
|
|
5
|
+
if (!isPlainObject(value) ||
|
|
6
|
+
Object.keys(value).some((key) => key !== "activeId" && key !== "profiles") ||
|
|
7
|
+
!Array.isArray(value.profiles) ||
|
|
8
|
+
(value.activeId !== null && typeof value.activeId !== "string"))
|
|
9
|
+
throw new Error("Profiles index has an invalid shape");
|
|
10
|
+
const profiles = value.profiles.map(parseProfileMeta);
|
|
11
|
+
const ids = new Set(profiles.map((profile) => profile.id));
|
|
12
|
+
if (ids.size !== profiles.length)
|
|
13
|
+
throw new Error("Profiles index has duplicate profile ids");
|
|
14
|
+
if (value.activeId !== null && !ids.has(value.activeId))
|
|
15
|
+
throw new Error("Selected profile is missing");
|
|
16
|
+
return { activeId: value.activeId, profiles };
|
|
17
|
+
}
|
|
18
|
+
export function parseProfileMeta(item) {
|
|
19
|
+
if (!isPlainObject(item) ||
|
|
20
|
+
typeof item.id !== "string" ||
|
|
21
|
+
!/^[0-9]+$/.test(item.id) ||
|
|
22
|
+
typeof item.revision !== "number" ||
|
|
23
|
+
!Number.isSafeInteger(item.revision) ||
|
|
24
|
+
item.revision < 1 ||
|
|
25
|
+
typeof item.name !== "string" ||
|
|
26
|
+
!item.name.trim() ||
|
|
27
|
+
item.name.length > 120 ||
|
|
28
|
+
typeof item.url !== "string" ||
|
|
29
|
+
typeof item.intervalHours !== "number" ||
|
|
30
|
+
!Number.isSafeInteger(item.intervalHours) ||
|
|
31
|
+
item.intervalHours < 0 ||
|
|
32
|
+
item.intervalHours > MAX_PROFILE_INTERVAL_HOURS ||
|
|
33
|
+
!isCanonicalIsoTimestamp(item.createdAt) ||
|
|
34
|
+
!isCanonicalIsoTimestamp(item.updatedAt))
|
|
35
|
+
throw new Error("Profiles index has invalid profile metadata");
|
|
36
|
+
if (item.url) {
|
|
37
|
+
const url = new URL(item.url);
|
|
38
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
39
|
+
throw new Error("Invalid profile URL");
|
|
40
|
+
}
|
|
41
|
+
let subInfo;
|
|
42
|
+
if (item.subInfo !== undefined) {
|
|
43
|
+
const info = item.subInfo;
|
|
44
|
+
if (!isPlainObject(info) ||
|
|
45
|
+
typeof info.upload !== "number" ||
|
|
46
|
+
!Number.isFinite(info.upload) ||
|
|
47
|
+
info.upload < 0 ||
|
|
48
|
+
typeof info.download !== "number" ||
|
|
49
|
+
!Number.isFinite(info.download) ||
|
|
50
|
+
info.download < 0 ||
|
|
51
|
+
typeof info.total !== "number" ||
|
|
52
|
+
!Number.isFinite(info.total) ||
|
|
53
|
+
info.total < 0 ||
|
|
54
|
+
(info.expire !== undefined &&
|
|
55
|
+
(typeof info.expire !== "number" || !Number.isFinite(info.expire) || info.expire < 0)))
|
|
56
|
+
throw new Error("Invalid subscription quota");
|
|
57
|
+
subInfo = {
|
|
58
|
+
upload: info.upload,
|
|
59
|
+
download: info.download,
|
|
60
|
+
total: info.total,
|
|
61
|
+
...(typeof info.expire === "number" ? { expire: info.expire } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (item.homePage !== undefined) {
|
|
65
|
+
if (typeof item.homePage !== "string")
|
|
66
|
+
throw new Error("Invalid profile home page");
|
|
67
|
+
const url = new URL(item.homePage);
|
|
68
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
69
|
+
throw new Error("Invalid profile home page");
|
|
70
|
+
}
|
|
71
|
+
if (item.lastError !== undefined &&
|
|
72
|
+
(typeof item.lastError !== "string" || item.lastError.length > 300)) {
|
|
73
|
+
throw new Error("Invalid profile update error");
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
id: item.id,
|
|
77
|
+
revision: item.revision,
|
|
78
|
+
name: item.name,
|
|
79
|
+
url: item.url,
|
|
80
|
+
intervalHours: item.intervalHours,
|
|
81
|
+
createdAt: item.createdAt,
|
|
82
|
+
updatedAt: item.updatedAt,
|
|
83
|
+
...(subInfo ? { subInfo } : {}),
|
|
84
|
+
...(typeof item.homePage === "string" ? { homePage: item.homePage } : {}),
|
|
85
|
+
...(typeof item.lastError === "string" ? { lastError: item.lastError } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|