@astralyn/sash 0.1.1 → 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 (102) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +17 -5
  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 -15
  19. package/dist/commands/logs.js +11 -4
  20. package/dist/commands/shared.js +2 -4
  21. package/dist/commands/status.js +5 -1
  22. package/dist/commands/update.js +7 -3
  23. package/dist/commands/web.js +14 -50
  24. package/dist/contracts.js +168 -348
  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 +129 -117
  29. package/dist/daemon/context.js +13 -7
  30. package/dist/daemon/entry.js +21 -38
  31. package/dist/daemon/errors.js +3 -0
  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 +17 -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 +26 -21
  38. package/dist/daemon/scheduler.js +3 -1
  39. package/dist/daemon/server.js +5 -2
  40. package/dist/daemon-client.js +13 -3
  41. package/dist/daemon-http.js +6 -0
  42. package/dist/daemon-lifecycle.js +19 -196
  43. package/dist/github.js +7 -2
  44. package/dist/http.js +7 -5
  45. package/dist/mihomo-config.js +2 -1
  46. package/dist/paths.js +5 -7
  47. package/dist/profile-model.js +87 -0
  48. package/dist/profile-service.js +250 -587
  49. package/dist/profiles.js +43 -171
  50. package/dist/runtime-lifecycle.js +112 -194
  51. package/dist/runtime-owner.js +37 -76
  52. package/dist/sash-client.js +40 -19
  53. package/dist/settings-service.js +29 -224
  54. package/dist/settings.js +61 -289
  55. package/dist/status.js +24 -16
  56. package/dist/supervisor.js +1 -14
  57. package/dist/sysproxy/common.js +5 -45
  58. package/dist/sysproxy/factory.js +24 -65
  59. package/dist/sysproxy/snapshot.js +24 -257
  60. package/dist/sysproxy.js +1 -4
  61. package/dist/system-proxy-manager.js +37 -35
  62. package/dist/ui/assets/{ConnectionsView-CfaOG2JV.js → ConnectionsView-BgXQM6Z4.js} +1 -1
  63. package/dist/ui/assets/{LogsView-BDiRATXN.js → LogsView-2f542P8z.js} +1 -1
  64. package/dist/ui/assets/{PaginationFooter-DTytr1iu.js → PaginationFooter-cj1tcZej.js} +1 -1
  65. package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
  66. package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
  67. package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
  68. package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
  69. package/dist/ui/assets/{RulesView-CjEyCN1A.js → RulesView-CtKvlckZ.js} +1 -1
  70. package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
  71. package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
  72. package/dist/ui/assets/index-CBInDvdJ.js +19 -0
  73. package/dist/ui/assets/index-mOLy7fkB.css +1 -0
  74. package/dist/ui/assets/{theme-BwDBMsKO.js → theme-D40MF8cQ.js} +1 -1
  75. package/dist/ui/index.html +2 -2
  76. package/docs/architecture-proposal.md +109 -0
  77. package/docs/autostart.md +101 -0
  78. package/docs/backend.md +92 -224
  79. package/docs/frontend.md +60 -106
  80. package/docs/usage.md +92 -164
  81. package/package.json +3 -3
  82. package/dist/commands/upgrade.js +0 -43
  83. package/dist/core-install-transaction.js +0 -114
  84. package/dist/core-update-coordination.js +0 -105
  85. package/dist/core-update-service.js +0 -245
  86. package/dist/managed-state-transaction.js +0 -375
  87. package/dist/offline-mutation.js +0 -58
  88. package/dist/profile-migration.js +0 -101
  89. package/dist/runtime-recovery.js +0 -31
  90. package/dist/sysproxy/darwin.js +0 -287
  91. package/dist/sysproxy/gnome.js +0 -181
  92. package/dist/sysproxy/legacy.js +0 -58
  93. package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
  94. package/dist/ui/assets/CodeEditorModal-D1zn-jRS.js +0 -14
  95. package/dist/ui/assets/ProfileEditorDialog-CyyP6OF1.js +0 -1
  96. package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
  97. package/dist/ui/assets/ProfilesView-hFa7-O72.js +0 -2
  98. package/dist/ui/assets/SettingsFileDialog-CjBSyH4K.js +0 -1
  99. package/dist/ui/assets/SettingsView-D5QvJId6.css +0 -1
  100. package/dist/ui/assets/SettingsView-DsRntRfn.js +0 -2
  101. package/dist/ui/assets/index-CxQTf_P9.css +0 -1
  102. package/dist/ui/assets/index-_mb4OfYp.js +0 -19
