@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.
- package/CHANGELOG.md +23 -0
- package/README.md +6 -3
- package/dist/commands/lifecycle.js +0 -16
- package/dist/commands/status.js +1 -6
- package/dist/commands/web.js +40 -12
- package/dist/contracts.js +16 -0
- package/dist/daemon/app.js +10 -3
- package/dist/daemon/errors.js +3 -1
- package/dist/daemon/handlers/daemon.js +15 -0
- package/dist/daemon/router.js +16 -8
- package/dist/daemon/server.js +1 -1
- package/dist/daemon/web-auth.js +52 -0
- package/dist/daemon-auth.js +2 -2
- package/dist/daemon-client.js +6 -0
- package/dist/daemon-http.js +1 -0
- package/dist/log-follow.js +2 -1
- package/dist/managed-state-transaction.js +6 -8
- package/dist/mihomo-config.js +5 -8
- package/dist/sash-client.js +24 -3
- package/dist/settings-service.js +14 -18
- package/dist/settings.js +5 -1
- package/dist/status.js +0 -24
- package/dist/ui/assets/{CodeEditorModal-CFEnWsyh.js → CodeEditorModal-D1zn-jRS.js} +1 -1
- package/dist/ui/assets/{ConnectionsView-DNGmZBSU.js → ConnectionsView-CfaOG2JV.js} +1 -1
- package/dist/ui/assets/{LogsView-fSiaxQ13.js → LogsView-BDiRATXN.js} +1 -1
- package/dist/ui/assets/{PaginationFooter-B3kHzRfB.js → PaginationFooter-DTytr1iu.js} +1 -1
- package/dist/ui/assets/{ProfileEditorDialog-BydoZthX.js → ProfileEditorDialog-CyyP6OF1.js} +1 -1
- package/dist/ui/assets/{ProfilesView-Btc1DOxE.js → ProfilesView-hFa7-O72.js} +2 -2
- package/dist/ui/assets/{RulesView-D9vZBiJ1.js → RulesView-CjEyCN1A.js} +1 -1
- package/dist/ui/assets/SettingsFileDialog-CjBSyH4K.js +1 -0
- package/dist/ui/assets/SettingsView-D5QvJId6.css +1 -0
- package/dist/ui/assets/SettingsView-DsRntRfn.js +2 -0
- package/dist/ui/assets/{0be242294f7d791af850c6df38ac78a0-2cL6Ntwf.woff2 → e2a57555d97d0b02b45d9418eb6ee295-D9nhF3rM.woff2} +0 -0
- package/dist/ui/assets/index-CxQTf_P9.css +1 -0
- package/dist/ui/assets/index-_mb4OfYp.js +19 -0
- package/dist/ui/assets/{theme-BNq4FkXS.js → theme-BwDBMsKO.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/dist/web-bootstrap.js +113 -0
- package/docs/backend.md +12 -4
- package/docs/frontend.md +13 -3
- package/docs/usage.md +10 -36
- package/package.json +8 -4
- package/dist/tun-guidance.js +0 -11
- 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/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
|
|
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
|
|
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}`);
|
package/dist/commands/status.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { log } from "../log.js";
|
|
2
|
-
import { collectRuntimeStatus, formatObservedProxy,
|
|
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
|
}
|
package/dist/commands/web.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
21
|
+
logger.warn("Core state is unavailable; opening the dashboard for recovery.");
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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");
|
package/dist/daemon/app.js
CHANGED
|
@@ -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: () =>
|
|
149
|
-
|
|
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")),
|
package/dist/daemon/errors.js
CHANGED
|
@@ -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();
|
package/dist/daemon/router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
263
|
+
isSessionToken: (token) => ctx.webAuth.isSession(token),
|
|
256
264
|
})) {
|
|
257
265
|
sendError(res, 401, "unauthorized", "Unauthorized control request");
|
|
258
266
|
return;
|
package/dist/daemon/server.js
CHANGED
|
@@ -38,7 +38,7 @@ export function createDaemonServer(deps) {
|
|
|
38
38
|
}
|
|
39
39
|
if (!isWebSocketRequestAuthorized(req, {
|
|
40
40
|
daemonSecret: context.settings.committed().daemonSecret,
|
|
41
|
-
|
|
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
|
+
}
|
package/dist/daemon-auth.js
CHANGED
|
@@ -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 &&
|
|
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
|
|
59
|
+
return opts.isSessionToken(token);
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
export function webSocketAuthResponseProtocol(value) {
|
package/dist/daemon-client.js
CHANGED
|
@@ -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
|
}
|
package/dist/daemon-http.js
CHANGED
package/dist/log-follow.js
CHANGED
|
@@ -193,7 +193,8 @@ export function followLogFile(file, options) {
|
|
|
193
193
|
if (watcher || stopped)
|
|
194
194
|
return;
|
|
195
195
|
try {
|
|
196
|
-
const
|
|
196
|
+
const directory = fs.realpathSync.native(path.dirname(file));
|
|
197
|
+
const watched = fs.watch(directory, (_event, filename) => {
|
|
197
198
|
if (filename === null || filename.toString() === path.basename(file))
|
|
198
199
|
scheduleDrain();
|
|
199
200
|
});
|
|
@@ -292,10 +292,9 @@ export async function retainManagedStateTransaction(layout, transaction, files =
|
|
|
292
292
|
rollbackErrors.push(`journal: ${clearErr.message}`);
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
}
|
package/dist/mihomo-config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import YAML from "yaml";
|
|
3
3
|
import { ERROR_BODY_LIMIT, fetchWithRetry } from "./http.js";
|
|
4
|
+
import { isPlainObject } from "./json-shape.js";
|
|
4
5
|
export function buildDefaultConfig() {
|
|
5
6
|
return {
|
|
6
7
|
mode: "rule",
|
|
@@ -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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
}
|
package/dist/sash-client.js
CHANGED
|
@@ -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", {
|
|
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"));
|