@astralyn/sash 0.1.0 → 0.1.1

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 (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -3
  3. package/dist/commands/lifecycle.js +0 -16
  4. package/dist/commands/status.js +1 -6
  5. package/dist/commands/web.js +40 -12
  6. package/dist/contracts.js +16 -0
  7. package/dist/daemon/app.js +10 -3
  8. package/dist/daemon/errors.js +3 -1
  9. package/dist/daemon/handlers/daemon.js +15 -0
  10. package/dist/daemon/router.js +16 -8
  11. package/dist/daemon/server.js +1 -1
  12. package/dist/daemon/web-auth.js +52 -0
  13. package/dist/daemon-auth.js +2 -2
  14. package/dist/daemon-client.js +6 -0
  15. package/dist/daemon-http.js +1 -0
  16. package/dist/log-follow.js +2 -1
  17. package/dist/managed-state-transaction.js +6 -8
  18. package/dist/mihomo-config.js +5 -8
  19. package/dist/sash-client.js +24 -3
  20. package/dist/settings-service.js +14 -18
  21. package/dist/settings.js +5 -1
  22. package/dist/status.js +0 -24
  23. package/dist/ui/assets/{CodeEditorModal-CFEnWsyh.js → CodeEditorModal-D1zn-jRS.js} +1 -1
  24. package/dist/ui/assets/{ConnectionsView-DNGmZBSU.js → ConnectionsView-CfaOG2JV.js} +1 -1
  25. package/dist/ui/assets/{LogsView-fSiaxQ13.js → LogsView-BDiRATXN.js} +1 -1
  26. package/dist/ui/assets/{PaginationFooter-B3kHzRfB.js → PaginationFooter-DTytr1iu.js} +1 -1
  27. package/dist/ui/assets/{ProfileEditorDialog-BydoZthX.js → ProfileEditorDialog-CyyP6OF1.js} +1 -1
  28. package/dist/ui/assets/{ProfilesView-Btc1DOxE.js → ProfilesView-hFa7-O72.js} +2 -2
  29. package/dist/ui/assets/{RulesView-D9vZBiJ1.js → RulesView-CjEyCN1A.js} +1 -1
  30. package/dist/ui/assets/SettingsFileDialog-CjBSyH4K.js +1 -0
  31. package/dist/ui/assets/SettingsView-D5QvJId6.css +1 -0
  32. package/dist/ui/assets/SettingsView-DsRntRfn.js +2 -0
  33. package/dist/ui/assets/{0be242294f7d791af850c6df38ac78a0-2cL6Ntwf.woff2 → e2a57555d97d0b02b45d9418eb6ee295-D9nhF3rM.woff2} +0 -0
  34. package/dist/ui/assets/index-CxQTf_P9.css +1 -0
  35. package/dist/ui/assets/index-_mb4OfYp.js +19 -0
  36. package/dist/ui/assets/{theme-BNq4FkXS.js → theme-BwDBMsKO.js} +1 -1
  37. package/dist/ui/index.html +2 -2
  38. package/dist/web-bootstrap.js +113 -0
  39. package/docs/backend.md +12 -4
  40. package/docs/frontend.md +13 -3
  41. package/docs/usage.md +10 -36
  42. package/package.json +8 -4
  43. package/dist/tun-guidance.js +0 -11
  44. package/dist/ui/assets/SettingsFileDialog-CNFEAVs4.js +0 -1
  45. package/dist/ui/assets/SettingsView-BlDhZkXQ.js +0 -2
  46. package/dist/ui/assets/SettingsView-CngS3vBM.css +0 -1
  47. package/dist/ui/assets/index-B61V60w_.js +0 -19
  48. package/dist/ui/assets/index-bkyxJG8J.css +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.1] - 2026-09-08
