@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +22 -7
  3. package/THIRD_PARTY_NOTICES.md +9 -0
  4. package/dist/api.js +14 -45
  5. package/dist/app-state.js +99 -0
  6. package/dist/autostart/command.js +24 -0
  7. package/dist/autostart/context.js +43 -0
  8. package/dist/autostart/files.js +60 -0
  9. package/dist/autostart/installation.js +48 -0
  10. package/dist/autostart/start.js +47 -0
  11. package/dist/autostart/windows-registry.js +88 -0
  12. package/dist/autostart/windows.js +59 -0
  13. package/dist/autostart-contract.js +31 -0
  14. package/dist/autostart-entry.js +10 -0
  15. package/dist/autostart.js +69 -0
  16. package/dist/cli.js +11 -11
  17. package/dist/commands/auto.js +15 -0
  18. package/dist/commands/lifecycle.js +5 -31
  19. package/dist/commands/logs.js +11 -4
  20. package/dist/commands/shared.js +2 -4
  21. package/dist/commands/status.js +6 -7
  22. package/dist/commands/update.js +7 -3
  23. package/dist/commands/web.js +19 -27
  24. package/dist/contracts.js +171 -335
  25. package/dist/core-config-validation.js +22 -38
  26. package/dist/core-update.js +126 -762
  27. package/dist/core.js +20 -46
  28. package/dist/daemon/app.js +132 -113
  29. package/dist/daemon/context.js +13 -7
  30. package/dist/daemon/entry.js +21 -38
  31. package/dist/daemon/errors.js +6 -1
  32. package/dist/daemon/handlers/autostart.js +18 -0
  33. package/dist/daemon/handlers/core.js +32 -9
  34. package/dist/daemon/handlers/daemon.js +32 -6
  35. package/dist/daemon/handlers/profiles.js +12 -2
  36. package/dist/daemon/handlers/settings.js +9 -36
  37. package/dist/daemon/router.js +40 -27
  38. package/dist/daemon/scheduler.js +3 -1
  39. package/dist/daemon/server.js +6 -3
  40. package/dist/daemon/web-auth.js +52 -0
  41. package/dist/daemon-auth.js +2 -2
  42. package/dist/daemon-client.js +18 -2
  43. package/dist/daemon-http.js +7 -0
  44. package/dist/daemon-lifecycle.js +19 -196
  45. package/dist/github.js +7 -2
  46. package/dist/http.js +7 -5
  47. package/dist/log-follow.js +2 -1
  48. package/dist/mihomo-config.js +7 -9
  49. package/dist/paths.js +5 -7
  50. package/dist/profile-model.js +87 -0
  51. package/dist/profile-service.js +250 -587
  52. package/dist/profiles.js +43 -171
  53. package/dist/runtime-lifecycle.js +112 -194
  54. package/dist/runtime-owner.js +37 -76
  55. package/dist/sash-client.js +63 -21
  56. package/dist/settings-service.js +29 -228
  57. package/dist/settings.js +61 -285
  58. package/dist/status.js +24 -40
  59. package/dist/supervisor.js +1 -14
  60. package/dist/sysproxy/common.js +5 -45
  61. package/dist/sysproxy/factory.js +24 -65
  62. package/dist/sysproxy/snapshot.js +24 -257
  63. package/dist/sysproxy.js +1 -4
  64. package/dist/system-proxy-manager.js +37 -35
  65. package/dist/ui/assets/{ConnectionsView-DNGmZBSU.js → ConnectionsView-BgXQM6Z4.js} +1 -1
  66. package/dist/ui/assets/{LogsView-fSiaxQ13.js → LogsView-2f542P8z.js} +1 -1
  67. package/dist/ui/assets/{PaginationFooter-B3kHzRfB.js → PaginationFooter-cj1tcZej.js} +1 -1
  68. package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
  69. package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
  70. package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
  71. package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
  72. package/dist/ui/assets/{RulesView-D9vZBiJ1.js → RulesView-CtKvlckZ.js} +1 -1
  73. package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
  74. package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
  75. package/dist/ui/assets/{0be242294f7d791af850c6df38ac78a0-2cL6Ntwf.woff2 → e2a57555d97d0b02b45d9418eb6ee295-D9nhF3rM.woff2} +0 -0
  76. package/dist/ui/assets/index-CBInDvdJ.js +19 -0
  77. package/dist/ui/assets/index-mOLy7fkB.css +1 -0
  78. package/dist/ui/assets/{theme-BNq4FkXS.js → theme-D40MF8cQ.js} +1 -1
  79. package/dist/ui/index.html +2 -2
  80. package/dist/web-bootstrap.js +113 -0
  81. package/docs/architecture-proposal.md +109 -0
  82. package/docs/autostart.md +101 -0
  83. package/docs/backend.md +92 -216
  84. package/docs/frontend.md +61 -97
  85. package/docs/usage.md +88 -186
  86. package/package.json +10 -6
  87. package/dist/commands/upgrade.js +0 -43
  88. package/dist/core-install-transaction.js +0 -114
  89. package/dist/core-update-coordination.js +0 -105
  90. package/dist/core-update-service.js +0 -245
  91. package/dist/managed-state-transaction.js +0 -377
  92. package/dist/offline-mutation.js +0 -58
  93. package/dist/profile-migration.js +0 -101
  94. package/dist/runtime-recovery.js +0 -31
  95. package/dist/sysproxy/darwin.js +0 -287
  96. package/dist/sysproxy/gnome.js +0 -181
  97. package/dist/sysproxy/legacy.js +0 -58
  98. package/dist/tun-guidance.js +0 -11
  99. package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
  100. package/dist/ui/assets/CodeEditorModal-CFEnWsyh.js +0 -14
  101. package/dist/ui/assets/ProfileEditorDialog-BydoZthX.js +0 -1
  102. package/dist/ui/assets/ProfilesView-Btc1DOxE.js +0 -2
  103. package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
  104. package/dist/ui/assets/SettingsFileDialog-CNFEAVs4.js +0 -1
  105. package/dist/ui/assets/SettingsView-BlDhZkXQ.js +0 -2
  106. package/dist/ui/assets/SettingsView-CngS3vBM.css +0 -1
  107. package/dist/ui/assets/index-B61V60w_.js +0 -19
  108. package/dist/ui/assets/index-bkyxJG8J.css +0 -1
