@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/core.js
CHANGED
|
@@ -6,12 +6,11 @@ import { pipeline } from "node:stream/promises";
|
|
|
6
6
|
import zlib from "node:zlib";
|
|
7
7
|
import AdmZip from "adm-zip";
|
|
8
8
|
import { currentCoreVersion, readInstallRecord, validateCoreReleaseTag, writeInstallRecord, } from "./core-install-record.js";
|
|
9
|
-
import { beginCoreInstallTransaction, clearCoreInstallTransaction, markCoreInstallTransactionCommitted, recoverCoreInstallTransaction, } from "./core-install-transaction.js";
|
|
10
9
|
import { containsCoreVersionToken } from "./core-version.js";
|
|
11
|
-
import {
|
|
10
|
+
import { pathEntryExists } from "./fs-atomic.js";
|
|
12
11
|
import { downloadReleaseAsset, listReleaseAssets, MIHOMO_REPO, RELEASE_ASSET_SIZE_LIMIT, resolveLatestTag, } from "./github.js";
|
|
13
12
|
import { sashLayout } from "./paths.js";
|
|
14
|
-
import { buildSanitizedEnv
|
|
13
|
+
import { buildSanitizedEnv } from "./process.js";
|
|
15
14
|
export function goOsArch(platform = process.platform, arch = process.arch) {
|
|
16
15
|
const osMap = { win32: "windows", darwin: "darwin", linux: "linux" };
|
|
17
16
|
const archMap = { x64: "amd64", arm64: "arm64" };
|
|
@@ -56,9 +55,12 @@ export async function extractCoreArchive(archivePath, assetName, destExe) {
|
|
|
56
55
|
throw new Error("Core archive exceeds the download safety limit");
|
|
57
56
|
}
|
|
58
57
|
const zip = new AdmZip(archivePath);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const entries = zip.getEntries();
|
|
59
|
+
if (entries.some((entry) => entry.entryName.split(/[\\/]/).includes("..") ||
|
|
60
|
+
/^(?:[\\/]|[A-Za-z]:)/.test(entry.entryName))) {
|
|
61
|
+
throw new Error("Core archive contains an unsafe path");
|
|
62
|
+
}
|
|
63
|
+
const entry = entries.find((candidate) => !candidate.isDirectory && /^mihomo.*\.exe$/i.test(path.basename(candidate.entryName)));
|
|
62
64
|
if (!entry)
|
|
63
65
|
throw new Error(`No mihomo*.exe found inside ${assetName}`);
|
|
64
66
|
if (entry.header.size > EXTRACT_SIZE_LIMIT) {
|
|
@@ -112,15 +114,17 @@ export function verifyCoreExecutable(exe, timeoutMs = 5000, expectedVersion) {
|
|
|
112
114
|
}
|
|
113
115
|
export async function stageCore(opts = {}) {
|
|
114
116
|
const layout = opts.layout ?? sashLayout();
|
|
115
|
-
const tag = validateCoreReleaseTag(opts.tag ?? (await resolveLatestTag(MIHOMO_REPO)));
|
|
116
|
-
const assets = await listReleaseAssets(MIHOMO_REPO, tag);
|
|
117
|
+
const tag = validateCoreReleaseTag(opts.tag ?? (await resolveLatestTag(MIHOMO_REPO, opts.signal)));
|
|
118
|
+
const assets = await listReleaseAssets(MIHOMO_REPO, tag, opts.signal);
|
|
117
119
|
const candidates = mihomoAssetCandidates(tag);
|
|
118
120
|
fs.mkdirSync(layout.tempDir, { recursive: true });
|
|
119
121
|
fs.mkdirSync(layout.binDir, { recursive: true });
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
+
const directory = fs.mkdtempSync(path.join(layout.tempDir, "core-download-"));
|
|
123
|
+
const archivePath = path.join(directory, "archive.download");
|
|
124
|
+
const stagedExe = path.join(directory, path.basename(layout.coreExe));
|
|
122
125
|
try {
|
|
123
126
|
const assetName = await downloadReleaseAsset({
|
|
127
|
+
signal: opts.signal,
|
|
124
128
|
repo: MIHOMO_REPO,
|
|
125
129
|
tag,
|
|
126
130
|
assets,
|
|
@@ -129,6 +133,7 @@ export async function stageCore(opts = {}) {
|
|
|
129
133
|
onProgress: opts.onProgress,
|
|
130
134
|
});
|
|
131
135
|
await extractCoreArchive(archivePath, assetName, stagedExe);
|
|
136
|
+
opts.signal?.throwIfAborted();
|
|
132
137
|
fs.chmodSync(stagedExe, 0o755);
|
|
133
138
|
verifyCoreExecutable(stagedExe, 5000, tag);
|
|
134
139
|
return { version: tag, exe: stagedExe };
|
|
@@ -139,41 +144,11 @@ export async function stageCore(opts = {}) {
|
|
|
139
144
|
}
|
|
140
145
|
finally {
|
|
141
146
|
fs.rmSync(archivePath, { force: true });
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
recoverCoreInstallTransaction(layout);
|
|
147
|
-
assertCoreInstallationConsistent(layout);
|
|
148
|
-
if (coreInstalled(layout)) {
|
|
149
|
-
throw new Error(`Core executable already exists at ${layout.coreExe}; use the update flow`);
|
|
150
|
-
}
|
|
151
|
-
const staged = await stageCore({ ...opts, layout });
|
|
152
|
-
let transactionStarted = false;
|
|
153
|
-
let committed = false;
|
|
154
|
-
try {
|
|
155
|
-
const transaction = beginCoreInstallTransaction(staged.version, layout);
|
|
156
|
-
transactionStarted = true;
|
|
157
|
-
durableRenameSync(staged.exe, layout.coreExe);
|
|
158
|
-
writeInstallRecord({ coreVersion: staged.version, installedAt: transaction.createdAt }, layout);
|
|
159
|
-
markCoreInstallTransactionCommitted(transaction, layout);
|
|
160
|
-
committed = true;
|
|
161
|
-
clearCoreInstallTransaction(layout);
|
|
162
|
-
return { version: staged.version, exe: layout.coreExe };
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
if (transactionStarted && !committed) {
|
|
166
|
-
try {
|
|
167
|
-
recoverCoreInstallTransaction(layout);
|
|
168
|
-
}
|
|
169
|
-
catch (recoveryErr) {
|
|
170
|
-
throw new Error(`${err.message}; Core install recovery also failed: ${recoveryErr.message}`);
|
|
171
|
-
}
|
|
147
|
+
try {
|
|
148
|
+
fs.rmdirSync(directory);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
172
151
|
}
|
|
173
|
-
throw err;
|
|
174
|
-
}
|
|
175
|
-
finally {
|
|
176
|
-
fs.rmSync(staged.exe, { force: true });
|
|
177
152
|
}
|
|
178
153
|
}
|
|
179
154
|
function isRegularFile(file) {
|
|
@@ -188,7 +163,6 @@ export function coreInstalled(layout = sashLayout()) {
|
|
|
188
163
|
return isRegularFile(layout.coreExe) && readInstallRecord(layout) !== undefined;
|
|
189
164
|
}
|
|
190
165
|
export function assertCoreInstallationConsistent(layout = sashLayout()) {
|
|
191
|
-
recoverBinaryUnlockProbe(layout.coreExe);
|
|
192
166
|
const binaryExists = pathEntryExists(layout.coreExe);
|
|
193
167
|
const installRecordExists = pathEntryExists(layout.installFile);
|
|
194
168
|
const binaryValid = isRegularFile(layout.coreExe);
|
|
@@ -202,5 +176,5 @@ export function assertCoreInstallationConsistent(layout = sashLayout()) {
|
|
|
202
176
|
: record
|
|
203
177
|
? "Core install metadata exists but the executable is missing"
|
|
204
178
|
: "Core install metadata is malformed without an executable";
|
|
205
|
-
throw new Error(`Core installation is incomplete or invalid: ${reason}.
|
|
179
|
+
throw new Error(`Core installation is incomplete or invalid: ${reason}. Preserve these files and reinstall into a clean data directory.`);
|
|
206
180
|
}
|
package/dist/daemon/app.js
CHANGED
|
@@ -1,156 +1,175 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { SashStateStore, StateConflictError } from "../app-state.js";
|
|
5
|
+
import { AutostartService } from "../autostart.js";
|
|
6
|
+
import { assertCoreInstallationConsistent, coreInstalled, currentCoreVersion, stageCore, } from "../core.js";
|
|
7
|
+
import { validateCoreConfig } from "../core-config-validation.js";
|
|
8
|
+
import { readCoreUpdateTransaction } from "../core-update.js";
|
|
9
|
+
import { ProfileService } from "../profile-service.js";
|
|
10
|
+
import { getActiveProfile, renderActiveConfig } from "../profiles.js";
|
|
8
11
|
import { RuntimeLifecycle } from "../runtime-lifecycle.js";
|
|
9
|
-
import { saveSettings } from "../settings.js";
|
|
10
12
|
import { SettingsService } from "../settings-service.js";
|
|
11
|
-
import { StateMutationQueue } from "../state-lock.js";
|
|
12
13
|
import { CoreSupervisor } from "../supervisor.js";
|
|
13
14
|
import { SystemProxyManager } from "../system-proxy-manager.js";
|
|
14
15
|
import { DaemonGate } from "./context.js";
|
|
16
|
+
import { WebAuthManager } from "./web-auth.js";
|
|
15
17
|
export function buildDaemonContext(deps) {
|
|
16
|
-
const layout = deps
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const { layout } = deps;
|
|
19
|
+
const state = deps.state ?? new SashStateStore(layout, deps.settings);
|
|
20
|
+
const settings = () => state.snapshot().settings;
|
|
19
21
|
const token = deps.token ?? crypto.randomBytes(24).toString("hex");
|
|
20
|
-
const startedAt = new Date().toISOString();
|
|
21
22
|
const systemProxy = deps.systemProxy ?? new SystemProxyManager({ layout });
|
|
22
|
-
const mutations = new StateMutationQueue(layout.mutationLockFile);
|
|
23
|
-
let profileRevision = 0;
|
|
24
23
|
let lifecycle;
|
|
24
|
+
let gate;
|
|
25
25
|
const supervisor = deps.supervisor ??
|
|
26
26
|
new CoreSupervisor({
|
|
27
27
|
layout,
|
|
28
|
-
settings: () =>
|
|
29
|
-
expectedVersion: () =>
|
|
30
|
-
onExit:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
console.error(`[sashd] failed to restore system proxy after Core exit: ${err.message}`);
|
|
36
|
-
}
|
|
37
|
-
},
|
|
28
|
+
settings: () => lifecycle?.settings() ?? settings(),
|
|
29
|
+
expectedVersion: () => currentCoreVersion(layout) || undefined,
|
|
30
|
+
onExit: () => gate
|
|
31
|
+
.mutate("recover Core exit", () => lifecycle.handleUnexpectedCoreExit())
|
|
32
|
+
.catch((error) => {
|
|
33
|
+
console.error(`[sashd] Core exit cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
34
|
+
}),
|
|
38
35
|
});
|
|
39
36
|
lifecycle = new RuntimeLifecycle({
|
|
37
|
+
layout,
|
|
40
38
|
supervisor,
|
|
41
39
|
systemProxy,
|
|
42
|
-
settings
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
completeAfterStart: () => {
|
|
46
|
-
completeCoordinatedCoreUpdateAfterStart(layout);
|
|
47
|
-
},
|
|
48
|
-
rollbackAfterStartFailure: () => rollbackCoordinatedCoreUpdate(layout),
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
const gate = new DaemonGate(mutations, async () => {
|
|
52
|
-
const coreWasRunning = supervisor.isRunning();
|
|
53
|
-
await lifecycle.close();
|
|
54
|
-
return { coreWasRunning };
|
|
40
|
+
settings,
|
|
41
|
+
verifyExecutable: deps.verifyCoreFn,
|
|
42
|
+
controllerProbe: deps.controllerProbe,
|
|
55
43
|
});
|
|
44
|
+
let downloading = false;
|
|
45
|
+
let preparation = new AbortController();
|
|
46
|
+
let profiles;
|
|
47
|
+
const cancelPreparations = () => {
|
|
48
|
+
preparation.abort(new StateConflictError("Core operation cancelled"));
|
|
49
|
+
preparation = new AbortController();
|
|
50
|
+
profiles.cancelDownloads();
|
|
51
|
+
};
|
|
52
|
+
gate = new DaemonGate(() => lifecycle.stop(), cancelPreparations);
|
|
56
53
|
const mutate = (purpose, action) => gate.mutate(purpose, action);
|
|
57
|
-
|
|
54
|
+
profiles = new ProfileService({
|
|
58
55
|
layout,
|
|
59
|
-
|
|
60
|
-
...(deps.fetchProfileFn ? { fetchProfile: deps.fetchProfileFn } : {}),
|
|
61
|
-
validateConfig: deps.validateConfigFn ?? ((generated) => validateCoreConfigText(generated.yaml, layout)),
|
|
62
|
-
reloadConfig: async (configPath) => {
|
|
63
|
-
if (!supervisor.isRunning())
|
|
64
|
-
return;
|
|
65
|
-
const api = new MihomoApi(runtimeSettings.controller, runtimeSettings.secret);
|
|
66
|
-
await api.reloadConfig(configPath);
|
|
67
|
-
},
|
|
56
|
+
state,
|
|
68
57
|
commit: mutate,
|
|
69
|
-
|
|
70
|
-
profileRevision += 1;
|
|
71
|
-
},
|
|
58
|
+
fetchProfile: deps.fetchProfileFn,
|
|
72
59
|
});
|
|
73
|
-
const settingsService = new SettingsService({
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
60
|
+
const settingsService = new SettingsService({ state, commit: mutate, lifecycle, supervisor });
|
|
61
|
+
const validate = (generated, executable, signal) => Promise.resolve(deps.validateConfigFn
|
|
62
|
+
? deps.validateConfigFn(generated, executable, signal)
|
|
63
|
+
: validateCoreConfig(executable, generated.yaml, layout, { signal }));
|
|
64
|
+
const savedConfiguration = () => {
|
|
65
|
+
const snapshot = state.snapshot();
|
|
66
|
+
const profile = getActiveProfile(snapshot.profiles);
|
|
67
|
+
return {
|
|
68
|
+
generated: renderActiveConfig(snapshot, layout),
|
|
69
|
+
settings: snapshot.settings,
|
|
70
|
+
profile: profile
|
|
71
|
+
? { id: profile.id, revision: profile.revision, name: profile.name, url: profile.url }
|
|
72
|
+
: null,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const requireRecoveredInstall = () => {
|
|
76
|
+
assertCoreInstallationConsistent(layout);
|
|
77
|
+
if (readCoreUpdateTransaction(layout))
|
|
78
|
+
throw new StateConflictError("Core update recovery is pending; run sash stop, then sash start");
|
|
79
|
+
};
|
|
80
|
+
const updateCore = async (version) => {
|
|
81
|
+
if (gate.isClosing)
|
|
82
|
+
throw new Error("sashd is shutting down");
|
|
83
|
+
if (downloading)
|
|
84
|
+
throw new StateConflictError("A Core download is already in progress");
|
|
85
|
+
requireRecoveredInstall();
|
|
86
|
+
const revision = state.snapshot().revision;
|
|
87
|
+
const epoch = lifecycle.revision;
|
|
88
|
+
const configuration = supervisor.isRunning() ? lifecycle.configuration() : savedConfiguration();
|
|
89
|
+
if (!configuration)
|
|
90
|
+
throw new Error("Running Core configuration is unknown");
|
|
91
|
+
const { signal } = preparation;
|
|
92
|
+
downloading = true;
|
|
93
|
+
let staged;
|
|
94
|
+
try {
|
|
95
|
+
staged = await (deps.stageCoreFn ?? stageCore)({ layout, tag: version, signal });
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
await validate(configuration.generated, staged.exe, signal);
|
|
98
|
+
const candidate = staged;
|
|
99
|
+
return await mutate("update Core", async () => {
|
|
100
|
+
signal.throwIfAborted();
|
|
101
|
+
state.assertCurrent(revision);
|
|
102
|
+
if (lifecycle.revision !== epoch)
|
|
103
|
+
throw new StateConflictError("Core changed during download; retry the update");
|
|
104
|
+
return lifecycle.update(candidate, configuration);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
signal.throwIfAborted();
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
downloading = false;
|
|
113
|
+
if (staged) {
|
|
114
|
+
fs.rmSync(staged.exe, { force: true });
|
|
96
115
|
try {
|
|
97
|
-
|
|
116
|
+
fs.rmdirSync(path.dirname(staged.exe));
|
|
98
117
|
}
|
|
99
|
-
catch
|
|
100
|
-
if (supervisor.isRunning())
|
|
101
|
-
continue;
|
|
102
|
-
throw err;
|
|
118
|
+
catch {
|
|
103
119
|
}
|
|
104
120
|
}
|
|
105
|
-
const preparedForStart = prepared;
|
|
106
|
-
const result = await mutate("start core", async () => {
|
|
107
|
-
if (!preparedForStart && !supervisor.isRunning())
|
|
108
|
-
return retryAfterPreparation;
|
|
109
|
-
return lifecycle.start(preparedForStart
|
|
110
|
-
? async () => {
|
|
111
|
-
await commitPreparedReload(preparedForStart, false);
|
|
112
|
-
}
|
|
113
|
-
: undefined);
|
|
114
|
-
});
|
|
115
|
-
if (result !== retryAfterPreparation)
|
|
116
|
-
return result;
|
|
117
121
|
}
|
|
118
122
|
};
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
const applyCore = async (onlyIfStopped = false) => {
|
|
124
|
+
const { signal } = preparation;
|
|
125
|
+
if (!coreInstalled(layout))
|
|
126
|
+
await updateCore();
|
|
127
|
+
return mutate("apply saved configuration", async () => {
|
|
128
|
+
signal.throwIfAborted();
|
|
129
|
+
requireRecoveredInstall();
|
|
130
|
+
if (onlyIfStopped && supervisor.isRunning())
|
|
131
|
+
return lifecycle.start();
|
|
132
|
+
const configuration = savedConfiguration();
|
|
133
|
+
await validate(configuration.generated, layout.coreExe, signal);
|
|
134
|
+
signal.throwIfAborted();
|
|
135
|
+
return lifecycle.apply(configuration);
|
|
136
|
+
});
|
|
130
137
|
};
|
|
131
138
|
const context = {
|
|
132
139
|
layout,
|
|
140
|
+
state,
|
|
133
141
|
token,
|
|
134
|
-
startedAt,
|
|
142
|
+
startedAt: new Date().toISOString(),
|
|
143
|
+
webAuth: new WebAuthManager(),
|
|
135
144
|
profiles,
|
|
136
145
|
settingsService,
|
|
137
146
|
lifecycle,
|
|
138
147
|
supervisor,
|
|
139
148
|
systemProxy,
|
|
149
|
+
autostart: deps.autostart ?? new AutostartService({ layout }),
|
|
140
150
|
gate,
|
|
141
|
-
settings: {
|
|
142
|
-
committed: () => committedSettings,
|
|
143
|
-
runtime: () => runtimeSettings,
|
|
144
|
-
},
|
|
145
151
|
mutate,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
settings: { committed: settings, runtime: () => lifecycle.settings() },
|
|
153
|
+
profileRevision: () => state.snapshot().revision,
|
|
154
|
+
pendingApply: () => {
|
|
155
|
+
const saved = state.snapshot();
|
|
156
|
+
const active = getActiveProfile(saved.profiles);
|
|
157
|
+
const applied = lifecycle.configuration();
|
|
158
|
+
return (!applied ||
|
|
159
|
+
applied.profile?.id !== active?.id ||
|
|
160
|
+
applied.profile?.revision !== active?.revision ||
|
|
161
|
+
applied.settings.mixedPort !== saved.settings.mixedPort ||
|
|
162
|
+
applied.settings.allowLan !== saved.settings.allowLan);
|
|
163
|
+
},
|
|
164
|
+
startCore: () => applyCore(true),
|
|
165
|
+
restartCore: () => applyCore(),
|
|
166
|
+
updateCore,
|
|
167
|
+
stopCore: () => {
|
|
168
|
+
cancelPreparations();
|
|
169
|
+
return mutate("stop Core", () => lifecycle.stop());
|
|
170
|
+
},
|
|
152
171
|
shutdown: () => gate.shutdown(),
|
|
153
|
-
closeListener: () => Promise.reject(new Error("
|
|
172
|
+
closeListener: () => Promise.reject(new Error("Listener is not ready")),
|
|
154
173
|
...(deps.onShutdown ? { onShutdown: deps.onShutdown } : {}),
|
|
155
174
|
};
|
|
156
175
|
return { context, supervisor, lifecycle, token };
|
package/dist/daemon/context.js
CHANGED
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
import { ShuttingDownError } from "./errors.js";
|
|
2
2
|
export class DaemonGate {
|
|
3
|
-
queue;
|
|
4
3
|
cleanup;
|
|
4
|
+
cancel;
|
|
5
|
+
tail = Promise.resolve();
|
|
5
6
|
closing = false;
|
|
6
7
|
cleanupPromise;
|
|
7
|
-
constructor(
|
|
8
|
-
this.queue = queue;
|
|
8
|
+
constructor(cleanup, cancel) {
|
|
9
9
|
this.cleanup = cleanup;
|
|
10
|
+
this.cancel = cancel;
|
|
10
11
|
}
|
|
11
12
|
get isClosing() {
|
|
12
13
|
return this.closing;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
mutate(_purpose, action) {
|
|
15
16
|
if (this.closing)
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
return Promise.reject(new ShuttingDownError());
|
|
18
|
+
const next = this.tail.then(() => {
|
|
18
19
|
if (this.closing)
|
|
19
20
|
throw new ShuttingDownError();
|
|
20
21
|
return action();
|
|
21
22
|
});
|
|
23
|
+
this.tail = next.then(() => undefined, () => undefined);
|
|
24
|
+
return next;
|
|
22
25
|
}
|
|
23
26
|
shutdown() {
|
|
24
27
|
if (this.cleanupPromise)
|
|
25
28
|
return this.cleanupPromise;
|
|
26
29
|
this.closing = true;
|
|
27
|
-
|
|
30
|
+
this.cancel();
|
|
31
|
+
const attempt = this.tail.then(this.cleanup);
|
|
32
|
+
this.tail = attempt.then(() => undefined, () => undefined);
|
|
28
33
|
this.cleanupPromise = attempt;
|
|
29
34
|
void attempt.catch(() => {
|
|
30
35
|
if (this.cleanupPromise === attempt) {
|
|
@@ -36,5 +41,6 @@ export class DaemonGate {
|
|
|
36
41
|
}
|
|
37
42
|
reopen() {
|
|
38
43
|
this.closing = false;
|
|
44
|
+
this.cleanupPromise = undefined;
|
|
39
45
|
}
|
|
40
46
|
}
|
package/dist/daemon/entry.js
CHANGED
|
@@ -1,67 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { atomicWriteFileSync } from "../fs-atomic.js";
|
|
4
|
-
import { readManagedStateTransactionStatus, recoverManagedStateTransaction, } from "../managed-state-transaction.js";
|
|
1
|
+
import { SashStateStore } from "../app-state.js";
|
|
2
|
+
import { atomicWriteFileSync, durableRemoveFileSync } from "../fs-atomic.js";
|
|
5
3
|
import { sashLayout } from "../paths.js";
|
|
6
|
-
import {
|
|
7
|
-
import { migrateProfileState } from "../profile-migration.js";
|
|
8
|
-
import { reconcileOrphanedRuntime } from "../runtime-recovery.js";
|
|
9
|
-
import { loadSettings } from "../settings.js";
|
|
10
|
-
import { acquireStateLock, StateMutationQueue } from "../state-lock.js";
|
|
4
|
+
import { acquireStateLock } from "../state-lock.js";
|
|
11
5
|
import { createDaemonServer } from "./server.js";
|
|
12
6
|
export async function runDaemon(opts = {}) {
|
|
13
7
|
const layout = opts.layout ?? sashLayout();
|
|
14
|
-
const
|
|
8
|
+
const lease = await acquireStateLock(layout.daemonLeaseFile, {
|
|
15
9
|
purpose: "sashd singleton",
|
|
16
10
|
timeoutMs: 0,
|
|
17
11
|
});
|
|
18
12
|
let onSignal;
|
|
13
|
+
let published = false;
|
|
19
14
|
try {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
recoverManagedStateTransaction(layout);
|
|
26
|
-
}
|
|
27
|
-
let loaded = loadSettings(layout);
|
|
28
|
-
const pendingUpdate = await reconcileOrphanedRuntime({ layout, settings: loaded });
|
|
29
|
-
assertCoreInstallationConsistent(layout);
|
|
30
|
-
loaded = loadSettings(layout);
|
|
31
|
-
if (!pendingUpdate) {
|
|
32
|
-
await migrateProfileState(loaded, layout);
|
|
33
|
-
}
|
|
34
|
-
return loaded;
|
|
35
|
-
});
|
|
36
|
-
const instance = createDaemonServer({ layout, settings });
|
|
37
|
-
const serverClosed = new Promise((resolve) => instance.server.once("close", resolve));
|
|
38
|
-
const port = settings.daemonPort;
|
|
15
|
+
const state = new SashStateStore(layout);
|
|
16
|
+
const instance = createDaemonServer({ layout, state });
|
|
17
|
+
await instance.lifecycle.recoverStartup();
|
|
18
|
+
const closed = new Promise((resolve) => instance.server.once("close", resolve));
|
|
19
|
+
const port = state.snapshot().settings.daemonPort;
|
|
39
20
|
await new Promise((resolve, reject) => {
|
|
40
|
-
instance.server.listen(port, "127.0.0.1", () => resolve());
|
|
41
21
|
instance.server.once("error", reject);
|
|
22
|
+
instance.server.listen(port, "127.0.0.1", resolve);
|
|
42
23
|
});
|
|
43
|
-
const
|
|
24
|
+
const record = {
|
|
44
25
|
pid: process.pid,
|
|
45
26
|
token: instance.token,
|
|
46
27
|
port,
|
|
47
28
|
startedAt: new Date().toISOString(),
|
|
48
29
|
};
|
|
49
|
-
atomicWriteFileSync(layout.daemonPidFile, `${JSON.stringify(
|
|
30
|
+
atomicWriteFileSync(layout.daemonPidFile, `${JSON.stringify(record, null, 2)}\n`);
|
|
31
|
+
published = true;
|
|
50
32
|
onSignal = () => {
|
|
51
|
-
void instance
|
|
52
|
-
|
|
53
|
-
|
|
33
|
+
void instance
|
|
34
|
+
.close()
|
|
35
|
+
.catch((error) => console.error(`[sashd] shutdown blocked: ${error instanceof Error ? error.message : String(error)}`));
|
|
54
36
|
};
|
|
55
37
|
process.on("SIGTERM", onSignal);
|
|
56
38
|
process.on("SIGINT", onSignal);
|
|
57
|
-
await
|
|
39
|
+
await closed;
|
|
58
40
|
}
|
|
59
41
|
finally {
|
|
60
42
|
if (onSignal) {
|
|
61
43
|
process.removeListener("SIGTERM", onSignal);
|
|
62
44
|
process.removeListener("SIGINT", onSignal);
|
|
63
45
|
}
|
|
64
|
-
|
|
65
|
-
|
|
46
|
+
if (published)
|
|
47
|
+
durableRemoveFileSync(layout.daemonPidFile);
|
|
48
|
+
lease.release();
|
|
66
49
|
}
|
|
67
50
|
}
|
package/dist/daemon/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { AutostartUnavailableError } from "../autostart.js";
|
|
1
2
|
import { HttpError } from "../daemon-http.js";
|
|
2
3
|
import { ProfileConflictError, ProfileInputError, ProfileNotFoundError, } from "../profile-service.js";
|
|
3
|
-
import { CoreUnhealthyError, SettingsInputError } from "../settings-service.js";
|
|
4
|
+
import { CoreUnhealthyError, SettingsConflictError, SettingsInputError, } from "../settings-service.js";
|
|
4
5
|
export class ShuttingDownError extends Error {
|
|
5
6
|
constructor(message = "sashd is shutting down") {
|
|
6
7
|
super(message);
|
|
@@ -31,6 +32,8 @@ export function errorToHttp(err) {
|
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
const message = err instanceof Error ? err.message : String(err);
|
|
35
|
+
if (err instanceof AutostartUnavailableError)
|
|
36
|
+
return { status: 409, code: "conflict", message };
|
|
34
37
|
if (err instanceof ProfileNotFoundError)
|
|
35
38
|
return { status: 404, code: "not_found", message };
|
|
36
39
|
if (err instanceof ProfileInputError)
|
|
@@ -39,6 +42,8 @@ export function errorToHttp(err) {
|
|
|
39
42
|
return { status: 400, code: "invalid_input", message };
|
|
40
43
|
if (err instanceof ProfileConflictError)
|
|
41
44
|
return { status: 409, code: "conflict", message };
|
|
45
|
+
if (err instanceof SettingsConflictError)
|
|
46
|
+
return { status: 409, code: "conflict", message };
|
|
42
47
|
if (err instanceof CoreUnhealthyError)
|
|
43
48
|
return { status: 409, code: "core_unhealthy", message };
|
|
44
49
|
if (err instanceof ShuttingDownError)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseAutostartEnabled } from "../../autostart-contract.js";
|
|
2
|
+
import { HttpError } from "../../daemon-http.js";
|
|
3
|
+
import { errorMessage } from "../../error-utils.js";
|
|
4
|
+
export async function readAutostart(ctx) {
|
|
5
|
+
return { status: 200, json: await ctx.autostart.inspect() };
|
|
6
|
+
}
|
|
7
|
+
export async function writeAutostart(ctx, req) {
|
|
8
|
+
const body = await req.readJson();
|
|
9
|
+
let enabled;
|
|
10
|
+
try {
|
|
11
|
+
enabled = parseAutostartEnabled(body);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new HttpError(400, errorMessage(error));
|
|
15
|
+
}
|
|
16
|
+
const status = await ctx.mutate("configure login startup", () => ctx.autostart.set(enabled));
|
|
17
|
+
return { status: 200, json: status };
|
|
18
|
+
}
|
|
@@ -1,17 +1,40 @@
|
|
|
1
|
+
import { MihomoApi } from "../../api.js";
|
|
2
|
+
import { validateCoreReleaseTag } from "../../core-install-record.js";
|
|
3
|
+
import { HttpError } from "../../daemon-http.js";
|
|
1
4
|
export async function startCore(ctx) {
|
|
2
|
-
|
|
3
|
-
return { status: 200, json: result };
|
|
5
|
+
return { status: 200, json: await ctx.startCore() };
|
|
4
6
|
}
|
|
5
7
|
export async function stopCore(ctx) {
|
|
6
|
-
await ctx.
|
|
8
|
+
await ctx.stopCore();
|
|
7
9
|
return { status: 204 };
|
|
8
10
|
}
|
|
9
11
|
export async function restartCore(ctx) {
|
|
10
|
-
|
|
11
|
-
return { status: 200, json: result };
|
|
12
|
+
return { status: 200, json: await ctx.restartCore() };
|
|
12
13
|
}
|
|
13
|
-
export async function
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
export async function updateCore(ctx, req) {
|
|
15
|
+
const body = await req.readJson(1024);
|
|
16
|
+
if (Object.keys(body).some((key) => key !== "version") ||
|
|
17
|
+
(body.version !== undefined && typeof body.version !== "string")) {
|
|
18
|
+
throw new HttpError(400, "Expected an optional Core version string");
|
|
19
|
+
}
|
|
20
|
+
let version;
|
|
21
|
+
try {
|
|
22
|
+
version = typeof body.version === "string" ? validateCoreReleaseTag(body.version) : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new HttpError(400, error instanceof Error ? error.message : String(error));
|
|
26
|
+
}
|
|
27
|
+
return { status: 200, json: await ctx.updateCore(version) };
|
|
28
|
+
}
|
|
29
|
+
export async function setCoreMode(ctx, req) {
|
|
30
|
+
const body = await req.readJson(1024);
|
|
31
|
+
const mode = body.mode;
|
|
32
|
+
if (Object.keys(body).some((key) => key !== "mode") ||
|
|
33
|
+
(mode !== "rule" && mode !== "global" && mode !== "direct"))
|
|
34
|
+
throw new HttpError(400, "Invalid routing mode");
|
|
35
|
+
await ctx.mutate("set routing mode", async () => {
|
|
36
|
+
const settings = ctx.settings.runtime();
|
|
37
|
+
await new MihomoApi(settings.controller, settings.secret).setMode(mode);
|
|
38
|
+
});
|
|
39
|
+
return { status: 204 };
|
|
17
40
|
}
|