@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,279 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import Fastify from 'fastify';
|
|
5
|
+
import fastifyStatic from '@fastify/static';
|
|
6
|
+
import fastifyWebsocket from '@fastify/websocket';
|
|
7
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
8
|
+
import { FleetError, normalizeError } from '../application/errors.js';
|
|
9
|
+
import { VERSION } from '../version.js';
|
|
10
|
+
import { AuditSink } from './audit.js';
|
|
11
|
+
import { WebAuth } from './auth.js';
|
|
12
|
+
import { FleetEventBus } from './events.js';
|
|
13
|
+
const statusFor = (code) => ({
|
|
14
|
+
role_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
|
|
15
|
+
idempotency_conflict: 409, stale_state: 409, rate_limited: 429,
|
|
16
|
+
invalid_request: 400, capability_unavailable: 409, prerequisite_unavailable: 503,
|
|
17
|
+
}[code] ?? 500);
|
|
18
|
+
export async function buildWebServer(services, boundary, options = {}) {
|
|
19
|
+
const app = Fastify({
|
|
20
|
+
trustProxy: false, bodyLimit: 64 * 1024, logger: false,
|
|
21
|
+
requestIdHeader: false, genReqId: () => cryptoRandomId(),
|
|
22
|
+
});
|
|
23
|
+
const auth = options.auth ?? new WebAuth(boundary.origin, boundary.host);
|
|
24
|
+
const audit = services.audit ?? new AuditSink();
|
|
25
|
+
const events = services.events ?? new FleetEventBus();
|
|
26
|
+
const digestKey = randomBytes(32);
|
|
27
|
+
await app.register(fastifyWebsocket, {
|
|
28
|
+
options: { maxPayload: 64 * 1024, perMessageDeflate: false },
|
|
29
|
+
});
|
|
30
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
31
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
32
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
33
|
+
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
|
34
|
+
reply.header('X-Frame-Options', 'DENY');
|
|
35
|
+
reply.header('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; ` +
|
|
36
|
+
`connect-src 'self' ws://${auth.host}; object-src 'none'; base-uri 'none'; ` +
|
|
37
|
+
`frame-ancestors 'none'; form-action 'self'; manifest-src 'self'; worker-src 'self'`);
|
|
38
|
+
if (request.url.startsWith('/api/'))
|
|
39
|
+
reply.header('Cache-Control', 'no-store');
|
|
40
|
+
if (request.headers.host !== auth.host)
|
|
41
|
+
throw new FleetError('forbidden', 'invalid Host header');
|
|
42
|
+
});
|
|
43
|
+
app.setErrorHandler(async (error, request, reply) => {
|
|
44
|
+
const fleetError = normalizeError(error, request.id);
|
|
45
|
+
await audit.record({
|
|
46
|
+
requestId: request.id, action: `${request.method} ${request.routeOptions.url ?? request.url}`,
|
|
47
|
+
result: 'rejected', errorCode: fleetError.code,
|
|
48
|
+
});
|
|
49
|
+
reply.code(statusFor(fleetError.code)).send({ error: fleetError.toJSON() });
|
|
50
|
+
});
|
|
51
|
+
app.post('/api/v1/auth/exchange', async (request, reply) => {
|
|
52
|
+
const { session, device } = auth.exchange(request);
|
|
53
|
+
setAuthCookies(reply, session.id, device.token);
|
|
54
|
+
await audit.record({ requestId: request.id, browser: session.id, action: 'auth.exchange', result: 'succeeded' });
|
|
55
|
+
return { csrfToken: session.csrf, expiresAt: new Date(session.absoluteExpiresAt).toISOString() };
|
|
56
|
+
});
|
|
57
|
+
app.post('/api/v1/auth/resume', async (request, reply) => {
|
|
58
|
+
const { session, device } = auth.resume(request);
|
|
59
|
+
setAuthCookies(reply, session.id, device.token);
|
|
60
|
+
await audit.record({ requestId: request.id, browser: session.id, action: 'auth.resume', result: 'succeeded' });
|
|
61
|
+
return { csrfToken: session.csrf, expiresAt: new Date(session.absoluteExpiresAt).toISOString() };
|
|
62
|
+
});
|
|
63
|
+
app.post('/api/v1/auth/logout', async (request, reply) => {
|
|
64
|
+
auth.logout(request);
|
|
65
|
+
clearAuthCookies(reply);
|
|
66
|
+
await audit.record({ requestId: request.id, action: 'auth.logout', result: 'succeeded' });
|
|
67
|
+
return { ok: true };
|
|
68
|
+
});
|
|
69
|
+
app.get('/api/v1/auth/session', async (request) => {
|
|
70
|
+
const session = auth.authenticate(request);
|
|
71
|
+
return { csrfToken: session.csrf, expiresAt: new Date(session.absoluteExpiresAt).toISOString() };
|
|
72
|
+
});
|
|
73
|
+
app.get('/api/v1/meta', async (request) => {
|
|
74
|
+
auth.authenticate(request);
|
|
75
|
+
return {
|
|
76
|
+
version: VERSION, api: { major: 1, minor: 0 },
|
|
77
|
+
websocketProtocols: ['ours-fleet-events.v1', 'ours-fleet-terminal.v1'],
|
|
78
|
+
auditDegraded: audit.degraded,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
app.get('/api/v1/creation-capabilities', async (request) => {
|
|
82
|
+
auth.authenticate(request);
|
|
83
|
+
return services.creation.capabilities();
|
|
84
|
+
});
|
|
85
|
+
app.post('/api/v1/roles/preview', async (request) => {
|
|
86
|
+
auth.authenticate(request, true);
|
|
87
|
+
return services.creation.preview(request.body);
|
|
88
|
+
});
|
|
89
|
+
app.post('/api/v1/roles', async (request, reply) => {
|
|
90
|
+
const session = auth.authenticate(request, true);
|
|
91
|
+
const body = request.body;
|
|
92
|
+
const idempotencyKey = String(request.headers['idempotency-key'] ?? '');
|
|
93
|
+
if (!body?.request || !body.previewHash)
|
|
94
|
+
throw new FleetError('invalid_request', 'request and previewHash are required');
|
|
95
|
+
const action = await services.creation.create(body.request, body.previewHash, idempotencyKey, session.id);
|
|
96
|
+
events.publish('creation.changed', action, action.roleId);
|
|
97
|
+
reply.header('Location', `/api/v1/creation-actions/${encodeURIComponent(action.actionId)}`);
|
|
98
|
+
reply.code(202);
|
|
99
|
+
return action;
|
|
100
|
+
});
|
|
101
|
+
app.get('/api/v1/roles', async (request) => {
|
|
102
|
+
auth.authenticate(request);
|
|
103
|
+
return { roles: await services.query.list() };
|
|
104
|
+
});
|
|
105
|
+
app.get('/api/v1/roles/:id', async (request) => {
|
|
106
|
+
auth.authenticate(request);
|
|
107
|
+
return services.query.detail(request.params.id);
|
|
108
|
+
});
|
|
109
|
+
app.get('/api/v1/roles/:id/output', async (request) => {
|
|
110
|
+
auth.authenticate(request);
|
|
111
|
+
const control = await services.session(request.params.id);
|
|
112
|
+
return control.recentOutput({
|
|
113
|
+
since: request.query.since ? Number(request.query.since) : undefined,
|
|
114
|
+
limit: request.query.limit ? Number(request.query.limit) : undefined,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
app.post('/api/v1/roles/:id/input', async (request) => {
|
|
118
|
+
const session = auth.authenticate(request, true);
|
|
119
|
+
const text = String(request.body?.text ?? '');
|
|
120
|
+
const control = await services.session(request.params.id);
|
|
121
|
+
const receipt = await control.sendText(text);
|
|
122
|
+
await audit.record({
|
|
123
|
+
requestId: request.id, browser: session.id, roleId: request.params.id,
|
|
124
|
+
action: 'session.send_text', result: receipt.accepted ? 'accepted' : 'failed',
|
|
125
|
+
bytes: Buffer.byteLength(text),
|
|
126
|
+
digest: createHmac('sha256', digestKey).update(text).digest('hex').slice(0, 24),
|
|
127
|
+
});
|
|
128
|
+
return receipt;
|
|
129
|
+
});
|
|
130
|
+
app.post('/api/v1/roles/:id/interrupt', async (request) => {
|
|
131
|
+
auth.authenticate(request, true);
|
|
132
|
+
const control = await services.session(request.params.id);
|
|
133
|
+
if (!control.interrupt)
|
|
134
|
+
throw new FleetError('capability_unavailable', 'interrupt is unavailable');
|
|
135
|
+
return control.interrupt();
|
|
136
|
+
});
|
|
137
|
+
app.post('/api/v1/roles/:id/permissions/:permissionId', async (request) => {
|
|
138
|
+
auth.authenticate(request, true);
|
|
139
|
+
const control = await services.session(request.params.id);
|
|
140
|
+
if (!control.respondPermission)
|
|
141
|
+
throw new FleetError('capability_unavailable', 'permission response is unavailable');
|
|
142
|
+
return control.respondPermission({
|
|
143
|
+
permissionId: request.params.permissionId,
|
|
144
|
+
optionId: String(request.body?.optionId ?? ''),
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
app.get('/api/v1/roles/:id/logs', async (request) => {
|
|
148
|
+
auth.authenticate(request);
|
|
149
|
+
return services.logs.source(request.params.id)
|
|
150
|
+
.tail(Number(request.query.limit ?? 200), request.query.cursor);
|
|
151
|
+
});
|
|
152
|
+
app.post('/api/v1/roles/:id/actions', async (request, reply) => {
|
|
153
|
+
auth.authenticate(request, true);
|
|
154
|
+
const body = request.body;
|
|
155
|
+
if (!body.action || !['start', 'stop', 'restart_resume', 'restart_fresh'].includes(body.action))
|
|
156
|
+
throw new FleetError('invalid_request', 'invalid lifecycle action');
|
|
157
|
+
const receipt = await services.commands.execute({
|
|
158
|
+
roleId: request.params.id, action: body.action,
|
|
159
|
+
actionId: body.actionId, confirmation: body.confirmation,
|
|
160
|
+
});
|
|
161
|
+
events.publish('action.changed', receipt, request.params.id);
|
|
162
|
+
reply.code(202);
|
|
163
|
+
return receipt;
|
|
164
|
+
});
|
|
165
|
+
app.get('/api/v1/actions/:actionId', async (request) => {
|
|
166
|
+
auth.authenticate(request);
|
|
167
|
+
const receipt = services.commands.get(request.params.actionId);
|
|
168
|
+
if (!receipt)
|
|
169
|
+
throw new FleetError('role_not_found', 'action not found');
|
|
170
|
+
return receipt;
|
|
171
|
+
});
|
|
172
|
+
app.get('/api/v1/creation-actions/:actionId', async (request) => {
|
|
173
|
+
auth.authenticate(request);
|
|
174
|
+
const action = services.creation.get(request.params.actionId);
|
|
175
|
+
if (!action)
|
|
176
|
+
throw new FleetError('role_not_found', 'creation action not found');
|
|
177
|
+
return action;
|
|
178
|
+
});
|
|
179
|
+
app.post('/api/v1/ws-tickets', async (request) => {
|
|
180
|
+
const body = request.body;
|
|
181
|
+
if (!body?.purpose || !['events', 'terminal'].includes(body.purpose))
|
|
182
|
+
throw new FleetError('invalid_request', 'ticket purpose is required');
|
|
183
|
+
return auth.mintTicket(request, body.purpose, body.roleId);
|
|
184
|
+
});
|
|
185
|
+
app.get('/api/v1/audit', async (request) => {
|
|
186
|
+
auth.authenticate(request);
|
|
187
|
+
return { records: audit.list() };
|
|
188
|
+
});
|
|
189
|
+
app.get('/api/v1/watchdogs', async (request) => {
|
|
190
|
+
auth.authenticate(request);
|
|
191
|
+
if (!services.watchdogs)
|
|
192
|
+
throw new FleetError('capability_unavailable', 'watchdogs are unavailable');
|
|
193
|
+
return services.watchdogs.list();
|
|
194
|
+
});
|
|
195
|
+
app.get('/api/v1/watchdogs/:name/reports', async (request) => {
|
|
196
|
+
auth.authenticate(request);
|
|
197
|
+
if (!services.watchdogs)
|
|
198
|
+
throw new FleetError('capability_unavailable', 'watchdogs are unavailable');
|
|
199
|
+
return services.watchdogs.reports(request.params.name, request.query.limit ? Number(request.query.limit) : undefined);
|
|
200
|
+
});
|
|
201
|
+
app.get('/api/v1/watchdogs/:name/reports/:runId', async (request) => {
|
|
202
|
+
auth.authenticate(request);
|
|
203
|
+
if (!services.watchdogs)
|
|
204
|
+
throw new FleetError('capability_unavailable', 'watchdogs are unavailable');
|
|
205
|
+
return services.watchdogs.report(request.params.name, request.params.runId);
|
|
206
|
+
});
|
|
207
|
+
app.get('/api/v1/events', { websocket: true }, (socket, request) => {
|
|
208
|
+
requireSubprotocol(request, 'ours-fleet-events.v1');
|
|
209
|
+
authorizeSocket(socket, request, async (hello) => {
|
|
210
|
+
const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'events');
|
|
211
|
+
auth.bindSocket(session.id, socket);
|
|
212
|
+
const detach = events.attach(socket, typeof hello.lastEventId === 'string' ? hello.lastEventId : undefined);
|
|
213
|
+
socket.on('close', detach);
|
|
214
|
+
socket.send(JSON.stringify({ kind: 'ready', at: new Date().toISOString() }));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
app.get('/api/v1/roles/:id/terminal', { websocket: true }, (socket, request) => {
|
|
218
|
+
requireSubprotocol(request, 'ours-fleet-terminal.v1');
|
|
219
|
+
authorizeSocket(socket, request, async (hello) => {
|
|
220
|
+
const ticket = String(hello.ticket ?? '');
|
|
221
|
+
const session = auth.consumeTicket(request, ticket, 'terminal', request.params.id);
|
|
222
|
+
auth.bindSocket(session.id, socket);
|
|
223
|
+
if (!services.terminalUpgrade)
|
|
224
|
+
throw new FleetError('capability_unavailable', 'terminal PTY support is unavailable');
|
|
225
|
+
await services.terminalUpgrade(socket, request, request.params.id, ticket, hello);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
const staticRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
|
|
229
|
+
if (existsSync(staticRoot)) {
|
|
230
|
+
await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
|
|
231
|
+
app.setNotFoundHandler((request, reply) => {
|
|
232
|
+
if (request.url.startsWith('/api/'))
|
|
233
|
+
return reply.code(404).send({ error: 'not found' });
|
|
234
|
+
return reply.sendFile('index.html');
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
app, auth, audit, events,
|
|
239
|
+
async close() {
|
|
240
|
+
auth.shutdown();
|
|
241
|
+
events.close();
|
|
242
|
+
await app.close();
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function setAuthCookies(reply, session, device) {
|
|
247
|
+
reply.header('Set-Cookie', [
|
|
248
|
+
`ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800`,
|
|
249
|
+
`ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=2592000`,
|
|
250
|
+
]);
|
|
251
|
+
}
|
|
252
|
+
function clearAuthCookies(reply) {
|
|
253
|
+
reply.header('Set-Cookie', [
|
|
254
|
+
'ofs_session=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
|
|
255
|
+
'ofs_device=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
function cryptoRandomId() { return randomBytes(12).toString('hex'); }
|
|
259
|
+
function requireSubprotocol(request, expected) {
|
|
260
|
+
const protocols = String(request.headers['sec-websocket-protocol'] ?? '')
|
|
261
|
+
.split(',').map(value => value.trim());
|
|
262
|
+
if (!protocols.includes(expected))
|
|
263
|
+
throw new FleetError('forbidden', `required subprotocol ${expected}`);
|
|
264
|
+
}
|
|
265
|
+
function authorizeSocket(socket, request, authorize) {
|
|
266
|
+
const timer = setTimeout(() => socket.close(4401, 'authorization timeout'), 5_000);
|
|
267
|
+
socket.once('message', data => {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
void (async () => {
|
|
270
|
+
try {
|
|
271
|
+
const hello = JSON.parse(data.toString());
|
|
272
|
+
await authorize(hello);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
socket.close(4403, normalizeError(error).message.slice(0, 120));
|
|
276
|
+
}
|
|
277
|
+
})();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
export declare const WEB_SYSTEMD_UNIT = "ours-fleet-web.service";
|
|
3
|
+
export declare const WEB_LAUNCHD_LABEL = "network.ours.fleet.web";
|
|
4
|
+
interface ServiceMetadata {
|
|
5
|
+
version: 2;
|
|
6
|
+
platform: 'linux' | 'darwin';
|
|
7
|
+
runtime: string;
|
|
8
|
+
script: string;
|
|
9
|
+
port: number;
|
|
10
|
+
configuration?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WebServiceOptions {
|
|
13
|
+
platform?: NodeJS.Platform;
|
|
14
|
+
exec?: Exec;
|
|
15
|
+
homeDir?: string;
|
|
16
|
+
stateDir?: string;
|
|
17
|
+
uid?: number;
|
|
18
|
+
runtimeExecutable?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class WebServiceManager {
|
|
21
|
+
private readonly platform;
|
|
22
|
+
private readonly exec;
|
|
23
|
+
private readonly homeDir;
|
|
24
|
+
private readonly stateDir;
|
|
25
|
+
private readonly uid;
|
|
26
|
+
private readonly runtimeExecutable;
|
|
27
|
+
constructor(options?: WebServiceOptions);
|
|
28
|
+
get metadataPath(): string;
|
|
29
|
+
get definitionPath(): string;
|
|
30
|
+
install(script: string, port?: number, configuration?: string): Promise<string[]>;
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
stop(): Promise<void>;
|
|
33
|
+
restart(): Promise<void>;
|
|
34
|
+
status(): Promise<string>;
|
|
35
|
+
uninstall(): Promise<string>;
|
|
36
|
+
readMetadata(): ServiceMetadata | undefined;
|
|
37
|
+
private requireInstalled;
|
|
38
|
+
private must;
|
|
39
|
+
}
|
|
40
|
+
export declare function systemdUnit(runtime: string, script: string, port: number, configuration?: string): string;
|
|
41
|
+
export declare function launchdPlist(runtime: string, script: string, port: number, configuration?: string): string;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { FleetError } from '../application/errors.js';
|
|
6
|
+
import { realExec } from '../exec.js';
|
|
7
|
+
import { home, stateRoot } from '../paths.js';
|
|
8
|
+
const VERSION = 2;
|
|
9
|
+
export const WEB_SYSTEMD_UNIT = 'ours-fleet-web.service';
|
|
10
|
+
export const WEB_LAUNCHD_LABEL = 'network.ours.fleet.web';
|
|
11
|
+
export class WebServiceManager {
|
|
12
|
+
platform;
|
|
13
|
+
exec;
|
|
14
|
+
homeDir;
|
|
15
|
+
stateDir;
|
|
16
|
+
uid;
|
|
17
|
+
runtimeExecutable;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
const platform = options.platform ?? process.platform;
|
|
20
|
+
if (platform !== 'linux' && platform !== 'darwin')
|
|
21
|
+
throw new FleetError('capability_unavailable', `web service is unsupported on ${platform}`);
|
|
22
|
+
this.platform = platform;
|
|
23
|
+
this.exec = options.exec ?? realExec;
|
|
24
|
+
this.homeDir = options.homeDir ?? home();
|
|
25
|
+
this.stateDir = options.stateDir ?? join(stateRoot(), 'web');
|
|
26
|
+
this.uid = options.uid ?? process.getuid?.() ?? 501;
|
|
27
|
+
this.runtimeExecutable = options.runtimeExecutable ?? process.execPath;
|
|
28
|
+
}
|
|
29
|
+
get metadataPath() { return join(this.stateDir, 'service.json'); }
|
|
30
|
+
get definitionPath() {
|
|
31
|
+
return this.platform === 'linux'
|
|
32
|
+
? join(this.homeDir, '.config', 'systemd', 'user', WEB_SYSTEMD_UNIT)
|
|
33
|
+
: join(this.homeDir, 'Library', 'LaunchAgents', `${WEB_LAUNCHD_LABEL}.plist`);
|
|
34
|
+
}
|
|
35
|
+
async install(script, port = 49_271, configuration) {
|
|
36
|
+
validatePort(port);
|
|
37
|
+
const resolvedScript = resolveExecutable(script, 'web CLI script');
|
|
38
|
+
const runtime = resolveExecutable(this.runtimeExecutable, 'Node runtime');
|
|
39
|
+
mkdirSync(dirname(this.definitionPath), { recursive: true, mode: 0o700 });
|
|
40
|
+
mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
|
|
41
|
+
chmodSync(this.stateDir, 0o700);
|
|
42
|
+
const config = configuration ? resolve(configuration) : undefined;
|
|
43
|
+
const metadata = {
|
|
44
|
+
version: VERSION, platform: this.platform, runtime, script: resolvedScript, port,
|
|
45
|
+
...(config ? { configuration: config } : {}),
|
|
46
|
+
};
|
|
47
|
+
replaceFileAtomically(this.definitionPath, this.platform === 'linux'
|
|
48
|
+
? systemdUnit(runtime, resolvedScript, port, config)
|
|
49
|
+
: launchdPlist(runtime, resolvedScript, port, config), 0o600);
|
|
50
|
+
replaceFileAtomically(this.metadataPath, JSON.stringify(metadata, null, 2) + '\n', 0o600);
|
|
51
|
+
if (this.platform === 'linux') {
|
|
52
|
+
await this.must('systemctl', ['--user', 'daemon-reload']);
|
|
53
|
+
await this.must('systemctl', ['--user', 'enable', WEB_SYSTEMD_UNIT]);
|
|
54
|
+
const linger = await this.exec('loginctl', ['show-user', userInfo().username, '-p', 'Linger', '--value']);
|
|
55
|
+
return [
|
|
56
|
+
`installed ${this.definitionPath}`,
|
|
57
|
+
linger.stdout.trim() === 'yes'
|
|
58
|
+
? 'login persistence available (linger enabled)'
|
|
59
|
+
: `warning: login persistence requires linger; run: sudo loginctl enable-linger ${userInfo().username}`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
return [`installed ${this.definitionPath}`, 'launchd service starts at login'];
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
this.requireInstalled();
|
|
66
|
+
if (this.platform === 'linux') {
|
|
67
|
+
await this.must('systemctl', ['--user', 'start', WEB_SYSTEMD_UNIT]);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const domain = `gui/${this.uid}`;
|
|
71
|
+
const loaded = await this.exec('launchctl', ['print', `${domain}/${WEB_LAUNCHD_LABEL}`]);
|
|
72
|
+
if (loaded.code === 0)
|
|
73
|
+
await this.must('launchctl', ['kickstart', `${domain}/${WEB_LAUNCHD_LABEL}`]);
|
|
74
|
+
else
|
|
75
|
+
await this.must('launchctl', ['bootstrap', domain, this.definitionPath]);
|
|
76
|
+
}
|
|
77
|
+
async stop() {
|
|
78
|
+
if (this.platform === 'linux') {
|
|
79
|
+
await this.must('systemctl', ['--user', 'stop', WEB_SYSTEMD_UNIT]);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const result = await this.exec('launchctl', ['bootout', `gui/${this.uid}/${WEB_LAUNCHD_LABEL}`]);
|
|
83
|
+
if (result.code !== 0 && !/could not find service|no such process/i.test(`${result.stdout}\n${result.stderr}`))
|
|
84
|
+
throw new FleetError('control_unavailable', `launchctl bootout failed: ${result.stderr.trim()}`);
|
|
85
|
+
}
|
|
86
|
+
async restart() {
|
|
87
|
+
this.requireInstalled();
|
|
88
|
+
if (this.platform === 'linux') {
|
|
89
|
+
await this.must('systemctl', ['--user', 'restart', WEB_SYSTEMD_UNIT]);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const result = await this.exec('launchctl', ['kickstart', '-k', `gui/${this.uid}/${WEB_LAUNCHD_LABEL}`]);
|
|
93
|
+
if (result.code !== 0) {
|
|
94
|
+
await this.stop();
|
|
95
|
+
await this.start();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async status() {
|
|
99
|
+
if (this.platform === 'linux') {
|
|
100
|
+
const result = await this.exec('systemctl', [
|
|
101
|
+
'--user', 'show', WEB_SYSTEMD_UNIT, '-p', 'LoadState', '-p', 'ActiveState',
|
|
102
|
+
'-p', 'SubState', '-p', 'ExecMainPID', '--no-pager',
|
|
103
|
+
]);
|
|
104
|
+
return result.stdout.trim() || result.stderr.trim() || `exit ${result.code}`;
|
|
105
|
+
}
|
|
106
|
+
const result = await this.exec('launchctl', ['print', `gui/${this.uid}/${WEB_LAUNCHD_LABEL}`]);
|
|
107
|
+
return result.code === 0 ? result.stdout.trim() : `not loaded (${WEB_LAUNCHD_LABEL})`;
|
|
108
|
+
}
|
|
109
|
+
async uninstall() {
|
|
110
|
+
if (this.platform === 'linux') {
|
|
111
|
+
await this.exec('systemctl', ['--user', 'disable', '--now', WEB_SYSTEMD_UNIT]);
|
|
112
|
+
rmSync(this.definitionPath, { force: true });
|
|
113
|
+
rmSync(this.metadataPath, { force: true });
|
|
114
|
+
await this.exec('systemctl', ['--user', 'daemon-reload']);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
await this.stop();
|
|
118
|
+
rmSync(this.definitionPath, { force: true });
|
|
119
|
+
rmSync(this.metadataPath, { force: true });
|
|
120
|
+
}
|
|
121
|
+
return `uninstalled ${this.platform === 'linux' ? WEB_SYSTEMD_UNIT : WEB_LAUNCHD_LABEL}`;
|
|
122
|
+
}
|
|
123
|
+
readMetadata() {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(readFileSync(this.metadataPath, 'utf8'));
|
|
126
|
+
return parsed.version === VERSION && parsed.platform === this.platform
|
|
127
|
+
&& typeof parsed.runtime === 'string' && typeof parsed.script === 'string'
|
|
128
|
+
&& Number.isInteger(parsed.port) ? parsed : undefined;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
requireInstalled() {
|
|
135
|
+
if (!existsSync(this.definitionPath) || !this.readMetadata())
|
|
136
|
+
throw new FleetError('prerequisite_unavailable', 'web service is not installed; run `ours-fleet web install`');
|
|
137
|
+
}
|
|
138
|
+
async must(command, args) {
|
|
139
|
+
const result = await this.exec(command, args);
|
|
140
|
+
if (result.code !== 0)
|
|
141
|
+
throw new FleetError('control_unavailable', `${command} ${args.join(' ')} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function resolveExecutable(executable, label) {
|
|
145
|
+
const absolute = isAbsolute(executable) ? executable : resolve(executable);
|
|
146
|
+
try {
|
|
147
|
+
return realpathSync(absolute);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new FleetError('prerequisite_unavailable', `${label} does not exist: ${absolute}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function validatePort(port) {
|
|
154
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
155
|
+
throw new FleetError('invalid_request', 'service port must be between 1 and 65535');
|
|
156
|
+
}
|
|
157
|
+
function systemdQuote(value) {
|
|
158
|
+
return `"${value.replace(/[%\\"]/g, char => char === '%' ? '%%' : `\\${char}`)}"`;
|
|
159
|
+
}
|
|
160
|
+
export function systemdUnit(runtime, script, port, configuration) {
|
|
161
|
+
const config = configuration ? ` --configuration ${systemdQuote(configuration)}` : '';
|
|
162
|
+
return `[Unit]\nDescription=ours-fleet localhost web console\nAfter=default.target\n\n`
|
|
163
|
+
+ `[Service]\nType=simple\nExecStart=${systemdQuote(runtime)} ${systemdQuote(script)} web serve --port ${port} --no-open${config}\n`
|
|
164
|
+
+ `Restart=on-failure\nRestartSec=5\nTimeoutStopSec=15\n\n`
|
|
165
|
+
+ `[Install]\nWantedBy=default.target\n`;
|
|
166
|
+
}
|
|
167
|
+
const xml = (value) => value.replace(/[&<>"']/g, char => ({
|
|
168
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
169
|
+
}[char]));
|
|
170
|
+
export function launchdPlist(runtime, script, port, configuration) {
|
|
171
|
+
const config = configuration
|
|
172
|
+
? `<string>--configuration</string><string>${xml(configuration)}</string>` : '';
|
|
173
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n`
|
|
174
|
+
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n`
|
|
175
|
+
+ `<plist version="1.0"><dict>\n<key>Label</key><string>${WEB_LAUNCHD_LABEL}</string>\n`
|
|
176
|
+
+ `<key>ProgramArguments</key><array><string>${xml(runtime)}</string><string>${xml(script)}</string><string>web</string>`
|
|
177
|
+
+ `<string>serve</string><string>--port</string><string>${port}</string><string>--no-open</string>${config}</array>\n`
|
|
178
|
+
+ `<key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n`
|
|
179
|
+
+ `<key>ProcessType</key><string>Background</string>\n</dict></plist>\n`;
|
|
180
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
import { Tmux } from '../../tmux.js';
|
|
3
|
+
import type { RoleRepository } from '../../application/role-repository.js';
|
|
4
|
+
import type { AuditSink } from '../audit.js';
|
|
5
|
+
type Constructor<T> = new (...args: any[]) => T;
|
|
6
|
+
/** Node exposes these CommonJS xterm packages under `default`; Vite may expose named exports. */
|
|
7
|
+
export declare function resolveModuleConstructor<T>(module: unknown, name: string): Constructor<T>;
|
|
8
|
+
export interface TerminalBridgeManagerOptions {
|
|
9
|
+
repository: RoleRepository;
|
|
10
|
+
audit: AuditSink;
|
|
11
|
+
tmux?: Tmux;
|
|
12
|
+
maxBridges?: number;
|
|
13
|
+
graceMs?: number;
|
|
14
|
+
loadPty?: () => Promise<typeof import('node-pty')>;
|
|
15
|
+
}
|
|
16
|
+
export declare class TerminalBridgeManager {
|
|
17
|
+
private readonly options;
|
|
18
|
+
private readonly bridges;
|
|
19
|
+
private ptyModule?;
|
|
20
|
+
private ptyError?;
|
|
21
|
+
constructor(options: TerminalBridgeManagerOptions);
|
|
22
|
+
available(): Promise<boolean>;
|
|
23
|
+
diagnostic(): string | undefined;
|
|
24
|
+
connect(socket: WebSocket, roleId: string, hello: Record<string, unknown>): Promise<void>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export {};
|