package/docs/backend.md CHANGED
@@ -1,268 +1,136 @@
1
- # Backend Architecture & Supervisor Design
2
-
3
- Sash uses a loopback-only supervisor daemon (`sashd`) to own the Core process, generated configuration, profile state, system-proxy ownership and the HTTP/WebSocket gateway.
4
-
5
- ```text
6
- CLI / WebUI
7
- │ http://127.0.0.1:19090
8
-
9
- sashd
10
- ├── /sash/* daemon control, Core lifecycle, settings, profiles, system proxy
11
- ├── /core/api/* authenticated controller reverse proxy
12
- ├── ProfileService
13
- ├── RuntimeLifecycle
14
- │ ├── CoreSupervisor
15
- │ └── SystemProxyManager
16
- └── state locks / atomic persistence
17
- ```
18
-
19
- The Core remains a non-detached child of `sashd`. Runtime transitions, disk mutations and daemon startup are serialized at separate boundaries instead of relying on PID files as locks.
20
-
21
- ---
22
-
23
- ## 1. Module Boundaries
24
-
25
- - `src/daemon.ts`: public facade re-exporting the daemon surface.
26
- - `src/daemon/router.ts`: the single URLPattern route table plus match → auth → dispatch; `src/daemon/handlers/*` own the per-domain handlers, `src/daemon/errors.ts` maps domain errors onto the unified error envelope, and `src/daemon/context.ts` holds the shared `DaemonContext`/`DaemonGate`.
27
- - `src/daemon/app.ts`: service assembly around one mutation queue; `src/daemon/server.ts` owns the HTTP/WebSocket listeners and listener close; `src/daemon/scheduler.ts` owns profile auto-update timers; `src/daemon/entry.ts` is the production entrypoint.
28
- - `src/daemon/web-auth.ts`: bounded in-memory bootstrap/session credentials. `src/web-bootstrap.ts` writes the private browser handoff used by `sash web`.
29
- - `src/runtime-lifecycle.ts`: serialized Core/proxy state transitions.
30
- - `src/supervisor.ts`: child ownership, readiness probes and verified termination.
31
- - `src/daemon-lifecycle.ts`: daemon discovery, singleton startup, CLI shutdown and the maintenance boundary used by full restarts and Core updates.
32
- - `src/state-lock.ts`: atomic file leases and cross-process mutation queues.
33
- - `src/system-proxy-manager.ts`: durable proxy ownership journal, serialized asynchronous OS operations, generation-bound observation cache and conditional recovery.
34
- - `src/sysproxy.ts` / `src/sysproxy/`: public system-proxy API plus focused Windows, macOS and GNOME asynchronous snapshot/apply backends.
35
- - `src/profile-service.ts`: profile/config preparation and publication through one-shot opaque capabilities with strict optimistic rechecks.
36
- - `src/core-install-record.ts`: the canonical install-record codec and release-tag validation shared by install and update paths.
37
- - `src/core-update.ts`: the low-level executable/install-record transaction, force-repair quarantine and crash recovery.
38
- - `src/core-update-coordination.ts`: one logical commit/rollback decision across retained managed state and the Core update journal.
39
- - `src/core-update-service.ts`: staged update, runtime ownership, maintenance, publication and restoration orchestration.
40
- - `src/offline-mutation.ts` / `src/runtime-recovery.ts`: daemon/offline ownership checks and the fixed legacy-proxy, journaled-proxy, stale-Core and update-recovery order.
41
- - `src/http.ts` / `src/github.ts`: bounded networking and trusted release downloads, including one absolute asset budget shared across mirror attempts and redirects.
42
- - `src/settings.ts`: versioned runtime schema, explicit public-field allowlist and candidate validation for `sash.json`.
43
- - `src/settings-service.ts`: shared online/offline settings preparation, durable publication and runtime-transition orchestration behind a single typed `apply(patch)` transaction.
44
- - `src/contracts.ts`: browser-safe API contracts and `unknown`-to-typed response parsers shared by the daemon client and WebUI.
45
- - `src/sash-client.ts`: browser-safe typed client for the daemon-owned `/sash/*` API, built from those parsers; `src/daemon-client.ts` is the Node factory adding retries, deadlines and loopback-only dispatch.
46
- - `src/runtime-owner.ts`: runtime ownership state machine and the start/stop/restart orchestration consumed by the thin `src/commands/*` wiring modules.
47
- - `src/json-shape.ts` / `src/error-utils.ts`: domain-neutral JSON shape, canonical timestamp and unknown-error helpers; persistent readers retain their own size, missing and corruption policies.
48
- - `src/status.ts`: stable CLI status/proxy observations, explicit unknown values and complete/incomplete exit semantics.
49
- - `src/log-follow.ts`: bounded tail/follow cursors with creation, truncation, identity-rotation and cancellation handling.
50
- - `web/src/stores/state.ts`: the single reactive WebUI state source, runtime ownership metadata and computed selectors. Focused runtime, profile, Core, telemetry and toast modules import it directly; `web/src/stores/index.ts` is only the stable public re-export facade.
51
- - `web/src/views/OverviewView.vue`: page header, Core restart and responsive two-column composition. `OverviewGeneralPane.vue` owns common controls and traffic, `OverviewProxyPane.vue` owns mode-driven groups, and `useProxyLatency()` owns generation-bound latency requests.
52
-
53
- ---
54
-
55
- ## 2. Ownership and Serialization
56
-
57
- ### Daemon ownership
1
+ # Backend Architecture
58
2
 
59
- `sashd` acquires `state/sashd.lock` before reading or migrating persistent state. The lock record contains a random token, PID, purpose and timestamp. A PID file is discovery metadata only; it is never the singleton authority.
3
+ Sash manages one local Core through one loopback daemon. The [high-level architecture](./architecture-proposal.md) explains the overall design; this document defines its implementation boundaries.
60
4
 
61
- - `state/sashd-start.lock` serializes concurrent CLI spawn attempts.
62
- - `state/runtime.lock` serializes top-level `start`, `stop`, `restart` and Core update operations.
63
- - `state/mutation.lock` separates daemon-owned mutations from offline CLI mutations.
64
- - `state/settings.lock` prevents concurrent first-run secret generation and settings rewrites.
65
- - `state/system-proxy.json.lock` serializes proxy journal operations.
5
+ ## Ownership
66
6
 
67
- Lock records are fully written and fsynced before an atomic hard-link publishes them. Synchronous and asynchronous callers share one acquisition decision and differ only in how they wait, so live-owner, dead-owner, corruption and deadline rules cannot drift. A live owner is never displaced. Dead owners can be reclaimed; corrupt records fail closed and require explicit repair. If a lock disappears between metadata inspection and bounded content read it is retried as a missing observation rather than mislabeled corrupt. Durable rename/remove operations retry Windows sharing violations without deleting a caller-owned source; an interrupted executable unlock probe is restored before Core consistency checks, while conflicting target/probe bytes are both preserved for explicit repair.
7
+ `daemon/entry.ts` acquires the instance lease, initializes `SashStateStore`, restores proxy ownership, terminates verified orphan Core processes, and recovers interrupted binary updates before opening the listener. Missing Core does not prevent management startup.
68
8
 