@@ -1,87 +1,48 @@
1
- import { assertCoreInstallationConsistent, coreInstalled, installCore } from "./core.js";
2
- import { recoverCoreInstallTransaction } from "./core-install-transaction.js";
3
- import { recoverCoordinatedCoreUpdate } from "./core-update-coordination.js";
1
+ import fs from "node:fs";
2
+ import { loadSettings } from "./app-state.js";
4
3
  import { SashDaemonClient } from "./daemon-client.js";
5
- import { ensureDaemon, evaluateDaemon, prepareDaemonMaintenance, spawnDaemon, stopDaemonFromCli, } from "./daemon-lifecycle.js";
6
- import { log } from "./log.js";
7
- import { runOfflineMutation } from "./offline-mutation.js";
8
- import { withStateLock } from "./state-lock.js";
9
- export async function resolveRuntimeOwner(ctx, dependencies = {}) {
10
- const daemon = await (dependencies.evaluateDaemon ?? evaluateDaemon)(ctx.layout, ctx.settings);
11
- if (daemon.kind === "healthy") {
12
- const client = (dependencies.clientFactory ?? ((port, secret) => new SashDaemonClient(port, secret)))(daemon.port, ctx.settings.daemonSecret);
13
- return { kind: "daemon", daemon, client };
14
- }
4
+ import { ensureDaemon, evaluateDaemon, stopDaemonFromCli, } from "./daemon-lifecycle.js";
5
+ export async function resolveRuntimeOwner(ctx, deps = {}) {
6
+ const daemon = await (deps.evaluateDaemon ?? evaluateDaemon)(ctx.layout, ctx.settings);
7
+ if (daemon.kind === "healthy")
8
+ return {
9
+ kind: "daemon",
10
+ daemon,
11
+ client: (deps.clientFactory ?? ((port, secret) => new SashDaemonClient(port, secret)))(daemon.port, ctx.settings.daemonSecret),
12
+ };
15
13
  return daemon.kind === "stopped" ? { kind: "offline", daemon } : { kind: "unhealthy", daemon };
16
14
  }
17
- export async function ensureCore(ctx) {
18
- recoverCoreInstallTransaction(ctx.layout);
19
- recoverCoordinatedCoreUpdate(ctx.layout);
20
- assertCoreInstallationConsistent(ctx.layout);
21
- if (coreInstalled(ctx.layout))
22
- return;
23
- log.info("mihomo core not installed; downloading latest release...");
24
- const { version } = await installCore({ layout: ctx.layout });
25
- log.ok(`mihomo core ${version} installed`);
26
- }
27
- function withRuntimeOperation(ctx, purpose, action) {
28
- return withStateLock(ctx.layout.runtimeOperationLockFile, { purpose, timeoutMs: 120_000 }, () => action());
29
- }
30
- async function healthyDaemonOwner(ctx) {
15
+ export async function ensureManagement(ctx) {
16
+ await ensureDaemon({ layout: ctx.layout, settings: ctx.settings });
17
+ ctx.settings = loadSettings(ctx.layout);
31
18
  const owner = await resolveRuntimeOwner(ctx);
32
- if (owner.kind !== "daemon") {
33
- throw new Error("sashd did not become healthy after startup");
34
- }
19
+ if (owner.kind !== "daemon")
20
+ throw new Error("sashd did not become healthy");
35
21
  return owner;
36
22
  }
37
23
  export async function ensureRunning(ctx) {
38
- return withRuntimeOperation(ctx, "start runtime", async () => {
39
- const initialOwner = await resolveRuntimeOwner(ctx);
40
- if (initialOwner.kind === "unhealthy") {
41
- throw new Error("sashd is already starting or unresponsive; refusing a competing start");
42
- }
43
- if (initialOwner.kind === "offline") {
44
- await runOfflineMutation(ctx, "install Core before start", () => ensureCore(ctx));
45
- }
46
- await ensureDaemon({ layout: ctx.layout, settings: ctx.settings });
47
- const owner = await healthyDaemonOwner(ctx);
48
- const result = await owner.client.startCore();
49
- return { owner, result };
50
- });
24
+ const owner = await ensureManagement(ctx);
25
+ return { owner, result: await owner.client.startCore() };
51
26
  }
52
27
  export async function stopRuntime(ctx) {
53
- return withRuntimeOperation(ctx, "stop runtime", async () => {
54
- const owner = await resolveRuntimeOwner(ctx);
55
- if (owner.kind !== "offline") {
56
- const stopped = await stopDaemonFromCli({ layout: ctx.layout, settings: ctx.settings });
57
- if (!stopped)
58
- throw new Error("sashd could not be stopped safely");
59
- }
60
- await runOfflineMutation(ctx, "finalize stopped runtime", () => undefined, {
61
- reconcileRuntime: {
62
- ...(owner.kind !== "offline" && owner.daemon.legacyOwnership ? { legacyDaemon: true } : {}),
63
- },
64
- });
65
- return {
66
- wasRunning: owner.kind !== "offline",
67
- legacyDaemon: owner.kind !== "offline" && owner.daemon.legacyOwnership === true,
68
- };
69
- });
28
+ const initial = await resolveRuntimeOwner(ctx);
29
+ if (initial.kind === "unhealthy")
30
+ throw new Error("sashd is unresponsive or its ownership is unknown; refusing an unverified stop");
31
+ if (initial.kind === "offline") {
32
+ const leftovers = [
33
+ ctx.layout.pidFile,
34
+ ctx.layout.systemProxyStateFile,
35
+ ctx.layout.coreUpdateTransactionFile,
36
+ ];
37
+ if (!leftovers.some((file) => fs.existsSync(file)))
38
+ return { wasRunning: false };
39
+ await ensureManagement(ctx);
40
+ }
41
+ if (!(await stopDaemonFromCli({ layout: ctx.layout, settings: ctx.settings })))
42
+ throw new Error("sashd shutdown could not be verified");
43
+ return { wasRunning: initial.kind === "daemon" };
70
44
  }
71
- export async function restartRuntime(ctx, deps = {}) {
72
- return withRuntimeOperation(ctx, "restart runtime", async () => {
73
- const maintenance = await prepareDaemonMaintenance(ctx.layout, ctx.settings, "runtime restart", deps);
74
- if (maintenance.legacyDaemon) {
75
- await runOfflineMutation(ctx, "clean up legacy runtime", () => undefined, {
76
- reconcileRuntime: { legacyDaemon: true },
77
- });
78
- }
79
- if (!maintenance.daemonWasRunning) {
80
- await runOfflineMutation(ctx, "install Core before restart", () => ensureCore(ctx));
81
- }
82
- const { pid: daemonPid } = await spawnDaemon({ layout: ctx.layout, settings: ctx.settings });
83
- const owner = await healthyDaemonOwner(ctx);
84
- const result = await owner.client.startCore();
85
- return { owner, result, daemonPid, daemonWasRunning: maintenance.daemonWasRunning };
86
- });
45
+ export async function restartRuntime(ctx) {
46
+ const owner = await ensureManagement(ctx);
47
+ return { owner, result: await owner.client.restartCore() };
87
48
  }
@@ -1,4 +1,5 @@
1
- import { parseApiErrorBody, parseCoreReloadResult, parseCoreStartResult, parseDaemonStatus, parseHealthInfo, parseProfileActionResponse, parseProfileActivateResponse, parseProfileContentResponse, parseProfileRemoveResponse, parseProfileRenameResponse, parseProfilesIndex, parseProfilesUpdateAllResponse, parseProfileUpdateResponse, parsePublicSettings, parseSettingsFileContent, parseSettingsWriteResult, parseShutdownResult, parseSystemProxyStatusResponse, } from "./contracts.js";
1
+ import { parseAutostartStatus } from "./autostart-contract.js";
2
+ import { parseApiErrorBody, parseCoreStartResult, parseCoreUpdateResponse, parseDaemonStatus, parseHealthInfo, parseProfileActionResponse, parseProfileActivateResponse, parseProfileContentResponse, parseProfileRemoveResponse, parseProfileRenameResponse, parseProfilesIndex, parseProfilesUpdateAllResponse, parseProfileUpdateResponse, parsePublicSettings, parseSettingsWriteResult, parseSystemProxyStatusResponse, parseWebBootstrapInfo, parseWebSessionInfo, } from "./contracts.js";
2
3
  const defaultFetch = async (url, init) => {
3
4
  const response = await fetch(url, {
4
5
  method: init.method,
@@ -8,6 +9,7 @@ const defaultFetch = async (url, init) => {
8
9
  });
9
10
  return { status: response.status, text: () => response.text() };
10
11
  };
12
+ const CORE_OPERATION_TIMEOUT_MS = 20 * 60_000;
11
13
  export class SashApiError extends Error {
12
14
  status;
13
15
  code;
@@ -24,6 +26,7 @@ export class SashClient {
24
26
  tokenHeader;
25
27
  fetchFn;
26
28
  timeoutMs;
29
+ onUnauthorized;
27
30
  constructor(options) {
28
31
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
29
32
  if (options.token)
@@ -31,10 +34,12 @@ export class SashClient {
31
34
  this.tokenHeader = options.tokenHeader ?? "authorization";
32
35
  this.fetchFn = options.fetchFn ?? defaultFetch;
33
36
  this.timeoutMs = options.timeoutMs ?? 5_000;
37
+ if (options.onUnauthorized)
38
+ this.onUnauthorized = options.onUnauthorized;
34
39
  }
35
40
  async request(endpoint, options = {}) {
36
41
  const headers = {};
37
- const token = this.token?.() ?? "";
42
+ const token = options.authenticate === false ? "" : (this.token?.() ?? "");
38
43
  if (token) {
39
44
  if (this.tokenHeader === "x-sash-token")
40
45
  headers["X-Sash-Token"] = token;
@@ -64,6 +69,8 @@ export class SashClient {
64
69
  }
65
70
  data = text;
66
71
  }
72
+ if (response.status === 401 && token)
73
+ this.onUnauthorized?.(token);
67
74
  if (response.status < 200 || response.status >= 300) {
68
75
  const parsedError = parseApiErrorBody(data);
69
76
  const message = parsedError?.message ?? (typeof data === "string" ? data.slice(0, 300).trim() : "");
@@ -72,25 +79,67 @@ export class SashClient {
72
79
  return data;
73
80
  }
74
81
  async health() {
75
- return parseHealthInfo(await this.request("/sash/daemon/health", { timeoutMs: 2_000, attempts: 1 }));
82
+ return parseHealthInfo(await this.request("/sash/daemon/health", {
83
+ timeoutMs: 2_000,
84
+ attempts: 1,
85
+ authenticate: false,
86
+ }));
87
+ }
88
+ async createWebBootstrap() {
89
+ return parseWebBootstrapInfo(await this.request("/sash/web/bootstrap", { method: "POST", timeoutMs: 5_000 }));
90
+ }
91
+ async redeemWebBootstrap(token) {
92
+ return parseWebSessionInfo(await this.request("/sash/web/session", {
93
+ method: "POST",
94
+ body: { token },
95
+ timeoutMs: 5_000,
96
+ attempts: 1,
97
+ authenticate: false,
98
+ }));
76
99
  }
77
100
  async status(fresh = false) {
78
- return parseDaemonStatus(await this.request(fresh ? "/sash/daemon/status?fresh=1" : "/sash/daemon/status"));
101
+ return parseDaemonStatus(await this.request(fresh ? "/sash/daemon/status?fresh=1" : "/sash/daemon/status", {
102
+ timeoutMs: 8000,
103
+ }));
79
104
  }
80
105
  async shutdown() {
81
- return parseShutdownResult(await this.request("/sash/daemon/shutdown", { method: "POST", timeoutMs: 45_000 }));
106
+ await this.request("/sash/daemon/shutdown", { method: "POST", timeoutMs: 45_000 });
107
+ }
108
+ async autostartStatus() {
109
+ return parseAutostartStatus(await this.request("/sash/autostart", { timeoutMs: 15_000, attempts: 1 }));
110
+ }
111
+ async setAutostart(enabled) {
112
+ return parseAutostartStatus(await this.request("/sash/autostart", {
113
+ method: "PUT",
114
+ body: { enabled },
115
+ timeoutMs: 60_000,
116
+ attempts: 1,
117
+ }));
82
118
  }
83
119
  async startCore() {
84
- return parseCoreStartResult(await this.request("/sash/core/start", { method: "POST", timeoutMs: 15_000 }));
120
+ return parseCoreStartResult(await this.request("/sash/core/start", {
121
+ method: "POST",
122
+ timeoutMs: CORE_OPERATION_TIMEOUT_MS,
123
+ }));
85
124
  }
86
125
  async stopCore() {
87
126
  await this.request("/sash/core/stop", { method: "POST", timeoutMs: 30_000 });
88
127
  }
89
128
  async restartCore() {
90
- return parseCoreStartResult(await this.request("/sash/core/restart", { method: "POST", timeoutMs: 30_000 }));
129
+ return parseCoreStartResult(await this.request("/sash/core/restart", {
130
+ method: "POST",
131
+ timeoutMs: CORE_OPERATION_TIMEOUT_MS,
132
+ }));
133
+ }
134
+ async updateCore(version) {
135
+ return parseCoreUpdateResponse(await this.request("/sash/core/update", {
136
+ method: "POST",
137
+ body: version ? { version } : {},
138
+ timeoutMs: CORE_OPERATION_TIMEOUT_MS,
139
+ }));
91
140
  }
92
- async reloadCoreConfig() {
93
- return parseCoreReloadResult(await this.request("/sash/core/reload", { method: "POST", timeoutMs: 30_000 }));
141
+ async setMode(mode) {
142
+ await this.request("/sash/core/mode", { method: "PUT", body: { mode } });
94
143
  }
95
144
  async proxyStatus(fresh = false) {
96
145
  return parseSystemProxyStatusResponse(await this.request(fresh ? "/sash/proxy?fresh=1" : "/sash/proxy"));
@@ -101,19 +150,12 @@ export class SashClient {
101
150
  async patchSettings(patch) {
102
151
  return parseSettingsWriteResult(await this.request("/sash/settings", { method: "PATCH", body: patch, timeoutMs: 45_000 }));
103
152
  }
104
- async getSettingsFile() {
105
- return parseSettingsFileContent(await this.request("/sash/settings/file"));
106
- }
107
- async writeSettingsFile(content) {
108
- return parseSettingsWriteResult(await this.request("/sash/settings/file", {
109
- method: "PUT",
110
- body: { content },
111
- timeoutMs: 45_000,
112
- }));
113
- }
114
153
  async listProfiles() {
115
154
  return parseProfilesIndex(await this.request("/sash/profiles"));
116
155
  }
156
+ async reorderProfiles(ids) {
157
+ return parseProfilesIndex(await this.request("/sash/profiles/order", { method: "PUT", body: { ids } }));
158
+ }
117
159
  async addProfile(url, opts = {}) {
118
160
  return parseProfileActionResponse(await this.request("/sash/profiles", {
119
161
  method: "POST",
@@ -147,10 +189,10 @@ export class SashClient {
147
189
  async getProfileContent(id) {
148
190
  return parseProfileContentResponse(await this.request(`/sash/profiles/${id}/content`));
149
191
  }
150
- async writeProfileContent(id, content) {
192
+ async writeProfileContent(id, content, revision) {
151
193
  return parseProfileUpdateResponse(await this.request(`/sash/profiles/${id}/content`, {
152
194
  method: "PUT",
153
- body: { content },
195
+ body: { content, revision },
154
196
  timeoutMs: 30_000,
155
197
  }));
156
198
  }
@@ -1,242 +1,43 @@
1
- import { commitManagedStateTransaction } from "./managed-state-transaction.js";
2
- import { ProfileConflictError, } from "./profile-service.js";
3
- import { sameSettings, validateSettingsCandidate } from "./settings.js";
4
- import { tunPrivilegeGuidance } from "./tun-guidance.js";
1
+ import { validateSettingsCandidate } from "./settings.js";
5
2
  export class SettingsInputError extends Error {
6
3
  }
7
4
  export class CoreUnhealthyError extends Error {
8
5
  }
9
- const CORE_SETTING_KEYS = ["mixedPort", "controller", "secret", "tun", "allowLan"];
6
+ export { StateConflictError as SettingsConflictError } from "./app-state.js";
10
7
  export class SettingsService {
11
8
  options;
12
9
  constructor(options) {
13
10
  this.options = options;
14
11
  }
15
12
  async apply(patch) {
16
- const previous = { ...this.options.getCommitted() };
17
- let candidate;
18
- try {
19
- candidate = validateSettingsCandidate(mergePatch(previous, patch));
20
- }
21
- catch (err) {
22
- throw new SettingsInputError(err.message);
23
- }
24
- const restartRequired = candidate.daemonPort !== previous.daemonPort;
25
- const proxyChanged = candidate.systemProxy !== previous.systemProxy;
26
- const coreChanged = CORE_SETTING_KEYS.some((key) => candidate[key] !== previous[key]);
27
- const daemonChanged = restartRequired || candidate.daemonSecret !== previous.daemonSecret;
28
- let committed = previous;
29
- if (coreChanged || daemonChanged) {
30
- const staged = { ...candidate, systemProxy: previous.systemProxy };
31
- committed = coreChanged
32
- ? await this.commitCoreChange(previous, staged, {
33
- verifyTun: candidate.tun && !previous.tun,
34
- })
35
- : await this.commitSettingsOnly(previous, staged);
36
- }
37
- if (proxyChanged) {
38
- committed = await this.commitSystemProxy(committed, candidate);
39
- }
40
- return { settings: committed, restartRequired };
41
- }
42
- async applyFileSettings(next) {
43
- const current = this.options.getCommitted();
44
- const patch = {};
45
- if (next.mixedPort !== current.mixedPort)
46
- patch.mixedPort = next.mixedPort;
47
- if (next.controller !== current.controller)
48
- patch.controller = next.controller;
49
- if (next.secret !== current.secret)
50
- patch.secret = next.secret;
51
- if (next.tun !== current.tun)
52
- patch.tun = next.tun;
53
- if (next.allowLan !== current.allowLan)
54
- patch.allowLan = next.allowLan;
55
- if (next.systemProxy !== current.systemProxy)
56
- patch.systemProxy = next.systemProxy;
57
- if (next.daemonPort !== current.daemonPort)
58
- patch.daemonPort = next.daemonPort;
59
- if (next.daemonSecret !== current.daemonSecret)
60
- patch.daemonSecret = next.daemonSecret;
61
- return this.apply(patch);
62
- }
63
- async commitSettingsOnly(previous, candidate) {
64
- return this.options.commit("update daemon settings", async () => {
65
- this.assertCurrent(previous);
66
- this.options.setRuntime(candidate);
13
+ return this.options.commit("save settings", async () => {
14
+ const state = this.options.state.snapshot();
15
+ let settings;
67
16
  try {
68
- await commitManagedStateTransaction(this.options.layout, { settings: candidate }, undefined);
69
- }
70
- catch (err) {
71
- this.options.setRuntime(previous);
72
- throw err;
73
- }
74
- this.options.setCommitted(candidate);
75
- return candidate;
17
+ settings = validateSettingsCandidate({ ...state.settings, ...patch });
18
+ }
19
+ catch (error) {
20
+ throw new SettingsInputError(error instanceof Error ? error.message : String(error));
21
+ }
22
+ if (patch.systemProxy === true) {
23
+ const core = await this.options.supervisor.status();
24
+ if (!core.running || !core.healthy)
25
+ throw new CoreUnhealthyError("Cannot enable system proxy: Core is not healthy");
26
+ }
27
+ this.options.state.assertCurrent(state.revision);
28
+ const changed = settings.mixedPort !== state.settings.mixedPort ||
29
+ settings.allowLan !== state.settings.allowLan ||
30
+ settings.systemProxy !== state.settings.systemProxy;
31
+ const saved = changed ? this.options.state.commit({ ...state, settings }) : state;
32
+ if (patch.systemProxy !== undefined) {
33
+ await this.options.lifecycle.reconcileSystemProxy();
34
+ }
35
+ const running = this.options.lifecycle.settings();
36
+ return {
37
+ settings: saved.settings,
38
+ restartRequired: saved.settings.mixedPort !== running.mixedPort ||
39
+ saved.settings.allowLan !== running.allowLan,
40
+ };
76
41
  });
77
42
  }
78
- async commitCoreChange(previous, candidate, opts) {
79
- for (let attempt = 0;; attempt += 1) {
80
- const prepared = await this.options.profiles.prepareActiveConfig(candidate, previous);
81
- let retryableProfileConflict;
82
- let callbackEntered = false;
83
- try {
84
- return await this.options.commit("update settings", async () => {
85
- this.assertCurrent(previous);
86
- try {
87
- return await this.options.profiles.withPreparedActivePublication(prepared, async (publication) => {
88
- callbackEntered = true;
89
- return this.commitCoreSettings(previous, candidate, opts, publication);
90
- });
91
- }
92
- catch (err) {
93
- if (err instanceof ProfileConflictError && !callbackEntered) {
94
- retryableProfileConflict = err;
95
- }
96
- throw err;
97
- }
98
- });
99
- }
100
- catch (err) {
101
- if (err !== retryableProfileConflict || attempt >= 1)
102
- throw err;
103
- }
104
- }
105
- }
106
- async commitSystemProxy(previous, candidate) {
107
- if (!candidate.systemProxy) {
108
- return this.options.commit("disable system proxy", async () => {
109
- this.assertCurrent(previous);
110
- this.options.setRuntime(candidate);
111
- try {
112
- await commitManagedStateTransaction(this.options.layout, { settings: candidate }, undefined);
113
- }
114
- catch (err) {
115
- this.options.setRuntime(previous);
116
- throw err;
117
- }
118
- this.options.setCommitted(candidate);
119
- if (this.options.lifecycle)
120
- await this.options.lifecycle.reconcileSystemProxy();
121
- else
122
- await this.options.releaseSystemProxy?.();
123
- return candidate;
124
- });
125
- }
126
- const core = await this.options.supervisor?.status();
127
- if (!core?.running || !core.healthy) {
128
- throw new CoreUnhealthyError("Cannot enable system proxy: core is not healthy");
129
- }
130
- return this.options.commit("enable system proxy", async () => {
131
- this.assertCurrent(previous);
132
- this.options.setRuntime(candidate);
133
- try {
134
- await commitManagedStateTransaction(this.options.layout, {
135
- settings: candidate,
136
- applyRuntime: () => this.requireLifecycle().reconcileSystemProxy(),
137
- }, undefined);
138
- this.options.setCommitted(candidate);
139
- return candidate;
140
- }
141
- catch (err) {
142
- this.options.setRuntime(previous);
143
- try {
144
- await this.requireLifecycle().reconcileSystemProxy();
145
- }
146
- catch (rollback) {
147
- throw new Error(`${err.message}; system proxy rollback failed: ${rollback.message}`);
148
- }
149
- throw err;
150
- }
151
- });
152
- }
153
- async commitCoreSettings(previous, candidate, opts, publication) {
154
- const wasRunning = this.options.supervisor?.isRunning() ?? false;
155
- this.options.setRuntime(candidate);
156
- try {
157
- await commitManagedStateTransaction(this.options.layout, {
158
- ...(publication.index ? { index: publication.index } : {}),
159
- ...(publication.profile
160
- ? {
161
- profile: {
162
- id: publication.profile.id,
163
- yamlText: publication.profile.yamlText,
164
- },
165
- }
166
- : {}),
167
- config: publication.config,
168
- settings: candidate,
169
- reloadRuntime: false,
170
- applyRuntime: async () => {
171
- if (!wasRunning)
172
- return;
173
- const result = await this.requireLifecycle().restart();
174
- if (opts.verifyTun && result.tunActive !== true) {
175
- throw new Error(result.tunActive === false
176
- ? `TUN did not become active. ${tunPrivilegeGuidance("activation-rolled-back", { root: this.options.layout.root })}`
177
- : `TUN activation could not be verified through the Core controller. ${tunPrivilegeGuidance("activation-rolled-back", { root: this.options.layout.root })}`);
178
- }
179
- },
180
- }, undefined);
181
- this.options.setCommitted(candidate);
182
- return candidate;
183
- }
184
- catch (err) {
185
- this.options.setRuntime(previous);
186
- const rollbackErrors = [];
187
- try {
188
- await this.options.profiles.commitPreparedActiveReload(publication.rollback, {
189
- reloadRuntime: false,
190
- boundary: "already-held",
191
- });
192
- }
193
- catch (rollback) {
194
- rollbackErrors.push(`config rollback failed: ${rollback.message}`);
195
- }
196
- if (wasRunning) {
197
- try {
198
- if (this.options.supervisor?.isRunning())
199
- await this.requireLifecycle().restart();
200
- else
201
- await this.requireLifecycle().start();
202
- }
203
- catch (rollback) {
204
- rollbackErrors.push(`runtime rollback failed: ${rollback.message}`);
205
- }
206
- }
207
- if (rollbackErrors.length)
208
- throw new Error(`${err.message}; ${rollbackErrors.join("; ")}`);
209
- throw err;
210
- }
211
- }
212
- requireLifecycle() {
213
- if (!this.options.lifecycle)
214
- throw new Error("No runtime lifecycle is available for this settings mutation");
215
- return this.options.lifecycle;
216
- }
217
- assertCurrent(previous) {
218
- if (!sameSettings(this.options.getCommitted(), previous)) {
219
- throw new Error("Settings changed while preparing configuration");
220
- }
221
- }
222
- }
223
- function mergePatch(previous, patch) {
224
- const candidate = { ...previous };
225
- if (patch.mixedPort !== undefined)
226
- candidate.mixedPort = patch.mixedPort;
227
- if (patch.controller !== undefined)
228
- candidate.controller = patch.controller;
229
- if (patch.secret !== undefined)
230
- candidate.secret = patch.secret;
231
- if (patch.tun !== undefined)
232
- candidate.tun = patch.tun;
233
- if (patch.allowLan !== undefined)
234
- candidate.allowLan = patch.allowLan;
235
- if (patch.systemProxy !== undefined)
236
- candidate.systemProxy = patch.systemProxy;
237
- if (patch.daemonPort !== undefined)
238
- candidate.daemonPort = patch.daemonPort;
239
- if (patch.daemonSecret !== undefined)
240
- candidate.daemonSecret = patch.daemonSecret;
241
- return candidate;
242
43
  }