10
+
11
+ ### Fixed
12
+
13
+ - Start a stopped Core update from its already validated configuration before normal profile publication, preserving coordinated rollback until the health check succeeds. This also fixes first startup after installing Core with `sash update`.
14
+ - Authorize dashboard HTTP and WebSocket control through private, single-use browser handoffs; keep credentials out of public health responses and preserve sessions across page refreshes.
15
+ - Keep the recovery dashboard available when Core startup fails without authorizing competing starts from unknown runtime observations.
16
+ - Fix native Windows log watching, clean font builds, temporary-directory aliases, private browser-handoff file checks and cross-platform test timing.
17
+ - Retry ownership-safe system-proxy cleanup when an explicit disable request repeats the saved off state.
18
+ - Preserve original transaction errors after successful rollback, retain causes on incomplete rollback and report stale settings snapshots as conflicts.
19
+
20
+ ### Added
21
+
22
+ - Publish verified npm artifacts through GitHub Actions trusted publishing, with provenance and registry-install checks.
23
+ - Add an npm release runbook, external package smoke checks and dependency update configuration.
24
+
25
+ ### Changed
26
+
27
+ - Defer TUN and Windows Service Mode to the `feat/tun-service-mode` development branch. This release provides local HTTP/SOCKS endpoints and system-proxy controls, with no TUN or service administration controls.
28
+ - Migrate enabled legacy TUN settings to off, reject new enable requests and explicitly disable TUN in generated configurations. Preserve original profile files and DNS/provider options; reject separate TUN listeners before publication.
29
+
7
30
  ## [0.1.0] - 2026-09-05
8
31
 
9
32
  ### Added
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > A lightweight command-line companion and web dashboard for a rule-based network core.
4
4
 
5
- Sash is a **network toolbox for developers and advanced users**. It installs, runs, and maintains a rule-based network core on your machine: local HTTP/SOCKS endpoints, rule-driven traffic routing, remote profile management, live traffic inspection through a built-in web dashboard, and an optional TUN mode for device-level traffic takeover.
5
+ Sash is a **network toolbox for developers, learning, and research**. It installs, runs, and maintains a rule-based network core on your machine: local HTTP/SOCKS endpoints, rule-driven traffic routing, remote profile management, and live traffic inspection through a built-in web dashboard.
6
6
 
7
7
  ## Features
8
8
 
@@ -12,7 +12,6 @@ Sash is a **network toolbox for developers and advanced users**. It installs, ru
12
12
  - **One-command lifecycle** — `sash start`, `stop`, `restart`, `status`, `logs`
13
13
  - **Remote profiles** — fetch, validate, schedule, and hot-reload core-format network profiles from the dashboard
14
14
  - **Verified upgrades** — SHA-256-verified downloads, bounded extraction, exact-version checks and atomic rollback (`sash update`)
15
- - **TUN mode** — device-level traffic takeover (requires starting the whole Sash runtime with elevated privileges)
16
15
  - **Credential hygiene** — child processes run with scrubbed environments; loopback traffic never traverses proxy dispatchers
17
16
 
18
17
  ## Requirements
@@ -49,11 +48,15 @@ sash status # runtime state, endpoints, and proxy status
49
48
  sash stop # restores prior proxy state, stops core and sashd