69
- Offline commands reload settings after acquiring `mutation.lock`. Ordinary mutations refuse a live orphan Core or corrupt PID record. Lifecycle and update callers must explicitly request reconciliation, which restores legacy and journaled proxy ownership before terminating only a verified stale Core and then recovering coordinated update state.
9
+ `daemon/app.ts` assembles the profile, settings, runtime and Windows services. `DaemonGate` in `daemon/context.ts` owns one in-memory mutation queue and shutdown admission. Reads do not wait for this queue. Downloads happen outside it, with deadlines and cancellation; commits reject stale saved-state revisions or runtime changes.
70
10
 
71
- CLI commands resolve one tagged runtime owner before choosing daemon or offline behavior. Only the healthy branch constructs a daemon client, and both control requests and displayed dashboard/API endpoints use the daemon's observed PID-record port rather than a potentially stale configured port. Confirmed-stopped and unresponsive owners remain distinct so an uncertain live daemon is never treated as offline.
11
+ CLI commands use `runtime-owner.ts` and `daemon-lifecycle.ts` for read-only discovery, management startup and API calls. A live but unverified daemon blocks competing startup and cannot be stopped by an unverified signal. CLI discovery uses the observed daemon port. The private browser handoff and startup diagnostics are the only incidental CLI files.
72
12
 
73
- ### Runtime lifecycle
13
+ | Module | Responsibility |
14
+ | --- | --- |
15
+ | `app-state.ts` | The sole atomic application manifest commit |
16
+ | `settings.ts`, `settings-service.ts` | Validate and save preferences; reconcile explicit proxy intent |
17
+ | `profile-model.ts`, `profiles.ts`, `profile-service.ts` | Validate metadata/YAML, manage immutable sources and saved selection |
18
+ | `runtime-lifecycle.ts` | Order Core and proxy changes; retain the applied configuration and runtime revision |
19
+ | `supervisor.ts`, `process.ts` | Owned child handles, version/health checks and verified termination |
20
+ | `core.ts`, `core-update.ts`, `core-install-record.ts` | Trusted downloads and one executable/install-record transaction |
21
+ | `system-proxy-manager.ts`, `sysproxy/` | Windows proxy snapshot, verification and conditional recovery |
22
+ | `autostart.ts`, `autostart/` | Windows current-user registration and launcher validation |
23
+ | `daemon/router.ts`, `daemon/handlers/` | Route matching, authentication, parsing and domain dispatch |
24
+ | `contracts.ts`, `sash-client.ts`, `daemon-client.ts` | Shared browser-safe protocol and direct Node transport |
74
25
 
75
- `RuntimeLifecycle` is the only daemon layer that combines Core and system-proxy transitions. Operations enter one promise queue and update a small phase model (`stopped`, `starting`, `running`, `stopping`, `restarting`, `failed`) with a monotonic generation.
26
+ Persistent locks remain only where separate processes share a resource: daemon startup/singleton ownership and per-user Windows proxy/autostart operations. There are no runtime, settings or offline mutation locks and no CLI maintenance handoff.
76
27
 
77
- Invariants:
28
+ ## Saved state
78
29
 
79
- 1. A start prepares and Core-validates the exact active config before spawn.
80
- 2. The Core must pass two readiness probes before it is considered healthy.
81
- 3. After readiness, a bounded `/configs` probe records the actual `tun.enable` state; probe failure is represented as unknown instead of being mistaken for inactive.
82
- 4. Desired system proxy is applied only after readiness.
83
- 5. A deliberate stop restores the previous OS proxy before stopping the Core.
84
- 6. If proxy restoration cannot be proved, the healthy Core is left running.
85
- 7. Late child-exit events and delayed cleanup callbacks cannot clear a replacement child.
86
- 8. Controller status probes retain an owned-child generation snapshot and report stopped if that child exits or is replaced while the probe is pending.
87
- 9. System-proxy application verifies the same healthy owned child before and after OS changes; lost ownership immediately triggers proxy release.
88
- 10. Process ownership is revalidated before every graceful or force signal; failed verification or termination preserves PID ownership.
30
+ The only supported application manifest is:
89
31
 
