@ours.network/fleet 0.10.3 → 0.11.0
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/README.md +66 -0
- package/dist/application/capabilities.d.ts +6 -0
- package/dist/application/capabilities.js +37 -0
- package/dist/application/errors.d.ts +31 -0
- package/dist/application/errors.js +51 -0
- package/dist/application/fleet-query-service.d.ts +42 -0
- package/dist/application/fleet-query-service.js +188 -0
- package/dist/application/log-service.d.ts +28 -0
- package/dist/application/log-service.js +146 -0
- package/dist/application/role-command-service.d.ts +37 -0
- package/dist/application/role-command-service.js +82 -0
- package/dist/application/role-creation-service.d.ts +142 -0
- package/dist/application/role-creation-service.js +374 -0
- package/dist/application/role-repository.d.ts +20 -0
- package/dist/application/role-repository.js +168 -0
- package/dist/application/session-control.d.ts +55 -0
- package/dist/application/session-control.js +115 -0
- package/dist/application/types.d.ts +156 -0
- package/dist/application/types.js +1 -0
- package/dist/cli.js +341 -3
- package/dist/config.d.ts +9 -2
- package/dist/config.js +21 -5
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +56 -0
- package/dist/duration.d.ts +5 -0
- package/dist/duration.js +20 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/ops.d.ts +16 -0
- package/dist/ops.js +112 -3
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/resolved-plan.js +7 -0
- package/dist/runner.js +10 -2
- package/dist/session/control.d.ts +4 -2
- package/dist/session/control.js +45 -13
- package/dist/spawn.d.ts +20 -2
- package/dist/spawn.js +94 -24
- package/dist/supervisor/launchd.js +17 -0
- package/dist/supervisor/none.js +17 -0
- package/dist/supervisor/systemd.js +4 -0
- package/dist/supervisor/types.d.ts +6 -0
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +8 -0
- package/dist/watchdog/alerts.d.ts +34 -0
- package/dist/watchdog/alerts.js +78 -0
- package/dist/watchdog/briefing.d.ts +65 -0
- package/dist/watchdog/briefing.js +181 -0
- package/dist/watchdog/config.d.ts +49 -0
- package/dist/watchdog/config.js +114 -0
- package/dist/watchdog/query.d.ts +78 -0
- package/dist/watchdog/query.js +124 -0
- package/dist/watchdog/report.d.ts +53 -0
- package/dist/watchdog/report.js +126 -0
- package/dist/watchdog/run.d.ts +61 -0
- package/dist/watchdog/run.js +318 -0
- package/dist/watchdog/scheduler.d.ts +105 -0
- package/dist/watchdog/scheduler.js +244 -0
- package/dist/watchdog/service.d.ts +46 -0
- package/dist/watchdog/service.js +179 -0
- package/dist/watchdog/store.d.ts +85 -0
- package/dist/watchdog/store.js +226 -0
- package/dist/web/audit.d.ts +22 -0
- package/dist/web/audit.js +54 -0
- package/dist/web/auth.d.ts +61 -0
- package/dist/web/auth.js +186 -0
- package/dist/web/control.d.ts +14 -0
- package/dist/web/control.js +110 -0
- package/dist/web/device-store.d.ts +27 -0
- package/dist/web/device-store.js +155 -0
- package/dist/web/events.d.ts +15 -0
- package/dist/web/events.js +34 -0
- package/dist/web/lock.d.ts +5 -0
- package/dist/web/lock.js +69 -0
- package/dist/web/runtime.d.ts +12 -0
- package/dist/web/runtime.js +214 -0
- package/dist/web/server.d.ts +37 -0
- package/dist/web/server.js +279 -0
- package/dist/web/service.d.ts +42 -0
- package/dist/web/service.js +180 -0
- package/dist/web/terminal/bridge.d.ts +27 -0
- package/dist/web/terminal/bridge.js +317 -0
- package/dist/web-app/assets/TerminalView-BvcIkuIF.js +9 -0
- package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
- package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
- package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
- package/dist/web-app/icons/ours-fleet.svg +4 -0
- package/dist/web-app/index.html +17 -0
- package/dist/web-app/manifest.webmanifest +15 -0
- package/dist/web-app/offline.html +18 -0
- package/dist/web-app/sw.js +51 -0
- package/package.json +26 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { watchdogsRoot } from '../paths.js';
|
|
4
|
+
// store.ts imports config.ts (ROLE_NAME_RE) but not the reverse — config.ts's
|
|
5
|
+
// import graph (config-yaml, isolation/policy, harness/registry, watchdog/config)
|
|
6
|
+
// never reaches back into watchdog/store.ts, so this does not create a cycle.
|
|
7
|
+
import { ROLE_NAME_RE } from '../config.js';
|
|
8
|
+
const RUN_ID_RE = /^\d{8}T\d{6}Z$/;
|
|
9
|
+
const REPORT_FILE_RE = /^\d{8}T\d{6}Z\.json$/;
|
|
10
|
+
/**
|
|
11
|
+
* A lock directory briefly exists before acquireRunLock can rename its
|
|
12
|
+
* prewritten owner.json into it. Missing/corrupt owner metadata is therefore
|
|
13
|
+
* reclaimable only after this grace period, never while that acquisition
|
|
14
|
+
* window may still be in progress.
|
|
15
|
+
*/
|
|
16
|
+
export const RUN_LOCK_OWNER_GRACE_MS = 10_000;
|
|
17
|
+
/**
|
|
18
|
+
* mkdirSync's `mode` is masked by the process umask and ignored outright when
|
|
19
|
+
* the directory already exists, so an explicit chmod after mkdir is the only
|
|
20
|
+
* way to guarantee 0700 regardless of umask or prior state.
|
|
21
|
+
*/
|
|
22
|
+
function ensureDir(dir) {
|
|
23
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
chmodSync(dir, 0o700);
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Choke point every other helper in this module goes through to reach a
|
|
29
|
+
* watchdog's on-disk state. Validates `name` BEFORE any join/mkdir (defense
|
|
30
|
+
* in depth, finding #1): a caller that forgets its own ROLE_NAME_RE guard
|
|
31
|
+
* (as the CLI's `watchdogKnown` once did) must not be able to turn an
|
|
32
|
+
* unvalidated `name` into `join(watchdogsRoot(), '../../victim')` and mkdir
|
|
33
|
+
* an arbitrary path on disk.
|
|
34
|
+
*/
|
|
35
|
+
export function watchdogDir(name) {
|
|
36
|
+
if (!ROLE_NAME_RE.test(name))
|
|
37
|
+
throw new Error(`invalid watchdog name '${name}'`);
|
|
38
|
+
return ensureDir(join(watchdogsRoot(), name));
|
|
39
|
+
}
|
|
40
|
+
export function reportsDir(name) {
|
|
41
|
+
return ensureDir(join(watchdogDir(name), 'reports'));
|
|
42
|
+
}
|
|
43
|
+
function runLockPath(name) {
|
|
44
|
+
return join(watchdogDir(name), '.run-lock');
|
|
45
|
+
}
|
|
46
|
+
function runLockOwnerPath(name) {
|
|
47
|
+
return join(runLockPath(name), 'owner.json');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* mkdir-as-mutex: atomic across processes, unlike a lock file (open+O_EXCL
|
|
51
|
+
* would work too, but a directory needs no cleanup of file contents and
|
|
52
|
+
* can't be partially written). EEXIST means another run holds it.
|
|
53
|
+
*
|
|
54
|
+
* Stamps `owner.json` with our pid (finding #2): ownership metadata is what
|
|
55
|
+
* lets a later `reclaimStaleRunLock` tell a lock abandoned by a dead process
|
|
56
|
+
* apart from one genuinely held by a live run. A naive "mkdir, then
|
|
57
|
+
* writeFileSync(owner.json)" leaves an unnecessarily wide window —
|
|
58
|
+
* between the mkdir succeeding and the write landing — where the lock dir
|
|
59
|
+
* exists but owner.json doesn't yet. A `reclaimStaleRunLock` call from
|
|
60
|
+
* another process landing in exactly that window would see a lock with no
|
|
61
|
+
* (or corrupt) owner metadata and treat a genuinely live lock as a legacy
|
|
62
|
+
* one, reclaiming it out from under us. Do all slow work (building the JSON,
|
|
63
|
+
* writing it, chmod) to a per-pid temp file BEFORE mkdir, then publish it with
|
|
64
|
+
* one rename. There is still an unavoidable interval between those two
|
|
65
|
+
* syscalls; reclaimStaleRunLock protects it by treating a fresh ownerless lock
|
|
66
|
+
* as held for RUN_LOCK_OWNER_GRACE_MS. The temp file is unique per-pid (this
|
|
67
|
+
* function is synchronous, so there's no same-process concurrent-call hazard
|
|
68
|
+
* either) and is cleaned up if mkdir loses the race.
|
|
69
|
+
*/
|
|
70
|
+
export function acquireRunLock(name) {
|
|
71
|
+
const dir = watchdogDir(name);
|
|
72
|
+
const owner = { pid: process.pid, at: new Date().toISOString() };
|
|
73
|
+
const tempOwnerPath = join(dir, `.owner.${process.pid}.tmp`);
|
|
74
|
+
writeFileSync(tempOwnerPath, JSON.stringify(owner), { mode: 0o600 });
|
|
75
|
+
chmodSync(tempOwnerPath, 0o600);
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(runLockPath(name));
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(tempOwnerPath);
|
|
82
|
+
}
|
|
83
|
+
catch { /* best effort cleanup */ }
|
|
84
|
+
if (e.code === 'EEXIST')
|
|
85
|
+
return false;
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
renameSync(tempOwnerPath, runLockOwnerPath(name));
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Release-tolerant of absence: a lock already gone (or never acquired) is not
|
|
93
|
+
* an error. Recursive because the lock dir now holds `owner.json` alongside
|
|
94
|
+
* the mkdir mutex itself (finding #2) — a plain rmdir would fail ENOTEMPTY.
|
|
95
|
+
*/
|
|
96
|
+
export function releaseRunLock(name) {
|
|
97
|
+
try {
|
|
98
|
+
rmSync(runLockPath(name), { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
if (e.code === 'ENOENT')
|
|
102
|
+
return;
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Reads a run lock's owner metadata; missing or corrupt yields undefined. */
|
|
107
|
+
export function readRunLockOwner(name) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = JSON.parse(readFileSync(runLockOwnerPath(name), 'utf8'));
|
|
110
|
+
if (!Number.isInteger(raw.pid) || raw.pid <= 0 || typeof raw.at !== 'string')
|
|
111
|
+
return undefined;
|
|
112
|
+
return { pid: raw.pid, at: raw.at };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** True iff `pid` names a live process. EPERM means the process exists but we can't signal it — still alive. */
|
|
119
|
+
function pidAlive(pid) {
|
|
120
|
+
try {
|
|
121
|
+
process.kill(pid, 0);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
return e.code === 'EPERM';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Owner-mandated (finding #2): only a DEMONSTRABLY stale run lock may be
|
|
130
|
+
* reclaimed — a lock is stale iff it isn't held at all, its owner metadata
|
|
131
|
+
* names a dead pid, or its owner metadata is missing/corrupt AND the lock dir
|
|
132
|
+
* is older than RUN_LOCK_OWNER_GRACE_MS. The age gate closes the interprocess
|
|
133
|
+
* interval between acquireRunLock's mkdir and owner.json rename: a concurrent
|
|
134
|
+
* scheduler sees a fresh ownerless lock as held, not stale. A live owner (a
|
|
135
|
+
* foreground `watchdog-run`, another scheduler instance, an in-progress run)
|
|
136
|
+
* is left strictly alone: reclaiming it would let two runs share the same temp
|
|
137
|
+
* dir.
|
|
138
|
+
* Returns true when the lock is (now) not held — whether because it was
|
|
139
|
+
* already absent or because a stale lock was just removed; false when a live
|
|
140
|
+
* lock was found and deliberately left in place.
|
|
141
|
+
*/
|
|
142
|
+
export function reclaimStaleRunLock(name) {
|
|
143
|
+
const path = runLockPath(name);
|
|
144
|
+
if (!existsSync(path))
|
|
145
|
+
return true;
|
|
146
|
+
const owner = readRunLockOwner(name);
|
|
147
|
+
if (owner !== undefined && pidAlive(owner.pid))
|
|
148
|
+
return false;
|
|
149
|
+
if (owner === undefined) {
|
|
150
|
+
try {
|
|
151
|
+
if (Date.now() - statSync(path).mtimeMs < RUN_LOCK_OWNER_GRACE_MS)
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
// The holder may have released between existsSync and statSync.
|
|
156
|
+
if (e.code === 'ENOENT')
|
|
157
|
+
return true;
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
releaseRunLock(name);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
/** Lexical-chronological UTC run id, e.g. '20260731T115000Z'. */
|
|
165
|
+
export function formatRunId(d) {
|
|
166
|
+
return d.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
|
|
167
|
+
}
|
|
168
|
+
const CORRUPT_SUMMARY = { checked: 0, healthy: 0, idle: 0, anomalies: 0 };
|
|
169
|
+
function reportPath(name, runId) {
|
|
170
|
+
return join(reportsDir(name), `${runId}.json`);
|
|
171
|
+
}
|
|
172
|
+
/** Newest-first run ids currently on disk, derived from filenames alone. */
|
|
173
|
+
function runIdsDesc(name) {
|
|
174
|
+
return readdirSync(reportsDir(name))
|
|
175
|
+
.filter(f => REPORT_FILE_RE.test(f))
|
|
176
|
+
.sort()
|
|
177
|
+
.reverse()
|
|
178
|
+
.map(f => f.slice(0, -'.json'.length));
|
|
179
|
+
}
|
|
180
|
+
export function writeReport(name, report) {
|
|
181
|
+
const path = reportPath(name, report.run_id);
|
|
182
|
+
writeFileSync(path, JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
|
|
183
|
+
chmodSync(path, 0o600);
|
|
184
|
+
return path;
|
|
185
|
+
}
|
|
186
|
+
/** Newest-first run listing; a corrupt report file yields a synthetic 'error' entry rather than throwing. */
|
|
187
|
+
export function listRuns(name) {
|
|
188
|
+
return runIdsDesc(name).map((runId) => {
|
|
189
|
+
try {
|
|
190
|
+
const report = JSON.parse(readFileSync(reportPath(name, runId), 'utf8'));
|
|
191
|
+
return {
|
|
192
|
+
runId, status: report.status, startedAt: report.started_at, finishedAt: report.finished_at,
|
|
193
|
+
summary: report.summary, error: report.error,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return { runId, status: 'error', startedAt: '', finishedAt: '', summary: CORRUPT_SUMMARY, error: 'unreadable report file' };
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/** Reads one run's full report. Rejects non-conforming runIds (path-traversal guard) and corrupt files by returning undefined. */
|
|
202
|
+
export function readReport(name, runId) {
|
|
203
|
+
if (!RUN_ID_RE.test(runId))
|
|
204
|
+
return undefined;
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(readFileSync(reportPath(name, runId), 'utf8'));
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export function latestReport(name) {
|
|
213
|
+
const [newest] = runIdsDesc(name);
|
|
214
|
+
return newest === undefined ? undefined : readReport(name, newest);
|
|
215
|
+
}
|
|
216
|
+
/** Deletes all but the `keep` newest reports (oldest first); returns the number pruned. */
|
|
217
|
+
export function pruneReports(name, keep) {
|
|
218
|
+
const stale = runIdsDesc(name).slice(keep);
|
|
219
|
+
for (const runId of stale) {
|
|
220
|
+
try {
|
|
221
|
+
unlinkSync(reportPath(name, runId));
|
|
222
|
+
}
|
|
223
|
+
catch { /* best effort */ }
|
|
224
|
+
}
|
|
225
|
+
return stale.length;
|
|
226
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface AuditEvent {
|
|
2
|
+
at?: string;
|
|
3
|
+
requestId?: string;
|
|
4
|
+
browser?: string;
|
|
5
|
+
roleId?: string;
|
|
6
|
+
action: string;
|
|
7
|
+
result: string;
|
|
8
|
+
errorCode?: string;
|
|
9
|
+
latencyMs?: number;
|
|
10
|
+
bytes?: number;
|
|
11
|
+
digest?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class AuditSink {
|
|
14
|
+
readonly dir: string;
|
|
15
|
+
private readonly file;
|
|
16
|
+
private readonly memory;
|
|
17
|
+
degraded?: string;
|
|
18
|
+
constructor(dir?: string);
|
|
19
|
+
record(event: AuditEvent): Promise<void>;
|
|
20
|
+
list(limit?: number): AuditEvent[];
|
|
21
|
+
private prune;
|
|
22
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { appendFile, chmod, readdir, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { stateRoot } from '../paths.js';
|
|
5
|
+
import { safeLine } from '../application/errors.js';
|
|
6
|
+
export class AuditSink {
|
|
7
|
+
dir;
|
|
8
|
+
file;
|
|
9
|
+
memory = [];
|
|
10
|
+
degraded;
|
|
11
|
+
constructor(dir = join(stateRoot(), 'web', 'audit')) {
|
|
12
|
+
this.dir = dir;
|
|
13
|
+
this.file = join(dir, 'audit.jsonl');
|
|
14
|
+
try {
|
|
15
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
this.degraded = error.message;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async record(event) {
|
|
22
|
+
const safe = {
|
|
23
|
+
at: event.at ?? new Date().toISOString(),
|
|
24
|
+
requestId: event.requestId, browser: event.browser?.slice(0, 12),
|
|
25
|
+
roleId: event.roleId, action: safeLine(event.action, 128),
|
|
26
|
+
result: safeLine(event.result, 128), errorCode: event.errorCode,
|
|
27
|
+
latencyMs: event.latencyMs, bytes: event.bytes, digest: event.digest,
|
|
28
|
+
};
|
|
29
|
+
this.memory.push(safe);
|
|
30
|
+
if (this.memory.length > 1_000)
|
|
31
|
+
this.memory.shift();
|
|
32
|
+
if (this.degraded)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
try {
|
|
36
|
+
if (statSync(this.file).size > 2 * 1024 * 1024)
|
|
37
|
+
await rename(this.file, join(this.dir, `audit-${Date.now()}.jsonl`));
|
|
38
|
+
}
|
|
39
|
+
catch { /* first write */ }
|
|
40
|
+
await appendFile(this.file, JSON.stringify(safe) + '\n', { mode: 0o600 });
|
|
41
|
+
await chmod(this.file, 0o600);
|
|
42
|
+
await this.prune();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
this.degraded = error.message;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
list(limit = 200) { return this.memory.slice(-Math.min(limit, 1_000)).reverse(); }
|
|
49
|
+
async prune() {
|
|
50
|
+
const files = (await readdir(this.dir)).filter(file => /^audit-\d+\.jsonl$/.test(file)).sort();
|
|
51
|
+
for (const file of files.slice(0, -10))
|
|
52
|
+
await unlink(join(this.dir, file)).catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
import type { WebSocket } from 'ws';
|
|
3
|
+
import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
|
|
4
|
+
export interface BrowserSession {
|
|
5
|
+
id: string;
|
|
6
|
+
csrf: string;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
lastSeenAt: number;
|
|
9
|
+
absoluteExpiresAt: number;
|
|
10
|
+
}
|
|
11
|
+
export interface AuthResult {
|
|
12
|
+
session: BrowserSession;
|
|
13
|
+
device: TrustedDeviceIssue;
|
|
14
|
+
}
|
|
15
|
+
interface Ticket {
|
|
16
|
+
value: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
purpose: 'events' | 'terminal';
|
|
19
|
+
roleId?: string;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
}
|
|
22
|
+
export declare class WebAuth {
|
|
23
|
+
private _origin;
|
|
24
|
+
private _host;
|
|
25
|
+
private readonly now;
|
|
26
|
+
private readonly devices;
|
|
27
|
+
private _bootstrapSecret;
|
|
28
|
+
private bootstrapExpiresAt;
|
|
29
|
+
private bootstrapUsed;
|
|
30
|
+
private readonly sessions;
|
|
31
|
+
private readonly sessionDevices;
|
|
32
|
+
private readonly tickets;
|
|
33
|
+
private readonly rates;
|
|
34
|
+
private readonly sockets;
|
|
35
|
+
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore);
|
|
36
|
+
get bootstrapSecret(): string;
|
|
37
|
+
get origin(): string;
|
|
38
|
+
get host(): string;
|
|
39
|
+
setBoundary(origin: string, host: string): void;
|
|
40
|
+
/** Mint a replacement for an operator-triggered reauthentication ceremony. */
|
|
41
|
+
mintBootstrap(): string;
|
|
42
|
+
validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
|
|
43
|
+
exchange(request: FastifyRequest): AuthResult;
|
|
44
|
+
resume(request: FastifyRequest): AuthResult;
|
|
45
|
+
authenticate(request: FastifyRequest, mutation?: boolean): BrowserSession;
|
|
46
|
+
logout(request: FastifyRequest): void;
|
|
47
|
+
mintTicket(request: FastifyRequest, purpose: Ticket['purpose'], roleId?: string): {
|
|
48
|
+
ticket: string;
|
|
49
|
+
expiresAt: string;
|
|
50
|
+
};
|
|
51
|
+
consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
|
|
52
|
+
bindSocket(sessionId: string, socket: WebSocket): void;
|
|
53
|
+
clearSessions(): void;
|
|
54
|
+
revokeAllTrustedDevices(): number;
|
|
55
|
+
shutdown(): void;
|
|
56
|
+
private createSession;
|
|
57
|
+
private removeSession;
|
|
58
|
+
private consumeRate;
|
|
59
|
+
}
|
|
60
|
+
export declare function parseCookies(cookie: string): Record<string, string>;
|
|
61
|
+
export {};
|
package/dist/web/auth.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { FleetError } from '../application/errors.js';
|
|
3
|
+
import { TrustedDeviceStore } from './device-store.js';
|
|
4
|
+
const token = (bytes = 32) => randomBytes(bytes).toString('base64url');
|
|
5
|
+
const same = (a, b) => {
|
|
6
|
+
const left = Buffer.from(a);
|
|
7
|
+
const right = Buffer.from(b);
|
|
8
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
9
|
+
};
|
|
10
|
+
export class WebAuth {
|
|
11
|
+
_origin;
|
|
12
|
+
_host;
|
|
13
|
+
now;
|
|
14
|
+
devices;
|
|
15
|
+
_bootstrapSecret = token(32);
|
|
16
|
+
bootstrapExpiresAt = Date.now() + 5 * 60_000;
|
|
17
|
+
bootstrapUsed = false;
|
|
18
|
+
sessions = new Map();
|
|
19
|
+
sessionDevices = new Map();
|
|
20
|
+
tickets = new Map();
|
|
21
|
+
rates = new Map();
|
|
22
|
+
sockets = new Map();
|
|
23
|
+
constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore()) {
|
|
24
|
+
this._origin = _origin;
|
|
25
|
+
this._host = _host;
|
|
26
|
+
this.now = now;
|
|
27
|
+
this.devices = devices;
|
|
28
|
+
}
|
|
29
|
+
get bootstrapSecret() { return this._bootstrapSecret; }
|
|
30
|
+
get origin() { return this._origin; }
|
|
31
|
+
get host() { return this._host; }
|
|
32
|
+
setBoundary(origin, host) {
|
|
33
|
+
this._origin = origin;
|
|
34
|
+
this._host = host;
|
|
35
|
+
}
|
|
36
|
+
/** Mint a replacement for an operator-triggered reauthentication ceremony. */
|
|
37
|
+
mintBootstrap() {
|
|
38
|
+
this._bootstrapSecret = token(32);
|
|
39
|
+
this.bootstrapExpiresAt = this.now() + 5 * 60_000;
|
|
40
|
+
this.bootstrapUsed = false;
|
|
41
|
+
return this._bootstrapSecret;
|
|
42
|
+
}
|
|
43
|
+
validateBoundary(request, requireOrigin) {
|
|
44
|
+
const host = request.headers.host;
|
|
45
|
+
if (host !== this.host)
|
|
46
|
+
throw new FleetError('forbidden', 'invalid Host header');
|
|
47
|
+
if (requireOrigin && request.headers.origin !== this.origin)
|
|
48
|
+
throw new FleetError('forbidden', 'invalid Origin header');
|
|
49
|
+
const fetchSite = request.headers['sec-fetch-site'];
|
|
50
|
+
if (fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
|
|
51
|
+
throw new FleetError('forbidden', 'cross-site request rejected');
|
|
52
|
+
}
|
|
53
|
+
exchange(request) {
|
|
54
|
+
this.validateBoundary(request, true);
|
|
55
|
+
this.consumeRate('bootstrap', 10, 60_000);
|
|
56
|
+
const authorization = request.headers.authorization ?? '';
|
|
57
|
+
const supplied = authorization.startsWith('Bootstrap ') ? authorization.slice(10) : '';
|
|
58
|
+
if (this.bootstrapUsed || this.now() > this.bootstrapExpiresAt || !same(this._bootstrapSecret, supplied))
|
|
59
|
+
throw new FleetError('unauthorized', 'bootstrap credential is invalid or expired');
|
|
60
|
+
this.bootstrapUsed = true;
|
|
61
|
+
const device = this.devices.issue();
|
|
62
|
+
return { session: this.createSession(device.id), device };
|
|
63
|
+
}
|
|
64
|
+
resume(request) {
|
|
65
|
+
this.validateBoundary(request, true);
|
|
66
|
+
this.consumeRate('device-resume', 30, 60_000);
|
|
67
|
+
const current = parseCookies(request.headers.cookie ?? '').ofs_device;
|
|
68
|
+
const device = current ? this.devices.rotate(current) : undefined;
|
|
69
|
+
if (!device)
|
|
70
|
+
throw new FleetError('unauthorized', 'trusted device is missing, expired, or revoked');
|
|
71
|
+
return { session: this.createSession(device.id), device };
|
|
72
|
+
}
|
|
73
|
+
authenticate(request, mutation = false) {
|
|
74
|
+
this.validateBoundary(request, mutation);
|
|
75
|
+
const id = parseCookies(request.headers.cookie ?? '').ofs_session;
|
|
76
|
+
const session = id ? this.sessions.get(id) : undefined;
|
|
77
|
+
const now = this.now();
|
|
78
|
+
if (!session || now > session.absoluteExpiresAt || now - session.lastSeenAt > 30 * 60_000) {
|
|
79
|
+
if (id)
|
|
80
|
+
this.removeSession(id);
|
|
81
|
+
throw new FleetError('unauthorized', 'browser session is missing or expired');
|
|
82
|
+
}
|
|
83
|
+
if (mutation) {
|
|
84
|
+
const supplied = String(request.headers['x-csrf-token'] ?? '');
|
|
85
|
+
if (!same(session.csrf, supplied))
|
|
86
|
+
throw new FleetError('forbidden', 'invalid CSRF token');
|
|
87
|
+
this.consumeRate(`mutation:${session.id}`, 120, 60_000);
|
|
88
|
+
}
|
|
89
|
+
session.lastSeenAt = now;
|
|
90
|
+
return session;
|
|
91
|
+
}
|
|
92
|
+
logout(request) {
|
|
93
|
+
const session = this.authenticate(request, true);
|
|
94
|
+
const deviceId = this.sessionDevices.get(session.id);
|
|
95
|
+
if (deviceId)
|
|
96
|
+
this.devices.revokeId(deviceId);
|
|
97
|
+
this.removeSession(session.id);
|
|
98
|
+
}
|
|
99
|
+
mintTicket(request, purpose, roleId) {
|
|
100
|
+
const session = this.authenticate(request, true);
|
|
101
|
+
const value = token();
|
|
102
|
+
const ticket = {
|
|
103
|
+
value, sessionId: session.id, purpose, roleId,
|
|
104
|
+
expiresAt: this.now() + 30_000,
|
|
105
|
+
};
|
|
106
|
+
this.tickets.set(value, ticket);
|
|
107
|
+
return { ticket: value, expiresAt: new Date(ticket.expiresAt).toISOString() };
|
|
108
|
+
}
|
|
109
|
+
consumeTicket(request, value, purpose, roleId) {
|
|
110
|
+
this.validateBoundary(request, true);
|
|
111
|
+
const ticket = this.tickets.get(value);
|
|
112
|
+
this.tickets.delete(value);
|
|
113
|
+
if (!ticket || ticket.expiresAt < this.now() || ticket.purpose !== purpose
|
|
114
|
+
|| ticket.roleId !== roleId)
|
|
115
|
+
throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
|
|
116
|
+
const session = this.sessions.get(ticket.sessionId);
|
|
117
|
+
if (!session)
|
|
118
|
+
throw new FleetError('unauthorized', 'browser session expired');
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
bindSocket(sessionId, socket) {
|
|
122
|
+
let sockets = this.sockets.get(sessionId);
|
|
123
|
+
if (!sockets) {
|
|
124
|
+
sockets = new Set();
|
|
125
|
+
this.sockets.set(sessionId, sockets);
|
|
126
|
+
}
|
|
127
|
+
sockets.add(socket);
|
|
128
|
+
socket.once('close', () => sockets?.delete(socket));
|
|
129
|
+
}
|
|
130
|
+
clearSessions() {
|
|
131
|
+
for (const sockets of this.sockets.values())
|
|
132
|
+
for (const socket of sockets)
|
|
133
|
+
socket.close(4401, 'authentication revoked');
|
|
134
|
+
this.sockets.clear();
|
|
135
|
+
this.sessions.clear();
|
|
136
|
+
this.sessionDevices.clear();
|
|
137
|
+
this.tickets.clear();
|
|
138
|
+
}
|
|
139
|
+
revokeAllTrustedDevices() {
|
|
140
|
+
const count = this.devices.revokeAll();
|
|
141
|
+
this.clearSessions();
|
|
142
|
+
return count;
|
|
143
|
+
}
|
|
144
|
+
shutdown() { this.clearSessions(); }
|
|
145
|
+
createSession(deviceId) {
|
|
146
|
+
const now = this.now();
|
|
147
|
+
const session = {
|
|
148
|
+
id: token(), csrf: token(), createdAt: now, lastSeenAt: now,
|
|
149
|
+
absoluteExpiresAt: now + 8 * 60 * 60_000,
|
|
150
|
+
};
|
|
151
|
+
this.sessions.set(session.id, session);
|
|
152
|
+
this.sessionDevices.set(session.id, deviceId);
|
|
153
|
+
return session;
|
|
154
|
+
}
|
|
155
|
+
removeSession(id) {
|
|
156
|
+
this.sessions.delete(id);
|
|
157
|
+
this.sessionDevices.delete(id);
|
|
158
|
+
for (const [ticket, value] of this.tickets)
|
|
159
|
+
if (value.sessionId === id)
|
|
160
|
+
this.tickets.delete(ticket);
|
|
161
|
+
for (const socket of this.sockets.get(id) ?? [])
|
|
162
|
+
socket.close(4401, 'authentication revoked');
|
|
163
|
+
this.sockets.delete(id);
|
|
164
|
+
}
|
|
165
|
+
consumeRate(key, limit, windowMs) {
|
|
166
|
+
const now = this.now();
|
|
167
|
+
let rate = this.rates.get(key);
|
|
168
|
+
if (!rate || rate.resetAt <= now) {
|
|
169
|
+
rate = { count: 0, resetAt: now + windowMs };
|
|
170
|
+
this.rates.set(key, rate);
|
|
171
|
+
}
|
|
172
|
+
rate.count++;
|
|
173
|
+
if (rate.count > limit)
|
|
174
|
+
throw new FleetError('rate_limited', 'request rate limit exceeded', {
|
|
175
|
+
retryable: true, details: { retryAfterMs: rate.resetAt - now },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function parseCookies(cookie) {
|
|
180
|
+
return Object.fromEntries(cookie.split(';').flatMap(part => {
|
|
181
|
+
const index = part.indexOf('=');
|
|
182
|
+
if (index < 0)
|
|
183
|
+
return [];
|
|
184
|
+
return [[part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())]];
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type WebControlCommand = 'open' | 'revoke-all';
|
|
2
|
+
export declare const webControlPath: (dir?: string) => string;
|
|
3
|
+
export interface WebControlServer {
|
|
4
|
+
path: string;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function startWebControlServer(options: {
|
|
8
|
+
dir?: string;
|
|
9
|
+
onOpen(): void | Promise<void>;
|
|
10
|
+
onRevokeAll(): void | Promise<void>;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
rateLimit?: number;
|
|
13
|
+
}): Promise<WebControlServer>;
|
|
14
|
+
export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<void>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createConnection, createServer } from 'node:net';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { FleetError, safeLine } from '../application/errors.js';
|
|
5
|
+
import { stateRoot } from '../paths.js';
|
|
6
|
+
export const webControlPath = (dir = join(stateRoot(), 'web')) => join(dir, 'control.sock');
|
|
7
|
+
export async function startWebControlServer(options) {
|
|
8
|
+
const dir = options.dir ?? join(stateRoot(), 'web');
|
|
9
|
+
const path = webControlPath(dir);
|
|
10
|
+
const now = options.now ?? Date.now;
|
|
11
|
+
const rateLimit = options.rateLimit ?? 10;
|
|
12
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
chmodSync(dir, 0o700);
|
|
14
|
+
rmSync(path, { force: true });
|
|
15
|
+
let windowStart = now();
|
|
16
|
+
let used = 0;
|
|
17
|
+
const server = createServer(socket => {
|
|
18
|
+
let body = '';
|
|
19
|
+
socket.setTimeout(5_000, () => socket.destroy());
|
|
20
|
+
socket.on('data', chunk => {
|
|
21
|
+
body += chunk.toString('utf8');
|
|
22
|
+
if (body.length > 4_096)
|
|
23
|
+
return socket.destroy();
|
|
24
|
+
if (!body.includes('\n'))
|
|
25
|
+
return;
|
|
26
|
+
socket.pause();
|
|
27
|
+
void (async () => {
|
|
28
|
+
try {
|
|
29
|
+
if (now() - windowStart >= 60_000) {
|
|
30
|
+
windowStart = now();
|
|
31
|
+
used = 0;
|
|
32
|
+
}
|
|
33
|
+
used++;
|
|
34
|
+
if (used > rateLimit)
|
|
35
|
+
throw new FleetError('rate_limited', 'local web control rate limit exceeded');
|
|
36
|
+
const parsed = JSON.parse(body.slice(0, body.indexOf('\n')));
|
|
37
|
+
if (parsed.command === 'open')
|
|
38
|
+
await options.onOpen();
|
|
39
|
+
else if (parsed.command === 'revoke-all')
|
|
40
|
+
await options.onRevokeAll();
|
|
41
|
+
else
|
|
42
|
+
throw new FleetError('invalid_request', 'unknown local web control command');
|
|
43
|
+
socket.end(JSON.stringify({ ok: true }) + '\n');
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const code = error instanceof FleetError ? error.code : 'internal';
|
|
47
|
+
const message = safeLine(error instanceof Error ? error.message : String(error));
|
|
48
|
+
socket.end(JSON.stringify({ ok: false, error: { code, message } }) + '\n');
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
await listen(server, path);
|
|
54
|
+
chmodSync(path, 0o600);
|
|
55
|
+
let closed = false;
|
|
56
|
+
return {
|
|
57
|
+
path,
|
|
58
|
+
async close() {
|
|
59
|
+
if (closed)
|
|
60
|
+
return;
|
|
61
|
+
closed = true;
|
|
62
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
63
|
+
rmSync(path, { force: true });
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function requestWebControl(command, path = webControlPath(), timeoutMs = 5_000) {
|
|
68
|
+
await new Promise((resolve, reject) => {
|
|
69
|
+
const socket = createConnection(path);
|
|
70
|
+
let response = '';
|
|
71
|
+
let settled = false;
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
socket.destroy();
|
|
74
|
+
reject(new FleetError('timeout', 'local web control request timed out'));
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
const finish = (error) => {
|
|
77
|
+
if (settled)
|
|
78
|
+
return;
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
if (error)
|
|
82
|
+
reject(error);
|
|
83
|
+
else
|
|
84
|
+
resolve();
|
|
85
|
+
};
|
|
86
|
+
socket.once('connect', () => socket.write(JSON.stringify({ command }) + '\n'));
|
|
87
|
+
socket.on('data', chunk => { response += chunk.toString('utf8'); });
|
|
88
|
+
socket.once('error', error => finish(new FleetError('control_unavailable', `running web console control is unavailable: ${safeLine(error.message)}`)));
|
|
89
|
+
socket.once('end', () => {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(response);
|
|
92
|
+
if (!parsed.ok)
|
|
93
|
+
throw new FleetError(parsed.error?.code === 'rate_limited' ? 'rate_limited' : 'rejected', parsed.error?.message ?? 'local web control request was rejected');
|
|
94
|
+
finish();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
finish(error);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function listen(server, path) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
server.once('error', reject);
|
|
105
|
+
server.listen(path, () => {
|
|
106
|
+
server.off('error', reject);
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|