50
49
  ```
51
50
 
51
+ Use `sash web` to authorize and open the dashboard. Opening its address directly shows connection instructions. Refreshing an authorized tab preserves access; after restarting Sash, run `sash web` again.
52
+
53
+ TUN and Windows Service Mode are deferred to the [development branch](https://github.com/ming-kang/Sash/tree/feat/tun-service-mode). Version 0.1.1 keeps TUN disabled and runs without a privileged service.
54
+
52
55
  ## Documentation
53
56
 
54
57
  Comprehensive documentation is available in the [`docs/`](./docs) directory:
55
58
 
56
- - [**User & Operations Guide**](./docs/usage.md) — complete CLI command reference, configuration parameters, TUN mode, and troubleshooting.
59
+ - [**User & Operations Guide**](./docs/usage.md) — complete CLI command reference, configuration parameters and troubleshooting.
57
60
  - [**Backend Architecture**](./docs/backend.md) — supervisor daemon model (`sashd`), API endpoints, lifecycle management, system proxy adapters, and safety invariants.
58
61
  - [**Frontend Architecture**](./docs/frontend.md) — built-in Vue 3 + Vite dashboard, shared API contracts, reactive runtime state, and WebSocket streaming.
59
62
  - [**Third-Party Notices**](./THIRD_PARTY_NOTICES.md) — licenses and attribution for code/assets embedded in the dashboard and the runtime-downloaded Core.
@@ -1,13 +1,11 @@
1
1
  import { log } from "../log.js";
2
2
  import { ensureRunning, restartRuntime, stopRuntime } from "../runtime-owner.js";
3
- import { tunPrivilegeGuidance } from "../tun-guidance.js";
4
3
  import { runtimeContext } from "./shared.js";
5
4
  export async function runStart() {
6
5
  const ctx = runtimeContext();
7
6
  const { owner, result } = await ensureRunning(ctx);
8
7
  log.ok(`core started (PID=${result.pid}${result.version ? `, version ${result.version}` : ""})`);
9
8
  printEndpoints(ctx, owner.daemon.port);
10
- reportTunState(ctx, result);
11
9
  if (ctx.settings.systemProxy)
12
10
  log.ok("system proxy enabled");
13
11
  }
@@ -28,20 +26,6 @@ export async function runRestart(deps = {}) {
28
26
  log.ok(`sashd ${verb} (PID=${daemonPid})`);
29
27
  log.ok(`core ${verb} (PID=${result.pid}${result.version ? `, version ${result.version}` : ""})`);
30
28
  printEndpoints(ctx, owner.daemon.port);
31
- reportTunState(ctx, result);
32
- }
33
- function reportTunState(ctx, result) {
34
- if (!ctx.settings.tun)
35
- return;
36
- if (result.tunActive === true) {
37
- log.ok("TUN active");
38
- }
39
- else if (result.tunActive === false) {
40
- log.warn(`TUN was requested but is inactive; the core remains available without TUN. ${tunPrivilegeGuidance("runtime-inactive", { root: ctx.layout.root })}`);
41
- }
42
- else {
43
- log.warn(`TUN was requested, but its runtime state could not be verified. ${tunPrivilegeGuidance("runtime-inactive", { root: ctx.layout.root })}`);
44
- }
45
29
  }
46
30
  export function printEndpoints(ctx, daemonPort) {
47
31
  log.kv("mixed port", `127.0.0.1:${ctx.settings.mixedPort}`);
@@ -1,6 +1,5 @@
1
1
  import { log } from "../log.js";
2
- import { collectRuntimeStatus, formatObservedProxy, formatTunObservation, markIncompleteObservation, runtimeStatusHeadline, shouldShowTunGuidance, } from "../status.js";
3
- import { tunPrivilegeGuidance } from "../tun-guidance.js";
2
+ import { collectRuntimeStatus, formatObservedProxy, markIncompleteObservation, runtimeStatusHeadline, } from "../status.js";
4
3
  import { runtimeContext } from "./shared.js";
5
4
  export async function runStatus(opts = {}, collect = () => collectRuntimeStatus(runtimeContext())) {
6
5
  const status = await collect();
@@ -30,10 +29,6 @@ export async function runStatus(opts = {}, collect = () => collectRuntimeStatus(
30
29
  log.kv("active profile", status.activeProfile
31
30
  ? `${status.activeProfile.name} (${status.activeProfile.url || "local file"})`
32
31
  : "(none)");
33
- log.kv("tun", formatTunObservation(status));
34
32
  log.kv("core version", status.core.installedVersion || "(not installed)");
35
- if (shouldShowTunGuidance(status)) {
36
- log.warn(tunPrivilegeGuidance("runtime-inactive", { root: status.paths.root }));
37
- }
38
33
  markIncompleteObservation(status.complete);
39
34
  }
@@ -1,33 +1,61 @@
1
1
  import { openInBrowser } from "../browser.js";
2
2
  import { log } from "../log.js";
3
3
  import { resolveRuntimeOwner } from "../runtime-owner.js";
4
+ import { writeBootstrapFile } from "../web-bootstrap.js";
4
5
  import { runStart } from "./lifecycle.js";
5
6
  import { runtimeContext } from "./shared.js";
6
7
  export function dashboardUrl(daemonPort) {
7
8
  return `http://127.0.0.1:${daemonPort}/ui/`;
8
9
  }