90
- Unexpected Core exit retries proxy restoration and records failures in daemon error logs.
91
-
92
- ---
93
-
94
- ## 3. API Namespaces
95
-
96
- There are exactly two HTTP namespaces plus the static dashboard. Every `/sash/*` route is implemented by `sashd` itself; every `/core/api/*` route is reverse-proxied to the Core controller. There are no root-level aliases and no unprefixed duplicates.
97
-
98
- ```text
99
- /sash/* implemented by sashd
100
- /core/api/* forwarded to the Core controller (HTTP and WebSocket share one rule)
101
- /ui/* static dashboard assets
102
- / 302 redirect to /ui/
32
+ ```ts
33
+ {
34
+ schemaVersion: 2,
35
+ revision: number,
36
+ settings: { mixedPort, controller, secret, allowLan, daemonPort, daemonSecret, systemProxy },
37
+ profiles: { activeId: string | null, profiles: ProfileMeta[] }
38
+ }
103
39
  ```
104
40
 
105
- ### `/sash/*`
106
-
107
- | Endpoint | Method | Auth | Description |
108
- | :--- | :--- | :--- | :--- |
109
- | `/sash/daemon/health` | `GET` | public | Readiness, PID, start time and per-boot identity nonce; never a credential. |
110
- | `/sash/web/bootstrap` | `POST` | control | Mint a single-use browser handoff, returning `{token, expiresAt}`. |
111
- | `/sash/web/session` | `POST` | bootstrap body | Exchange `{token}` for a private browser session `{token, daemonToken}`. |
112
- | `/sash/daemon/status` | `GET` | public | Daemon/Core/proxy/public-settings snapshot; `core.tunActive` is the verified runtime TUN state when available, while proxy `appliedKnown`/`stateKnown` and `queryError` preserve OS observation uncertainty. |
113
- | `/sash/daemon/shutdown` | `POST` | control | Under the daemon mutation queue, snapshot whether Core was running, restore proxy/stop Core, return `{coreWasRunning}`, then close. Cleanup failure returns `500` and leaves the daemon available for retry. |
114
- | `/sash/core/start` | `POST` | control | Rebuild config, start and wait for readiness; returns `{pid, version?, tunActive?}`. |
115
- | `/sash/core/stop` | `POST` | control | Restore proxy, then stop the child; `204`. |
116
- | `/sash/core/restart` | `POST` | control | Rebuild config and execute one serialized replacement; same body as start. |
117
- | `/sash/core/reload` | `POST` | control | Re-render, validate and reload active config; returns `{proxyCount, source}`. |
118
- | `/sash/proxy` | `GET` | public | Desired, Sash-owned and observed OS proxy state. |
119
- | `/sash/settings` | `GET` | public | Public settings projection (secrets omitted). |
120
- | `/sash/settings` | `PATCH` | control | Partial-object update (`SettingsPatch`); one transaction per apply, returns `{restartRequired, settings}`. Enabling `systemProxy` requires a healthy Core. |
121
- | `/sash/settings/file` | `GET` | control | Raw canonical `sash.json` text, including secrets. |
122
- | `/sash/settings/file` | `PUT` | control | Replace settings from raw text; parsed, diffed and applied through the same `SettingsService.apply` path. |
123
- | `/sash/profiles` | `GET` / `POST` | public / control | List profiles; add a remote subscription. |
124
- | `/sash/profiles/import` | `POST` | control | Import a local profile document. |
125
- | `/sash/profiles/active` | `PUT` | control | Activate a profile id or `null`. |
126
- | `/sash/profiles/update-all` | `POST` | control | Update every profile; always `200`, per-profile failures in `failed`. |
127
- | `/sash/profiles/:id/content` | `GET` / `PUT` | control | Read or replace raw profile YAML. |
128
- | `/sash/profiles/:id/update` | `POST` | control | Re-fetch one remote profile. |
129
- | `/sash/profiles/:id` | `PATCH` / `DELETE` | control | Rename or remove one profile. |
130
-
131
- Appending `?fresh=1` to status/proxy reads bypasses the short OS-state cache used by normal WebUI polling.
132
-
133
- ### Response envelope
134
-
135
- Success responses return the resource body directly with no `ok` field; mutations with no content return `204`. Error responses always carry `{error: {code, message}}` where `code` is machine-readable: `invalid_input`, `not_found`, `conflict`, `core_unhealthy`, `shutting_down`, `unauthorized`, `http` or `internal`. A `500` never leaks internal error text. `update-all` reports per-profile business failures through its `failed` array at HTTP `200`.
41
+ `ProfileMeta` contains an ID, a content revision, a display name, source URL, update interval, timestamps and optional provider/error metadata. Content lives in `profiles/<id>/<revision>.yaml`. Names and paths are separate; clients cannot provide arbitrary file paths.
136
42
 
137
- The WebUI store has one runtime-refresh path. Normal polling keeps a coherent same-owner Core snapshot until it is missing, degraded or stale for the current profile revision, while explicit post-mutation refreshes force a complete snapshot. Connections, proxies and rules use the same generation-bound fetch/adopt/error template, so responses from an older runtime owner cannot overwrite current state.
43
+ Saving source content validates bounded core-format YAML, atomically writes a new immutable file, then atomically commits its reference with the rest of `sash.json`. A crash before the manifest commit leaves the previous source referenced. Deletion commits removal before cleanup. Unreferenced files may remain after interrupted cleanup; they are not a second source of truth. Identical remote bytes retain the content revision. Editor writes must include the revision read when opening the editor; stale writes return a conflict.
138
44
 
139
- The Node daemon client and browser WebUI share one browser-safe client built on `src/contracts.ts`: successful bodies are read as `unknown` and passed through per-resource parsers. Required nested fields, positive safe-integer PIDs, valid ports, nonnegative revisions, canonical timestamps, optional Core fields, proxy state and explicit public settings are validated before state changes. Unknown extra fields are tolerated for forward compatibility but discarded from the typed projection. `appliedKnown` and `stateKnown` are mandatory inside the current daemon; only the network parser normalizes flags omitted by a legacy daemon to `false`. A malformed `200` response is therefore an error, not trusted TypeScript data.
45
+ The manifest is capped at 2 MiB, profile content at 8 MiB. Readers reject invalid schemas, duplicate IDs, invalid revisions, non-regular files and oversized content. Secrets must be nonblank, the controller must be loopback-only, and all listener ports must differ. No legacy formats or migrations are accepted. Existing invalid state is preserved.
140
46
 
141
- ### `/core/api/*`
47
+ `runtime/config.yaml` is derived from the selected saved profile or the built-in DIRECT-only default. Source YAML is preserved verbatim. Sash overlays operational ports, controller credentials and LAN access, removes competing managed keys, disables TUN and rejects separate TUN listeners.
142
48
 
143
- Every request in this namespace requires the persistent CLI bearer or per-boot WebUI token before an upstream connection is opened. `sashd` strips Sash credentials and the browser Host header, then injects the internal controller bearer.
49
+ ## Save, Apply and stop
144
50
 
145
- HTTP and WebSocket routing consume one parsed origin-form request target. Absolute-form, authority-form, asterisk-form, network-path and cross-authority backslash targets are rejected with `400`. Route matching removes only trailing route slashes; WHATWG dot-segment normalization happens once, encoded slashes are not decoded again, and the same canonical pathname constructs the Core target. In particular, `/core/api?x=1` forwards as `/?x=1`, repeated namespace-root slashes collapse to `/`, and the query is appended exactly once.
51
+ Network preference changes, profile selection, edits and scheduled downloads only save state. They do not restart or reload Core. System-proxy intent is a separate OS action: enable requires a healthy owned Core; disable saves the off preference before attempting restoration and can be retried without another manifest write.
146
52
 
147
- Known paths with the wrong method return `405` plus an `Allow` header derived from the route table. Dashboard redirects accept only `GET`/`HEAD` and preserve the root query.
53
+ Apply executes inside the daemon queue:
148
54
 
149
- Traffic/log streams are authenticated `GET` WebSocket upgrades under `/core/api/traffic` and `/core/api/logs`. HTTP and WebSocket share the same gateway rule: only `/core/api/*` is forwarded, and WebSocket upgrades only accept `GET`.
55
+ 1. Generate the candidate from saved state and run Core's configuration validator against a private temporary file.
56
+ 2. Restore the original system proxy. Failure leaves the current healthy Core running.
57
+ 3. Stop the verified Core and ensure its controller is vacant.
58
+ 4. Atomically publish the generated configuration.
59
+ 5. Start Core, verify the expected version through consecutive readiness probes, and record the applied configuration.
60
+ 6. Reconcile the saved system-proxy preference against the actual running port.
150
61
 
151
- ---
62
+ Validation failure leaves the old runtime untouched. Failure after stopping does not roll back saved edits; management stays available and reports the unapplied configuration. There is no general settings/profile/runtime compensation transaction.
152
63
 
153
- ## 4. Control-Request Security
64
+ Stop and shutdown cancel preparation work, including downloads and the configuration-test child. Shutdown rejects later mutations, drains the active operation, restores proxy state, stops Core, acknowledges with `204`, then closes the listener. Cleanup failure keeps the daemon available for retry. An active binary swap completes or rolls back in order.
154
65
 
155
- - The daemon listener binds only to `127.0.0.1`, rejects non-loopback Host headers, and only accepts loopback Core controller addresses.
156
- - State-changing methods and every HTTP Core-gateway route require the persistent CLI bearer or a private WebUI session token. The sole mutation exception, `POST /sash/web/session`, validates its single-use bootstrap credential independently. Any request carrying a non-loopback Origin header is rejected outright, regardless of method.
157
- - WebSocket upgrades validate loopback Origin, authentication and route boundaries.
158
- - Public settings/status contracts omit controller and daemon secrets.
159
- - Public health's `token` identifies the daemon boot for lifecycle checks; HTTP and WebSocket authorization never accept it. JSON API responses use `Cache-Control: no-store`.
160
- - Controller and daemon clients use a direct dispatcher with normal TLS verification; proxy environment variables apply only to remote downloads.
161
- - Every managed runtime and OS/browser/package helper child removes GitHub/npm tokens, npm credential-file/auth variables and npm registry credentials. Fixed Windows/macOS system tools use trusted absolute paths; Linux desktop helpers are resolved only through absolute PATH entries.
162
-
163
- `sash web` authenticates with the CLI bearer to mint a 90-second, single-use bootstrap token. It writes an atomic HTML handoff in a new `<root>/temp/web-bootstrap-<expiry>-<random>/` directory: POSIX directories/files use `0700`/`0600`, and Windows directories are created with a protected owner-only DACL before writing credentials. Only the non-secret file URL reaches the browser launcher and only the ordinary dashboard address reaches CLI output. Expired handoff directories are cleaned on the next invocation; live handoffs survive concurrent invocations and browser cold starts.
164
-
165
- The document navigates to the dashboard with a fragment containing the bootstrap token. The frontend removes the fragment before making requests, then exchanges it for a session through the request body. The daemon stores only token hashes in memory, with at most 32 pending bootstraps and 256 sessions; oldest entries are evicted at capacity. Redemption consumes a token before issuing its session, and a restart invalidates both collections. The response's public `daemonToken` binds browser storage to the issuing boot. The persistent CLI bearer never enters the browser handoff.
166
-
167
- ---
168
-
169
- ## 5. Settings, Profiles and Config Transactions
170
-
171
- `sash.json` has explicit `schemaVersion: 1`. Loading validates the JSON root, every field type, nonblank control-character-free secrets, port range, loopback controller address, unknown keys and all three listener ports (`mixedPort`, controller and daemon) for collisions. Version-0 files and removed version metadata migrate to canonical v1. In 0.1.1, otherwise valid legacy files also migrate enabled TUN to false under the settings lock using the atomic writer. Invalid or future-version files are never overwritten.
172
-
173
- After managed-state recovery, daemon and offline initialization first migrate a nonblank legacy `subscriptionUrl` into an active meta-only profile. That URL has priority over any pre-profile `config.yaml`. Only when `profiles/index.json` does not exist may Sash import `config.yaml` once as the active local `Imported config` profile. A present empty index is an explicit opt-out. The candidate must be bounded, regular, valid core-format YAML and contain non-default routing content after managed operational keys are stripped: nonempty proxies/providers, or nonempty rules/groups that differ from the Sash DIRECT-only default. Exact generated defaults are not imported. Invalid candidates fail initialization without changing `config.yaml`; successful import journals the profile YAML and index under `mutation.lock` while leaving `config.yaml` byte-for-byte in place. Later `ProfileService` preparation re-renders and, when Core is installed, validates the canonical profile-derived candidate before runtime use.
174
-
175
- `SettingsService` snapshots committed settings, creates an immutable canonical candidate, then fetches/renders/Core-validates active profile configuration outside the mutation lock. Under the short commit boundary it rechecks settings/profile snapshots and journals settings plus generated config before publication. The daemon exposes only `committedSettings` to GET/status/auth handlers; Core spawn/restart can temporarily use `runtimeSettings` while a candidate transition is in progress. The committed in-memory snapshot changes only after the journaled transition succeeds; failure restores disk/config and the old runtime. Version 0.1.1 rejects TUN enable requests before configuration publication or runtime transitions. Generated configs explicitly disable the TUN listener; original profile DNS/provider options are preserved.
176
-
177
- Prepared profile work is never exposed as a mutable internal transaction object. `ProfileService` issues WeakMap-backed, one-shot opaque capabilities that reject forgery, cross-instance use and repeated consumption. Settings publication deliberately binds a weak active-source snapshot (`activeId`, profile identity/URL and exact raw YAML digest), so unrelated metadata updates do not invalidate an otherwise safe settings change. A strict active reload instead binds the committed settings, complete active `ProfileMeta`, active selection and raw digest. Both paths prepare outside the mutation boundary and consume the capability only while rechecking and publishing; only a conflict raised before the publication callback is entered receives the single bounded automatic retry.
178
-
179
- Profile application follows:
180
-
181
- 1. Parse the untrusted source profile.
182
- 2. Overlay Sash-owned operational keys.
183
- 3. Write an isolated candidate.
184
- 4. Run the installed Core with `-t -d <root> -f <candidate>`.
185
- 5. Enter the short profile commit boundary, re-read the bounded regular index/profile files and verify the target profile identity, URL, active selection, managed-settings snapshot and exact raw-profile SHA-256 captured during preparation.
186
- 6. Snapshot the affected fixed roles (`sash.json`, profile YAML, index and/or `config.yaml`), then atomically persist a `publishing` record in `state/managed-state-transaction.json` before publication.
187
- 7. Ordinary settings/profile publication reloads or restarts only after every file is published, marks the journal `committed`, then clears it. The same journal can include the canonical `sash.json` snapshot. Startup finalizes a committed journal without rolling the published state back.
188
- 8. Core update publication uses a version-3 `core-update` coordination record. After profile/index/config files publish, phase `retained` preserves their exact pre-update snapshots until the Core journal has a durable health/restoration outcome. Ordinary mutations cannot consume this retained state.
189
- 9. On any publication or reload failure, restore every snapshot while continuing after individual restore errors, then reload the prior config when one existed. A rollback reload failure is reported explicitly. Incomplete rollback retains the journal; daemon and offline initialization recover an ordinary `publishing` journal under `mutation.lock` before reading or migrating profile state.
190
-
191
- The daemon parses profile request bodies before its mutation boundary. Remote fetch, YAML parsing, rendering and Core validation also occur before that boundary; only recheck, publication and runtime reload are serialized. Offline commands use the same split boundary after reloading settings, verifying daemon/orphan-Core ownership and migrating the legacy setting. Remote and stored profile YAML are capped at 8 MiB; `profiles/index.json` is capped at 2 MiB, and both must be regular files. Profile requests use an absolute deadline and explicit hop-by-hop redirects: HTTPS cannot downgrade to HTTP, restricted literal addresses cannot cross origins, and public origins cannot redirect to literal private/loopback targets. Scheduled network fetches use bounded concurrency; state commits remain serialized and recheck profile identity/URL and active selection before publication. Scheduler timers are retained when daemon cleanup or listener closure fails, and are cleared only after successful shutdown.
192
-
193
- ---
194
-
195
- ## 6. System-Proxy Ownership
196
-
197
- `settings.systemProxy` stores desired state. `state/system-proxy.json` separately records ownership:
198
-
199
- ```text
200
- prepared: original snapshot + intended target persisted before OS writes
201
- applied: target was written and read back exactly
202
- restoring: restoration began and original/target-compatible partial state remains recoverable
203
- ```
66
+ Unexpected Core exits trigger bounded proxy-restoration retries. Late child events cannot clear a replacement child. Status and proxy application verify that ownership remained the same across asynchronous probes. Unknown identity or uncertain termination preserves the PID record.
204
67
 
205
- All platform capture/apply work runs through asynchronous `execFile` children with the same scrubbed environment, absolute trusted-tool resolution, timeout and output bounds as other managed helpers. Commands remain explicitly sequential: Windows writes PAC/endpoints before `ProxyEnable` and awaits best-effort WinINet refresh; macOS writes data before states and turns unwanted modes off before enabling selected modes; GNOME writes every endpoint before changing `mode`. This keeps the daemon event loop responsive without weakening partial-write ordering.
68
+ ## Core updates
206
69
 
207
- `SystemProxyManager` serializes inspect, apply and release operations in one in-process queue in addition to the cross-process journal lock. Every mutation invalidates a monotonic observation generation when queued and again when complete. Same-generation inspections share one in-flight capture; ordinary polling can reuse a settled short-lived cache, while a fresh read bypasses only that settled cache. Inspection compares strict journal observations before and after OS capture and retries once if another process changes or removes the journal. Unstable observations are never cached. `/sash/health` does not enter this queue, so it remains responsive while a slow OS inspection is pending.
70
+ Core acquisition selects an unmodified upstream release artifact using official GitHub metadata. Mirrors transport bytes only. Initial URLs and redirects must be HTTPS and host-allowlisted; the complete archive must match the official SHA-256 digest. Archives are capped at 128 MiB, extraction at 512 MiB, ZIP paths cannot escape, and the staged executable must report the exact requested version.
208
71
 
209
- Enable flow:
72
+ App captures the applied configuration when Core is running, or saved configuration when it is stopped. Download and candidate config validation leave management responsive. Before publication the queue rechecks saved-state and runtime revisions.
210
73
 
211
- 1. Capture all fields Sash will modify.
212
- 2. Derive the target and persist `prepared` atomically.
213
- 3. Re-read the OS state to detect changes during preparation.
214
- 4. Apply and verify the target.
215
- 5. Mark the journal `applied`.
74
+ `core-update.ts` receives only the staged executable and runtime callbacks. Its fixed journal contains previous/target install records and three phases:
216
75
 
217
- Release/recovery restores only when every managed value still equals either the original or Sash target. Before multi-field restoration Sash persists the `restoring` phase, so a crash can continue from an original/target-compatible partial state. A third-party value is never overwritten. Failed restoration retains the journal and blocks deliberate Core shutdown.
76
+ | Phase | Meaning |
77
+ | --- | --- |
78
+ | `prepared` | Rollback ownership is durable before moving files |
79
+ | `swapped` | New executable and install metadata are published |
80
+ | `verified` | Health verification and restoration of the original running/stopped state succeeded |
218
81
 
219
- Platform scope:
82
+ The old executable remains `.bak` until the final phase. Even a stopped update or first install performs a temporary start and health check immediately, with no system proxy enabled, then stops again. Failure restores the old binary/install record and, when applicable, the original running state. Recovery preserves unrecognized or corrupt files and blocks another unsafe update. A verified journal only needs final cleanup.
220
83
 
221
- - Windows: manual proxy, bypass list and PAC URL are managed; the legacy flat automatic-detection value is observed for status but intentionally neither written nor verified.
222
- - macOS: HTTP, HTTPS, SOCKS and automatic proxy URL/state for every active network service. Existing authenticated proxy settings are not taken over because credentials cannot be restored safely.
223
- - Linux: GNOME `gsettings` mode, automatic URL, HTTP authentication toggle and HTTP/HTTPS/SOCKS endpoints. Other desktop environments are reported unsupported.
84
+ Profile sources, metadata and settings never participate in this transaction. There is no deferred health decision, force-repair quarantine or coordinated second journal.
224
85
 
225
- A missing journal never authorizes Sash to disable an unrelated system proxy.
86
+ ## Windows integration
226
87
 
227
- ---
88
+ System-proxy ownership uses `state/system-proxy.json` with `prepared`, `applied` and `restoring` phases. Before OS writes it stores the original and target Windows registry values. Managed values are proxy enable/server, bypass list and PAC URL; Windows owns `AutoDetect`, which is observed but not written or compared for ownership.
228
89
 
229
- ## 7. Core Download and Update Trust
90
+ Recovery restores an exact owned target, or original/target-compatible partial values from `prepared`/`restoring`. Third-party changes to an already applied target block restoration. Missing journals never authorize disabling an unrelated proxy. The per-user OS lock is independent of `SASH_HOME` so separate instances cannot write the registry concurrently.
230
91
 
231
- Release identity and asset metadata come only from official GitHub endpoints. Asset mirrors are byte transports, not trust roots.
92
+ Proxy operations use asynchronous, bounded helper processes. Inspection is cached briefly, shared while in flight, and reports unknown while a local write is pending. Unstable journal observations are retried once and never cached. Desired, Sash-applied and OS-observed proxy state remain separate.
232
93
 
233
- - Release tags are restricted to safe path tokens.
234
- - Every initial/redirect URL is HTTPS and host-allowlisted.
235
- - Downloads are streamed with a 128 MiB archive cap and backpressure.
236
- - The complete archive must match the SHA-256 digest published in GitHub release metadata.
237
- - Extraction has a 512 MiB output cap; ZIP deflate is streamed rather than fully inflated in memory.
238
- - The staged executable must report the exact requested release token via `-v`.
239
- - The staged executable validates the freshly generated active configuration before publication.
94
+ Autostart uses a current-user registry entry and hidden launcher. See [Automatic Startup](./autostart.md). Other platforms report desktop integration as unsupported; portable CLI/Core primitives remain available.
240
95
 
241
- First install publishes through `state/core-install-transaction.json`: after staging has passed digest/version checks, Sash records an empty pre-install binary/metadata snapshot, publishes the executable, publishes `state/install.json`, marks the transaction `committed`, then clears it. Startup/offline recovery removes binary and metadata for an interrupted `publishing` transaction, while a `committed` marker is only cleared. Transaction JSON uses a strict fixed schema with no stored paths.
96
+ ## HTTP contract
242
97
 
243
- Update flow downloads and validates outside runtime ownership. Once staging completes, it acquires `state/runtime.lock` before requesting the atomic maintenance shutdown snapshot and keeps that ownership through offline publication and runtime restoration. Under `mutation.lock`, Sash reloads committed settings, performs legacy/journaled proxy cleanup and verified stale-Core cleanup once, recovers earlier update state, prepares the active profile outside the short commit boundary, then rechecks both the profile capability and controller vacancy before publication.
98
+ `/sash/*` is implemented by the daemon, `/core/api/*` is the authenticated Core gateway, and `/ui/*` serves the bundled dashboard. The route table is the canonical API inventory.
244
99
 
245
- The final mutation first writes a retained managed-state journal for profile/index/config snapshots, then starts `state/core-update-transaction.json`. Normal updates use the version-1 `prepared`, `swapped` and `health-verified` phases. A previously running Core is verified immediately; a stopped Core remains in `swapped` with the old install record and `.bak` until its next managed start proves the target controller version and health. Successful external restoration marks managed state committed before removing the Core rollback slot, so a crash cannot lose both rollback directions. Failure restores managed state before binary/install metadata and still attempts both sides if either rollback reports an error.
100
+ | Endpoint | Method | Access / result |
101
+ | --- | --- | --- |
102
+ | `/sash/daemon/health` | GET | Public per-boot identity, PID and start time |
103
+ | `/sash/daemon/status` | GET | Public management/runtime snapshot without secrets |
104
+ | `/sash/daemon/shutdown` | POST | Control; complete cleanup, then `204` and listener close |
105
+ | `/sash/web/bootstrap` | POST | Control; mint one-time browser handoff |
106
+ | `/sash/web/session` | POST | Redeem the handoff token supplied in the body |
107
+ | `/sash/core/start` | POST | Control; idempotent start, applying saved state if stopped |
108
+ | `/sash/core/restart` | POST | Control; Apply saved state and restart Core |
109
+ | `/sash/core/stop` | POST | Control; stop Core, keep management; `204` |
110
+ | `/sash/core/update` | POST | Control; optional `{version}`, returns `{version}` |
111
+ | `/sash/core/mode` | PUT | Control; `{mode}` changes running Core mode |
112
+ | `/sash/proxy` | GET | Public desired/applied/observed state |
113
+ | `/sash/settings` | GET / PATCH | Public settings / control save `{mixedPort?, allowLan?, systemProxy?}` |
114
+ | `/sash/autostart` | GET / PUT | Control; inspect or set `{enabled}` |
115
+ | `/sash/profiles` | GET / POST | Public metadata list / control remote import |
116
+ | `/sash/profiles/import` | POST | Control; local YAML import |
117
+ | `/sash/profiles/order` | PUT | Control; save complete `{ids}` order |
118
+ | `/sash/profiles/active` | PUT | Control; save `{id}` or `null`, without applying |
119
+ | `/sash/profiles/update-all` | POST | Control; saved updates, with per-profile failures |
120
+ | `/sash/profiles/:id/content` | GET / PUT | Control; read YAML/revision or save `{content, revision}` |
121
+ | `/sash/profiles/:id/update` | POST | Control; download and save new content |
122
+ | `/sash/profiles/:id` | PATCH / DELETE | Control; rename or remove |
246
123
 
247
- Forced repair uses the version-2 `repair-prepared` and `repair-restoring` phases in addition to the normal swap/health phases. Malformed executable and install-metadata entries are moved to fixed `.repair.bak` quarantine paths only after the journal is durable. Partial quarantine and partial restoration are resumable. Missing, extra or unowned quarantine entries fail closed. Daemon startup and offline commands use the same proxy, stale-Core and coordinated-journal recovery order before ordinary consistency checks.
124
+ Status includes `daemon.bootId`, `revisions.profiles` (saved-state revision), `revisions.runtime`, and `configuration: {pending, appliedProfile, appliedSettings}`. Saved selection and actual running configuration are distinct. Proxy observation flags are required; no absent flag is guessed from an old protocol. `?fresh=1` bypasses settled proxy inspection cache.
248
125
 
249
- Official digest metadata is mandatory. If the metadata API is unavailable, Sash refuses an unverifiable mirror download instead of falling back to executing unverified bytes.
126
+ Success bodies are resources; empty mutations return `204`. Errors use `{error: {code, message}}`. Unknown required fields or malformed successful payloads are rejected by the shared client. Raw settings editing and config reload routes do not exist.
250
127
 
251
- ---
128
+ The Core gateway permits queries, node selection and connection deletion. Managed configuration changes must use Sash controls. Mode uses `/sash/core/mode`; traffic and log WebSockets use `/core/api/traffic` and `/core/api/logs`.
252
129
 
253
- ## 8. Persistence Safety
130
+ ## Security and persistence
254
131
 
255
- Atomic state writes use a same-directory temporary file, file `fsync`, rename and POSIX parent-directory `fsync`. Sensitive files use mode `0o600` on POSIX. Important files are:
132
+ The listener binds to `127.0.0.1`, validates loopback Host/Origin, and parses one canonical origin-form target for authentication and forwarding. Core requests use a direct dispatcher. Browser requests carry private per-boot sessions; the persistent CLI bearer never reaches the browser or Core gateway. Public health identity is not a credential. API responses are non-cacheable.
256
133
 
257
- - `sash.json`: versioned desired settings and local secrets.
258
- - `profiles/index.json`, `profiles/<id>.yaml`: profile metadata/content.
259
- - `config.yaml`: runtime config rendered from the active profile or the DIRECT-only default; a qualifying pre-profile file is preserved during its one-time local-profile import.
260
- - `state/sash.pid`, `state/sashd.pid`: discovery records.
261
- - `state/system-proxy.json`: durable proxy ownership transaction.
262
- - `state/managed-state-transaction.json`: recoverable settings/profile/index/config snapshots, including retained Core-update coordination state.
263
- - `state/install.json`: committed Core version metadata using the shared fixed-shape install-record codec.
264
- - `state/core-install-transaction.json`: strict first-install publication journal.
265
- - `state/core-update-transaction.json`: strict version-1 update journal or version-2 force-repair quarantine journal, including deferred managed-start rollback ownership.
266
- - `state/*.lock`: local-filesystem ownership records.
134
+ `sash web` creates a 90-second single-use handoff in an owner-private local file. The browser removes its fragment before redemption, stores the session in tab storage and verifies the daemon boot. Only hashes are retained in the daemon, with bounded grant/session counts. Restarting Core preserves sessions; restarting the daemon invalidates them.
267
135
 
268
- `SASH_HOME` should reside on a local filesystem that supports atomic rename, hard links and normal per-user permissions.
136
+ All helper children receive scrubbed environments. Sensitive state/logs use POSIX `0600`; browser handoffs also use owner-only Windows ACLs. Atomic publication uses a same-directory temporary file, file fsync, rename and POSIX directory fsync. Windows sharing violations are retried without deleting unverified files. Use a local filesystem supporting atomic rename and hard links.