@ours.network/fleet 0.10.2 → 0.10.4
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 +31 -0
- package/dist/application/fleet-query-service.js +180 -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 +141 -0
- package/dist/config.d.ts +7 -2
- package/dist/config.js +18 -4
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/runner.js +10 -2
- package/dist/session/acp.js +5 -1
- 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/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 +170 -0
- package/dist/web/server.d.ts +35 -0
- package/dist/web/server.js +261 -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-DcImdrI1.js +9 -0
- package/dist/web-app/assets/index-BokQN1Ao.js +9 -0
- package/dist/web-app/assets/index-lAXzaOZM.css +1 -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
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface TrustedDeviceIssue {
|
|
2
|
+
token: string;
|
|
3
|
+
id: string;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
}
|
|
6
|
+
/** Persistent trusted-device registry. Raw device secrets never enter this store. */
|
|
7
|
+
export declare class TrustedDeviceStore {
|
|
8
|
+
private readonly now;
|
|
9
|
+
private readonly ttlMs;
|
|
10
|
+
private readonly maxDevices;
|
|
11
|
+
readonly dir: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
private devices;
|
|
14
|
+
constructor(dir?: string, now?: () => number, ttlMs?: number, maxDevices?: number);
|
|
15
|
+
issue(): TrustedDeviceIssue;
|
|
16
|
+
/** Validate and rotate on use. The old token is invalid as soon as this returns. */
|
|
17
|
+
rotate(token: string): TrustedDeviceIssue | undefined;
|
|
18
|
+
revoke(token: string): boolean;
|
|
19
|
+
/** Revoke an already-authenticated device without retaining its raw secret in a session. */
|
|
20
|
+
revokeId(id: string): boolean;
|
|
21
|
+
revokeAll(): number;
|
|
22
|
+
count(): number;
|
|
23
|
+
private read;
|
|
24
|
+
private prune;
|
|
25
|
+
private write;
|
|
26
|
+
}
|
|
27
|
+
export declare const TRUSTED_DEVICE_MAX_AGE_SECONDS: number;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { FleetError } from '../application/errors.js';
|
|
6
|
+
import { stateRoot } from '../paths.js';
|
|
7
|
+
const VERSION = 1;
|
|
8
|
+
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60_000;
|
|
9
|
+
const DEFAULT_MAX_DEVICES = 32;
|
|
10
|
+
const MAX_STORE_BYTES = 128 * 1024;
|
|
11
|
+
const opaque = (bytes) => randomBytes(bytes).toString('base64url');
|
|
12
|
+
const digest = (id, secret) => createHash('sha256').update('ours-fleet-device-v1\0').update(id).update('\0').update(secret).digest('base64url');
|
|
13
|
+
const safeEqual = (left, right) => {
|
|
14
|
+
const a = Buffer.from(left);
|
|
15
|
+
const b = Buffer.from(right);
|
|
16
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
17
|
+
};
|
|
18
|
+
function parseToken(token) {
|
|
19
|
+
const split = token.indexOf('.');
|
|
20
|
+
if (split < 1)
|
|
21
|
+
return undefined;
|
|
22
|
+
const id = token.slice(0, split);
|
|
23
|
+
const secret = token.slice(split + 1);
|
|
24
|
+
if (!/^[A-Za-z0-9_-]{16,64}$/.test(id) || !/^[A-Za-z0-9_-]{32,128}$/.test(secret))
|
|
25
|
+
return undefined;
|
|
26
|
+
return { id, secret };
|
|
27
|
+
}
|
|
28
|
+
/** Persistent trusted-device registry. Raw device secrets never enter this store. */
|
|
29
|
+
export class TrustedDeviceStore {
|
|
30
|
+
now;
|
|
31
|
+
ttlMs;
|
|
32
|
+
maxDevices;
|
|
33
|
+
dir;
|
|
34
|
+
path;
|
|
35
|
+
devices;
|
|
36
|
+
constructor(dir = join(stateRoot(), 'web'), now = Date.now, ttlMs = DEFAULT_TTL_MS, maxDevices = DEFAULT_MAX_DEVICES) {
|
|
37
|
+
this.now = now;
|
|
38
|
+
this.ttlMs = ttlMs;
|
|
39
|
+
this.maxDevices = maxDevices;
|
|
40
|
+
this.dir = dir;
|
|
41
|
+
this.path = join(dir, 'trusted-devices.json');
|
|
42
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
chmodSync(dir, 0o700);
|
|
44
|
+
this.devices = this.read();
|
|
45
|
+
this.prune(true);
|
|
46
|
+
}
|
|
47
|
+
issue() {
|
|
48
|
+
this.prune(false);
|
|
49
|
+
const now = this.now();
|
|
50
|
+
const id = opaque(16);
|
|
51
|
+
const secret = opaque(32);
|
|
52
|
+
const device = {
|
|
53
|
+
id, secretHash: digest(id, secret), pairedAt: now,
|
|
54
|
+
lastUsedAt: now, expiresAt: now + this.ttlMs,
|
|
55
|
+
};
|
|
56
|
+
this.devices.push(device);
|
|
57
|
+
this.devices.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
58
|
+
this.devices = this.devices.slice(0, Math.max(1, Math.min(this.maxDevices, 128)));
|
|
59
|
+
this.write();
|
|
60
|
+
return { token: `${id}.${secret}`, id, expiresAt: device.expiresAt };
|
|
61
|
+
}
|
|
62
|
+
/** Validate and rotate on use. The old token is invalid as soon as this returns. */
|
|
63
|
+
rotate(token) {
|
|
64
|
+
const parsed = parseToken(token);
|
|
65
|
+
if (!parsed)
|
|
66
|
+
return undefined;
|
|
67
|
+
this.prune(false);
|
|
68
|
+
const index = this.devices.findIndex(device => device.id === parsed.id);
|
|
69
|
+
const existing = index >= 0 ? this.devices[index] : undefined;
|
|
70
|
+
if (!existing || !safeEqual(existing.secretHash, digest(parsed.id, parsed.secret)))
|
|
71
|
+
return undefined;
|
|
72
|
+
const now = this.now();
|
|
73
|
+
if (existing.expiresAt <= now) {
|
|
74
|
+
this.devices.splice(index, 1);
|
|
75
|
+
this.write();
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const secret = opaque(32);
|
|
79
|
+
const replacement = {
|
|
80
|
+
...existing, secretHash: digest(existing.id, secret),
|
|
81
|
+
lastUsedAt: now, expiresAt: now + this.ttlMs,
|
|
82
|
+
};
|
|
83
|
+
this.devices[index] = replacement;
|
|
84
|
+
this.write();
|
|
85
|
+
return { token: `${existing.id}.${secret}`, id: existing.id, expiresAt: replacement.expiresAt };
|
|
86
|
+
}
|
|
87
|
+
revoke(token) {
|
|
88
|
+
const parsed = parseToken(token);
|
|
89
|
+
if (!parsed)
|
|
90
|
+
return false;
|
|
91
|
+
const index = this.devices.findIndex(device => device.id === parsed.id
|
|
92
|
+
&& safeEqual(device.secretHash, digest(parsed.id, parsed.secret)));
|
|
93
|
+
if (index < 0)
|
|
94
|
+
return false;
|
|
95
|
+
this.devices.splice(index, 1);
|
|
96
|
+
this.write();
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/** Revoke an already-authenticated device without retaining its raw secret in a session. */
|
|
100
|
+
revokeId(id) {
|
|
101
|
+
const index = this.devices.findIndex(device => device.id === id);
|
|
102
|
+
if (index < 0)
|
|
103
|
+
return false;
|
|
104
|
+
this.devices.splice(index, 1);
|
|
105
|
+
this.write();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
revokeAll() {
|
|
109
|
+
const count = this.devices.length;
|
|
110
|
+
this.devices = [];
|
|
111
|
+
this.write();
|
|
112
|
+
return count;
|
|
113
|
+
}
|
|
114
|
+
count() { this.prune(true); return this.devices.length; }
|
|
115
|
+
read() {
|
|
116
|
+
try {
|
|
117
|
+
const stat = lstatSync(this.path);
|
|
118
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
119
|
+
throw new FleetError('forbidden', 'trusted-device store is not a regular private file');
|
|
120
|
+
if (stat.size > MAX_STORE_BYTES)
|
|
121
|
+
throw new FleetError('forbidden', 'trusted-device store is oversized');
|
|
122
|
+
chmodSync(this.path, 0o600);
|
|
123
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
124
|
+
if (parsed.version !== VERSION || !Array.isArray(parsed.devices))
|
|
125
|
+
return [];
|
|
126
|
+
return parsed.devices.slice(0, 128).filter((device) => Boolean(device && typeof device.id === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(device.id)
|
|
127
|
+
&& typeof device.secretHash === 'string' && /^[A-Za-z0-9_-]{32,128}$/.test(device.secretHash)
|
|
128
|
+
&& Number.isFinite(device.pairedAt) && Number.isFinite(device.lastUsedAt)
|
|
129
|
+
&& Number.isFinite(device.expiresAt)));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof FleetError)
|
|
133
|
+
throw error;
|
|
134
|
+
if (error.code === 'ENOENT')
|
|
135
|
+
return [];
|
|
136
|
+
// Corrupt content fails closed. A later explicit pairing atomically
|
|
137
|
+
// replaces it; no credential is recovered or guessed.
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
prune(persist) {
|
|
142
|
+
const now = this.now();
|
|
143
|
+
const before = this.devices.length;
|
|
144
|
+
this.devices = this.devices.filter(device => device.expiresAt > now)
|
|
145
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
|
146
|
+
.slice(0, Math.max(1, Math.min(this.maxDevices, 128)));
|
|
147
|
+
if (persist && before !== this.devices.length)
|
|
148
|
+
this.write();
|
|
149
|
+
}
|
|
150
|
+
write() {
|
|
151
|
+
replaceFileAtomically(this.path, JSON.stringify({ version: VERSION, devices: this.devices }, null, 2) + '\n', 0o600);
|
|
152
|
+
chmodSync(this.path, 0o600);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export const TRUSTED_DEVICE_MAX_AGE_SECONDS = DEFAULT_TTL_MS / 1000;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
export interface FleetEvent {
|
|
3
|
+
eventId: string;
|
|
4
|
+
at: string;
|
|
5
|
+
roleId?: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
payload: unknown;
|
|
8
|
+
}
|
|
9
|
+
export declare class FleetEventBus {
|
|
10
|
+
private readonly events;
|
|
11
|
+
private readonly clients;
|
|
12
|
+
publish(kind: string, payload: unknown, roleId?: string): FleetEvent;
|
|
13
|
+
attach(socket: WebSocket, lastEventId?: string): () => void;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export class FleetEventBus {
|
|
3
|
+
events = [];
|
|
4
|
+
clients = new Set();
|
|
5
|
+
publish(kind, payload, roleId) {
|
|
6
|
+
const event = { eventId: randomUUID(), at: new Date().toISOString(), roleId, kind, payload };
|
|
7
|
+
this.events.push(event);
|
|
8
|
+
if (this.events.length > 5_000)
|
|
9
|
+
this.events.shift();
|
|
10
|
+
const frame = JSON.stringify(event);
|
|
11
|
+
for (const socket of this.clients) {
|
|
12
|
+
if (socket.readyState === socket.OPEN && socket.bufferedAmount < 1024 * 1024)
|
|
13
|
+
socket.send(frame);
|
|
14
|
+
}
|
|
15
|
+
return event;
|
|
16
|
+
}
|
|
17
|
+
attach(socket, lastEventId) {
|
|
18
|
+
this.clients.add(socket);
|
|
19
|
+
if (lastEventId) {
|
|
20
|
+
const index = this.events.findIndex(event => event.eventId === lastEventId);
|
|
21
|
+
if (index < 0)
|
|
22
|
+
socket.send(JSON.stringify({ kind: 'resync.required', at: new Date().toISOString() }));
|
|
23
|
+
else
|
|
24
|
+
for (const event of this.events.slice(index + 1))
|
|
25
|
+
socket.send(JSON.stringify(event));
|
|
26
|
+
}
|
|
27
|
+
return () => this.clients.delete(socket);
|
|
28
|
+
}
|
|
29
|
+
close() {
|
|
30
|
+
for (const socket of this.clients)
|
|
31
|
+
socket.close(1001, 'server shutdown');
|
|
32
|
+
this.clients.clear();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/web/lock.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { stateRoot } from '../paths.js';
|
|
4
|
+
import { FleetError } from '../application/errors.js';
|
|
5
|
+
const processMarker = (pid) => {
|
|
6
|
+
try {
|
|
7
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
8
|
+
const end = stat.lastIndexOf(')');
|
|
9
|
+
return stat.slice(end + 2).split(/\s+/)[19];
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const alive = (pid) => {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export function acquireWebServerLock(dir = join(stateRoot(), 'web')) {
|
|
25
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const path = join(dir, 'server.lock');
|
|
27
|
+
const ours = { pid: process.pid, marker: processMarker(process.pid), createdAt: new Date().toISOString() };
|
|
28
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
31
|
+
writeFileSync(fd, JSON.stringify(ours) + '\n');
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
let released = false;
|
|
34
|
+
return {
|
|
35
|
+
path,
|
|
36
|
+
release() {
|
|
37
|
+
if (released)
|
|
38
|
+
return;
|
|
39
|
+
released = true;
|
|
40
|
+
try {
|
|
41
|
+
const current = JSON.parse(readFileSync(path, 'utf8'));
|
|
42
|
+
if (current.pid === ours.pid && current.marker === ours.marker)
|
|
43
|
+
rmSync(path, { force: true });
|
|
44
|
+
}
|
|
45
|
+
catch { /* do not remove a lock we cannot prove is ours */ }
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== 'EEXIST')
|
|
51
|
+
throw error;
|
|
52
|
+
try {
|
|
53
|
+
const current = JSON.parse(readFileSync(path, 'utf8'));
|
|
54
|
+
if (typeof current.pid === 'number' && alive(current.pid)) {
|
|
55
|
+
const marker = processMarker(current.pid);
|
|
56
|
+
if (!marker || !current.marker || marker === current.marker)
|
|
57
|
+
throw new FleetError('conflict', `another ours-fleet web server is running (pid ${current.pid})`);
|
|
58
|
+
}
|
|
59
|
+
rmSync(path, { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch (readError) {
|
|
62
|
+
if (readError instanceof FleetError)
|
|
63
|
+
throw readError;
|
|
64
|
+
throw new FleetError('conflict', 'web server lock exists but cannot be verified safely');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new FleetError('conflict', 'could not acquire web server lock');
|
|
69
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type WebServer } from './server.js';
|
|
2
|
+
export interface StartWebOptions {
|
|
3
|
+
configPath?: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
open?: boolean;
|
|
6
|
+
binPath: string;
|
|
7
|
+
log?(line: string): void;
|
|
8
|
+
}
|
|
9
|
+
export interface RunningWebConsole extends WebServer {
|
|
10
|
+
address: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function startWebConsole(options: StartWebOptions): Promise<RunningWebConsole>;
|