9
- export async function runWeb(opts = {}) {
10
- const ctx = runtimeContext();
11
- let owner = await resolveRuntimeOwner(ctx);
12
- let coreRunning = false;
10
+ export async function runWeb(opts = {}, deps = {}) {
11
+ const logger = deps.log ?? log;
12
+ const resolve = deps.resolveRuntimeOwner ?? resolveRuntimeOwner;
13
+ const ctx = (deps.runtimeContext ?? runtimeContext)();
14
+ let owner = await resolve(ctx);
15
+ let coreRunning = null;
13
16
  if (owner.kind === "daemon") {
14
17
  try {
15
18
  coreRunning = (await owner.client.status()).core.running;
16
19
  }
17
20
  catch {
18
- coreRunning = false;
21
+ logger.warn("Core state is unavailable; opening the dashboard for recovery.");
19
22
  }
20
23
  }
21
- if (!coreRunning) {
22
- log.info("sash is not running; starting it first...");
23
- await runStart();
24
- owner = await resolveRuntimeOwner(ctx);
24
+ if (owner.kind === "offline" || coreRunning === false) {
25
+ logger.info("sash is not running; starting it first...");
26
+ let started = false;
27
+ try {
28
+ await (deps.runStart ?? runStart)();
29
+ started = true;
30
+ }
31
+ catch (error) {
32
+ try {
33
+ owner = await resolve(ctx);
34
+ }
35
+ catch {
36
+ throw error;
37
+ }
38
+ if (owner.kind !== "daemon")
39
+ throw error;
40
+ logger.warn(`Core startup failed; opening the dashboard for recovery: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ if (started)
43
+ owner = await resolve(ctx);
25
44
  }
26
45
  if (owner.kind !== "daemon") {
27
46
  throw new Error("sashd did not become healthy before opening the dashboard");
28
47
  }
29
48
  const url = dashboardUrl(owner.daemon.port);
30
- log.ok(`dashboard: ${url}`);
31
- if (!opts.noOpen)
32
- openInBrowser(url);
49
+ if (opts.noOpen) {
50
+ logger.ok(`dashboard: ${url}`);
51
+ logger.info("run 'sash web' without --no-open to authorize a browser session");
52
+ return;
53
+ }
54
+ const bootstrap = await owner.client.createWebBootstrap();
55
+ const file = (deps.writeBootstrap ?? writeBootstrapFile)(ctx.layout, {
56
+ dashboardUrl: url,
57
+ ...bootstrap,
58
+ });
59
+ (deps.openInBrowser ?? openInBrowser)(file.fileUrl);
60
+ logger.ok(`dashboard: ${url}`);
33
61
  }
package/dist/contracts.js CHANGED
@@ -163,6 +163,22 @@ export function parseHealthInfo(value) {
163
163
  startedAt: timestampValue(required(source, "startedAt", contract, "startedAt"), contract, "startedAt"),
164
164
  };
165
165
  }
166
+ export function parseWebBootstrapInfo(value) {
167
+ const contract = "sashd web bootstrap";
168
+ const source = objectValue(value, contract, "response");
169
+ return {
170
+ token: stringValue(required(source, "token", contract, "token"), contract, "token", true),
171
+ expiresAt: timestampValue(required(source, "expiresAt", contract, "expiresAt"), contract, "expiresAt"),
172
+ };
173
+ }
174
+ export function parseWebSessionInfo(value) {
175
+ const contract = "sashd web session";
176
+ const source = objectValue(value, contract, "response");
177
+ return {
178
+ token: stringValue(required(source, "token", contract, "token"), contract, "token", true),
179
+ daemonToken: stringValue(required(source, "daemonToken", contract, "daemonToken"), contract, "daemonToken", true),
180
+ };
181
+ }
166
182
  export function parseCoreStartResult(value) {
167
183
  const contract = "sashd core start";
168
184
  const source = objectValue(value, contract, "response");
@@ -12,6 +12,7 @@ import { StateMutationQueue } from "../state-lock.js";
12
12
  import { CoreSupervisor } from "../supervisor.js";
13
13
  import { SystemProxyManager } from "../system-proxy-manager.js";
14
14
  import { DaemonGate } from "./context.js";
15
+ import { WebAuthManager } from "./web-auth.js";
15
16
  export function buildDaemonContext(deps) {
16
17
  const layout = deps.layout;
17
18
  let committedSettings = saveSettings({ ...deps.settings }, layout);
@@ -89,6 +90,9 @@ export function buildDaemonContext(deps) {
89
90
  boundary: "already-held",
90
91
  });
91
92
  const startCore = async () => {
93
+ if (readCoreUpdateTransaction(layout)) {
94
+ return mutate("start pending Core update", () => lifecycle.start());
95
+ }
92
96
  const retryAfterPreparation = Symbol("retry Core start after preparation");
93
97
  for (;;) {
94
98
  let prepared;
@@ -132,6 +136,7 @@ export function buildDaemonContext(deps) {
132
136
  layout,
133
137
  token,
134
138
  startedAt,
139
+ webAuth: new WebAuthManager(),
135
140
  profiles,
136
141
  settingsService,
137
142
  lifecycle,
@@ -145,9 +150,11 @@ export function buildDaemonContext(deps) {
145
150
  mutate,
146
151
  profileRevision: () => profileRevision,
147
152
  startCore,
148
- restartCore: () => withPreparedReloadRetry("restart core", (prepared) => lifecycle.restart(async () => {
149
- await commitPreparedReload(prepared, false);
150
- })),
153
+ restartCore: () => readCoreUpdateTransaction(layout)
154
+ ? startCore()
155
+ : withPreparedReloadRetry("restart core", (prepared) => lifecycle.restart(async () => {
156
+ await commitPreparedReload(prepared, false);
157
+ })),
151
158
  reloadCoreConfig: () => withPreparedReloadRetry("reload core config", (prepared) => commitPreparedReload(prepared, true)),
152
159
  shutdown: () => gate.shutdown(),
153
160
  closeListener: () => Promise.reject(new Error("listener close is not wired yet")),
@@ -1,6 +1,6 @@
1
1
  import { HttpError } from "../daemon-http.js";
2
2
  import { ProfileConflictError, ProfileInputError, ProfileNotFoundError, } from "../profile-service.js";
3
- import { CoreUnhealthyError, SettingsInputError } from "../settings-service.js";
3
+ import { CoreUnhealthyError, SettingsConflictError, SettingsInputError, } from "../settings-service.js";
4
4
  export class ShuttingDownError extends Error {
5
5
  constructor(message = "sashd is shutting down") {
6
6
  super(message);
@@ -39,6 +39,8 @@ export function errorToHttp(err) {
39
39
  return { status: 400, code: "invalid_input", message };
40
40
  if (err instanceof ProfileConflictError)
41
41
  return { status: 409, code: "conflict", message };
42
+ if (err instanceof SettingsConflictError)
43
+ return { status: 409, code: "conflict", message };
42
44
  if (err instanceof CoreUnhealthyError)
43
45
  return { status: 409, code: "core_unhealthy", message };
44
46
  if (err instanceof ShuttingDownError)
@@ -1,9 +1,24 @@
1
1
  import { currentCoreVersion } from "../../core.js";
2
+ import { HttpError } from "../../daemon-http.js";
2
3
  import { publicSettings } from "../../settings.js";
3
4
  export function health(ctx) {
4
5
  const body = { token: ctx.token, pid: process.pid, startedAt: ctx.startedAt };
5
6
  return { status: 200, json: body };
6
7
  }
8
+ export function createWebBootstrap(ctx) {
9
+ const bootstrap = ctx.webAuth.createBootstrap();
10
+ const body = { token: bootstrap.token, expiresAt: bootstrap.expiresAt };
11
+ return { status: 200, json: body };
12
+ }
13
+ export async function redeemWebBootstrap(ctx, req) {
14
+ const body = await req.readJson(1024);
15
+ const token = typeof body.token === "string" ? body.token.trim() : "";
16
+ const session = ctx.webAuth.redeemBootstrap(token);
17
+ if (!session)
18
+ throw new HttpError(401, "Invalid or expired bootstrap token");
19
+ const response = { token: session, daemonToken: ctx.token };
20
+ return { status: 200, json: response };
21
+ }
7
22
  export async function daemonStatus(ctx, req) {
8
23
  const settings = ctx.settings.committed();
9
24
  const runtimeCore = await ctx.supervisor.status();
@@ -4,7 +4,7 @@ import { forwardHttpToCore } from "../daemon-proxy.js";
4
4
  import { serveStaticUi } from "../daemon-static.js";
5
5
  import { errorToHttp } from "./errors.js";
6
6
  import { reloadCoreConfig, restartCore, startCore, stopCore } from "./handlers/core.js";
7
- import { daemonStatus, health, shutdownDaemon } from "./handlers/daemon.js";
7
+ import { createWebBootstrap, daemonStatus, health, redeemWebBootstrap, shutdownDaemon, } from "./handlers/daemon.js";
8
8
  import { activateProfile, addProfile, importProfile, listProfiles, readProfileContent, removeProfile, renameProfile, updateAllProfiles, updateProfile, writeProfileContent, } from "./handlers/profiles.js";
9
9
  import { proxyStatus } from "./handlers/proxy.js";
10
10
  import { patchSettings, readSettings, readSettingsFile, writeSettingsFile, } from "./handlers/settings.js";
@@ -49,6 +49,18 @@ export function buildRoutes() {
49
49
  auth: "control",
50
50
  handler: shutdownDaemon,
51
51
  },
52
+ {
53
+ methods: ["POST"],
54
+ pattern: path("/sash/web/bootstrap"),
55
+ auth: "control",
56
+ handler: createWebBootstrap,
57
+ },
58
+ {
59
+ methods: ["POST"],
60
+ pattern: path("/sash/web/session"),
61
+ auth: "public",
62
+ handler: redeemWebBootstrap,
63
+ },
52
64
  { methods: ["POST"], pattern: path("/sash/core/start"), auth: "control", handler: startCore },
53
65
  { methods: ["POST"], pattern: path("/sash/core/stop"), auth: "control", handler: stopCore },
54
66
  {
@@ -83,12 +95,8 @@ export function buildRoutes() {
83
95
  auth: "control",
84
96
  handler: writeSettingsFile,
85
97
  },
86
- {
87
- methods: ["GET", "POST"],
88
- pattern: path("/sash/profiles"),
89
- auth: "public",
90
- handler: (ctx, req) => (req.method === "POST" ? addProfile(ctx, req) : listProfiles(ctx)),
91
- },
98
+ { methods: ["GET"], pattern: path("/sash/profiles"), auth: "public", handler: listProfiles },
99
+ { methods: ["POST"], pattern: path("/sash/profiles"), auth: "control", handler: addProfile },
92
100
  {
93
101
  methods: ["POST"],
94
102
  pattern: path("/sash/profiles/import"),
@@ -252,7 +260,7 @@ export async function dispatch(ctx, routes, req, res) {
252
260
  if (requiresAuth &&
253
261
  !isControlRequestAuthorized(req, {
254
262
  daemonSecret: ctx.settings.committed().daemonSecret,
255
- bootToken: ctx.token,
263
+ isSessionToken: (token) => ctx.webAuth.isSession(token),
256
264
  })) {
257
265
  sendError(res, 401, "unauthorized", "Unauthorized control request");
258
266
  return;
@@ -38,7 +38,7 @@ export function createDaemonServer(deps) {
38
38
  }
39
39
  if (!isWebSocketRequestAuthorized(req, {
40
40
  daemonSecret: context.settings.committed().daemonSecret,
41
- bootToken: context.token,
41
+ isSessionToken: (token) => context.webAuth.isSession(token),
42
42
  })) {
43
43
  rejectUpgrade(socket, 401, "Unauthorized WebSocket request");
44
44
  return;
@@ -0,0 +1,52 @@
1
+ import crypto from "node:crypto";
2
+ export const WEB_BOOTSTRAP_TTL_MS = 90_000;
3
+ const MAX_PENDING_BOOTSTRAPS = 32;
4
+ const MAX_SESSIONS = 256;
5
+ function hashToken(token) {
6
+ return crypto.createHash("sha256").update(token).digest("hex");
7
+ }
8
+ export class WebAuthManager {
9
+ pendingBootstraps = new Map();
10
+ sessions = new Map();
11
+ createBootstrap(now = Date.now()) {
12
+ this.sweepExpired(now);
13
+ while (this.pendingBootstraps.size >= MAX_PENDING_BOOTSTRAPS) {
14
+ const oldest = this.pendingBootstraps.keys().next().value;
15
+ if (oldest === undefined)
16
+ break;
17
+ this.pendingBootstraps.delete(oldest);
18
+ }
19
+ const token = crypto.randomBytes(32).toString("hex");
20
+ const expiresAtMs = now + WEB_BOOTSTRAP_TTL_MS;
21
+ this.pendingBootstraps.set(hashToken(token), expiresAtMs);
22
+ return { token, expiresAt: new Date(expiresAtMs).toISOString() };
23
+ }
24
+ redeemBootstrap(token, now = Date.now()) {
25
+ this.sweepExpired(now);
26
+ if (!token)
27
+ return null;
28
+ const key = hashToken(token);
29
+ const expiresAt = this.pendingBootstraps.get(key);
30
+ if (expiresAt === undefined || expiresAt <= now)
31
+ return null;
32
+ this.pendingBootstraps.delete(key);
33
+ while (this.sessions.size >= MAX_SESSIONS) {
34
+ const oldest = this.sessions.keys().next().value;
35
+ if (oldest === undefined)
36
+ break;
37
+ this.sessions.delete(oldest);
38
+ }
39
+ const session = crypto.randomBytes(32).toString("hex");
40
+ this.sessions.set(hashToken(session), true);
41
+ return session;
42
+ }
43
+ isSession(token) {
44
+ return token !== "" && this.sessions.has(hashToken(token));
45
+ }
46
+ sweepExpired(now) {
47
+ for (const [key, expiresAt] of this.pendingBootstraps) {
48
+ if (expiresAt <= now)
49
+ this.pendingBootstraps.delete(key);
50
+ }
51
+ }
52
+ }
@@ -41,7 +41,7 @@ export function isControlRequestAuthorized(req, opts) {
41
41
  if (bearer && opts.daemonSecret && secretsEqual(bearer, opts.daemonSecret))
42
42
  return true;
43
43
  const webToken = firstHeader(req.headers["x-sash-token"]).trim();
44
- return Boolean(webToken && secretsEqual(webToken, opts.bootToken));
44
+ return Boolean(webToken && opts.isSessionToken(webToken));
45
45
  }
46
46
  function webSocketProtocols(value) {
47
47
  return (Array.isArray(value) ? value.join(",") : (value ?? ""))
@@ -56,7 +56,7 @@ export function isWebSocketRequestAuthorized(req, opts) {
56
56
  if (!protocol.startsWith(WEB_SOCKET_TOKEN_PROTOCOL_PREFIX))
57
57
  return false;
58
58
  const token = protocol.slice(WEB_SOCKET_TOKEN_PROTOCOL_PREFIX.length);
59
- return Boolean(token && secretsEqual(token, opts.bootToken));
59
+ return opts.isSessionToken(token);
60
60
  });
61
61
  }
62
62
  export function webSocketAuthResponseProtocol(value) {
@@ -32,6 +32,9 @@ export class SashDaemonClient {
32
32
  health() {
33
33
  return this.client.health();
34
34
  }
35
+ createWebBootstrap() {
36
+ return this.client.createWebBootstrap();
37
+ }
35
38
  async isReachable() {
36
39
  try {
37
40
  return Boolean((await this.health()).token);
@@ -46,6 +49,9 @@ export class SashDaemonClient {
46
49
  startCore() {
47
50
  return this.client.startCore();
48
51
  }
52
+ patchSettings(patch) {
53
+ return this.client.patchSettings(patch);
54
+ }
49
55
  maintenanceShutdown() {
50
56
  return this.client.shutdown();
51
57
  }
@@ -85,6 +85,7 @@ export function sendJson(res, statusCode, data) {
85
85
  res.writeHead(statusCode, {
86
86
  "Content-Type": "application/json; charset=utf-8",
87
87
  "Content-Length": Buffer.byteLength(body),
88
+ "Cache-Control": "no-store",
88
89
  });
89
90
  res.end(body);
90
91
  }
@@ -193,7 +193,8 @@ export function followLogFile(file, options) {
193
193
  if (watcher || stopped)
194
194
  return;
195
195
  try {
196
- const watched = fs.watch(path.dirname(file), (_event, filename) => {
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
  });
@@ -292,10 +292,9 @@ export async function retainManagedStateTransaction(layout, transaction, files =
292
292
  rollbackErrors.push(`journal: ${clearErr.message}`);
293
293
  }
294
294
  }
295
- const suffix = rollbackErrors.length
296
- ? `; managed-state transaction rollback failed: ${rollbackErrors.join("; ")}`
297
- : "";
298
- throw new Error(`${err.message}${suffix}`);
295
+ if (rollbackErrors.length === 0)
296
+ throw err;
297
+ throw new Error(`${err.message}; managed-state transaction rollback failed: ${rollbackErrors.join("; ")}`, { cause: err });
299
298
  }
300
299
  }
301
300
  export function rollbackRetainedManagedStateTransaction(layout, files = defaultManagedStateFileOperations) {
@@ -369,9 +368,8 @@ export async function commitManagedStateTransaction(layout, transaction, reloadC
369
368
  rollbackErrors.push(`journal: ${clearErr.message}`);
370
369
  }
371
370
  }
372
- const suffix = rollbackErrors.length
373
- ? `; managed-state transaction rollback failed: ${rollbackErrors.join("; ")}`
374
- : "";
375
- throw new Error(`${err.message}${suffix}`);
371
+ if (rollbackErrors.length === 0)
372
+ throw err;
373
+ throw new Error(`${err.message}; managed-state transaction rollback failed: ${rollbackErrors.join("; ")}`, { cause: err });
376
374
  }
377
375
  }
@@ -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",
@@ -312,14 +313,10 @@ export function overlayManagedKeys(base, settings) {
312
313
  out["allow-lan"] = settings.allowLan;
313
314
  out["external-controller"] = settings.controller;
314
315
  out.secret = settings.secret;
315
- if (settings.tun) {
316
- out.tun = {
317
- enable: true,
318
- stack: "mixed",
319
- "auto-route": true,
320
- "auto-detect-interface": true,
321
- "dns-hijack": ["any:53"],
322
- };
316
+ out.tun = { enable: false };
317
+ if (Array.isArray(out.listeners) &&
318
+ out.listeners.some((listener) => isPlainObject(listener) && listener.type === "tun")) {
319
+ throw new Error("TUN listeners are unavailable in this release");
323
320
  }
324
321
  return out;
325
322
  }
@@ -1,4 +1,4 @@
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 { parseApiErrorBody, parseCoreReloadResult, parseCoreStartResult, parseDaemonStatus, parseHealthInfo, parseProfileActionResponse, parseProfileActivateResponse, parseProfileContentResponse, parseProfileRemoveResponse, parseProfileRenameResponse, parseProfilesIndex, parseProfilesUpdateAllResponse, parseProfileUpdateResponse, parsePublicSettings, parseSettingsFileContent, parseSettingsWriteResult, parseShutdownResult, parseSystemProxyStatusResponse, parseWebBootstrapInfo, parseWebSessionInfo, } from "./contracts.js";
2
2
  const defaultFetch = async (url, init) => {
3
3
  const response = await fetch(url, {
4
4
  method: init.method,
@@ -24,6 +24,7 @@ export class SashClient {
24
24
  tokenHeader;
25
25
  fetchFn;
26
26
  timeoutMs;
27
+ onUnauthorized;
27
28
  constructor(options) {
28
29
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
29
30
  if (options.token)
@@ -31,10 +32,12 @@ export class SashClient {
31
32
  this.tokenHeader = options.tokenHeader ?? "authorization";
32
33
  this.fetchFn = options.fetchFn ?? defaultFetch;
33
34
  this.timeoutMs = options.timeoutMs ?? 5_000;
35
+ if (options.onUnauthorized)
36
+ this.onUnauthorized = options.onUnauthorized;
34
37
  }
35
38
  async request(endpoint, options = {}) {
36
39
  const headers = {};
37
- const token = this.token?.() ?? "";
40
+ const token = options.authenticate === false ? "" : (this.token?.() ?? "");
38
41
  if (token) {
39
42
  if (this.tokenHeader === "x-sash-token")
40
43
  headers["X-Sash-Token"] = token;
@@ -64,6 +67,8 @@ export class SashClient {
64
67
  }
65
68
  data = text;
66
69
  }
70
+ if (response.status === 401 && token)
71
+ this.onUnauthorized?.(token);
67
72
  if (response.status < 200 || response.status >= 300) {
68
73
  const parsedError = parseApiErrorBody(data);
69
74
  const message = parsedError?.message ?? (typeof data === "string" ? data.slice(0, 300).trim() : "");
@@ -72,7 +77,23 @@ export class SashClient {
72
77
  return data;
73
78
  }
74
79
  async health() {
75
- return parseHealthInfo(await this.request("/sash/daemon/health", { timeoutMs: 2_000, attempts: 1 }));
80
+ return parseHealthInfo(await this.request("/sash/daemon/health", {
81
+ timeoutMs: 2_000,
82
+ attempts: 1,
83
+ authenticate: false,
84
+ }));
85
+ }
86
+ async createWebBootstrap() {
87
+ return parseWebBootstrapInfo(await this.request("/sash/web/bootstrap", { method: "POST", timeoutMs: 5_000 }));
88
+ }
89
+ async redeemWebBootstrap(token) {
90
+ return parseWebSessionInfo(await this.request("/sash/web/session", {
91
+ method: "POST",
92
+ body: { token },
93
+ timeoutMs: 5_000,
94
+ attempts: 1,
95
+ authenticate: false,
96
+ }));
76
97
  }
77
98
  async status(fresh = false) {
78
99
  return parseDaemonStatus(await this.request(fresh ? "/sash/daemon/status?fresh=1" : "/sash/daemon/status"));