@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { RoleRepository } from '../application/role-repository.js';
|
|
5
|
+
import { FleetQueryService } from '../application/fleet-query-service.js';
|
|
6
|
+
import { AcpRoleSessionAdapter, TmuxRoleSessionAdapter } from '../application/session-control.js';
|
|
7
|
+
import { StructuredLogService } from '../application/log-service.js';
|
|
8
|
+
import { RoleCommandService } from '../application/role-command-service.js';
|
|
9
|
+
import { RoleCreationService } from '../application/role-creation-service.js';
|
|
10
|
+
import { FleetError } from '../application/errors.js';
|
|
11
|
+
import { controlRequest, controlSocketPath } from '../session/control.js';
|
|
12
|
+
import { Tmux } from '../tmux.js';
|
|
13
|
+
import { pickBackend } from '../supervisor/index.js';
|
|
14
|
+
import { realExec } from '../exec.js';
|
|
15
|
+
import { home, stateRoot } from '../paths.js';
|
|
16
|
+
import { AuditSink } from './audit.js';
|
|
17
|
+
import { FleetEventBus } from './events.js';
|
|
18
|
+
import { buildWebServer } from './server.js';
|
|
19
|
+
import { TerminalBridgeManager } from './terminal/bridge.js';
|
|
20
|
+
import { acquireWebServerLock } from './lock.js';
|
|
21
|
+
import { TrustedDeviceStore } from './device-store.js';
|
|
22
|
+
import { WebAuth } from './auth.js';
|
|
23
|
+
import { startWebControlServer } from './control.js';
|
|
24
|
+
export async function startWebConsole(options) {
|
|
25
|
+
const requestedPort = options.port ?? 49_271;
|
|
26
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535)
|
|
27
|
+
throw new FleetError('invalid_request', 'port must be between 0 and 65535');
|
|
28
|
+
const lock = acquireWebServerLock();
|
|
29
|
+
const webDir = resolve(stateRoot(), 'web');
|
|
30
|
+
const auth = new WebAuth(`http://127.0.0.1:${requestedPort}`, `127.0.0.1:${requestedPort}`, Date.now, new TrustedDeviceStore(webDir));
|
|
31
|
+
const tmux = new Tmux();
|
|
32
|
+
const backend = pickBackend();
|
|
33
|
+
const repository = new RoleRepository({
|
|
34
|
+
configPath: options.configPath,
|
|
35
|
+
probeBackend: async (name) => {
|
|
36
|
+
let acp = false;
|
|
37
|
+
const permanent = resolve(home(), '.ours-fleet', 'agents', name);
|
|
38
|
+
const temporary = resolve(home(), '.ours-fleet', 'tmp', name);
|
|
39
|
+
for (const dir of [permanent, temporary]) {
|
|
40
|
+
if (!existsSync(controlSocketPath(dir)))
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
acp = (await controlRequest(dir, { command: 'snapshot' }, 500)).ok;
|
|
44
|
+
}
|
|
45
|
+
catch { /* stale socket is evidence, not reachability */ }
|
|
46
|
+
}
|
|
47
|
+
return { acp, tmux: await tmux.has(name).catch(() => false) };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const events = new FleetEventBus();
|
|
51
|
+
const audit = new AuditSink();
|
|
52
|
+
const terminals = new TerminalBridgeManager({ repository, audit, tmux });
|
|
53
|
+
const terminalAvailable = await terminals.available();
|
|
54
|
+
const query = new FleetQueryService({
|
|
55
|
+
repository, supervisor: backend, tmux,
|
|
56
|
+
capabilityContext: { terminalPtyAvailable: terminalAvailable },
|
|
57
|
+
});
|
|
58
|
+
const ops = { backend, binPath: options.binPath, log: options.log ?? (() => { }) };
|
|
59
|
+
const creation = new RoleCreationService({
|
|
60
|
+
configPath: options.configPath, ops, binPath: options.binPath,
|
|
61
|
+
allowedCwdRoots: [realpathSync(home()), realpathSync(process.cwd())],
|
|
62
|
+
probeReady: async (name) => {
|
|
63
|
+
const detail = await query.detail(name).catch(() => undefined);
|
|
64
|
+
return detail?.status.overall === 'ready' || detail?.status.overall === 'busy'
|
|
65
|
+
? 'ready' : detail?.status.overall === 'attention' ? 'attention' : 'unknown';
|
|
66
|
+
},
|
|
67
|
+
onProgress: action => {
|
|
68
|
+
events.publish('creation.changed', action, action.roleId);
|
|
69
|
+
void audit.record({
|
|
70
|
+
roleId: action.roleId, action: `creation.${action.state}`,
|
|
71
|
+
result: action.error?.code ?? action.state,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const commands = new RoleCommandService({
|
|
76
|
+
repository, ops, configPath: options.configPath,
|
|
77
|
+
status: async (roleId) => (await query.detail(roleId)).status,
|
|
78
|
+
onProgress: receipt => {
|
|
79
|
+
events.publish('action.changed', receipt, receipt.roleId);
|
|
80
|
+
void audit.record({
|
|
81
|
+
roleId: receipt.roleId, action: `lifecycle.${receipt.action}`,
|
|
82
|
+
result: receipt.state, errorCode: receipt.error?.code,
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const logs = new StructuredLogService(backend, realExec);
|
|
87
|
+
let server;
|
|
88
|
+
try {
|
|
89
|
+
server = await buildWebServer({
|
|
90
|
+
query, repository, logs, commands, creation, audit, events,
|
|
91
|
+
terminalUpgrade: terminalAvailable
|
|
92
|
+
? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
|
|
93
|
+
: undefined,
|
|
94
|
+
async session(roleId) {
|
|
95
|
+
const role = await repository.get(roleId);
|
|
96
|
+
if (!role)
|
|
97
|
+
throw new FleetError('role_not_found', `no such role '${roleId}'`);
|
|
98
|
+
if (role.configuredBackend === 'acp') {
|
|
99
|
+
const dir = repository.stateDir(role);
|
|
100
|
+
if (!dir)
|
|
101
|
+
throw new FleetError('control_unavailable', 'role state directory is unavailable');
|
|
102
|
+
return new AcpRoleSessionAdapter(dir);
|
|
103
|
+
}
|
|
104
|
+
if (role.configuredBackend === 'tmux' || role.detectedBackend === 'tmux')
|
|
105
|
+
return new TmuxRoleSessionAdapter(roleId, tmux);
|
|
106
|
+
throw new FleetError('capability_unavailable', 'role session backend is unavailable');
|
|
107
|
+
},
|
|
108
|
+
}, { origin: `http://127.0.0.1:${requestedPort}`, host: `127.0.0.1:${requestedPort}` }, { auth });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
lock.release();
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
let address;
|
|
115
|
+
try {
|
|
116
|
+
address = await server.app.listen({ host: '127.0.0.1', port: requestedPort });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
await server.close();
|
|
120
|
+
lock.release();
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
const actual = new URL(address);
|
|
124
|
+
if (actual.hostname !== '127.0.0.1') {
|
|
125
|
+
await server.close();
|
|
126
|
+
lock.release();
|
|
127
|
+
throw new FleetError('forbidden', 'web console refused a non-loopback bind');
|
|
128
|
+
}
|
|
129
|
+
const host = `127.0.0.1:${actual.port}`;
|
|
130
|
+
server.auth.setBoundary(`http://${host}`, host);
|
|
131
|
+
let control;
|
|
132
|
+
try {
|
|
133
|
+
control = await startWebControlServer({
|
|
134
|
+
dir: webDir,
|
|
135
|
+
onOpen() {
|
|
136
|
+
const url = `http://${host}/#bootstrap=${server.auth.mintBootstrap()}`;
|
|
137
|
+
openBrowser(url);
|
|
138
|
+
},
|
|
139
|
+
onRevokeAll() { server.auth.revokeAllTrustedDevices(); },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
await server.close();
|
|
144
|
+
lock.release();
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
if (options.open !== false)
|
|
148
|
+
openBrowser(`http://${host}/#bootstrap=${server.auth.bootstrapSecret}`);
|
|
149
|
+
return {
|
|
150
|
+
...server, address: `http://${host}`,
|
|
151
|
+
async close() {
|
|
152
|
+
try {
|
|
153
|
+
await control.close();
|
|
154
|
+
await terminals.close();
|
|
155
|
+
await server.close();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
lock.release();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function openBrowser(url) {
|
|
164
|
+
const command = process.platform === 'darwin' ? 'open'
|
|
165
|
+
: process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
166
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
167
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, shell: false });
|
|
168
|
+
child.on('error', () => undefined);
|
|
169
|
+
child.unref();
|
|
170
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type FastifyInstance, type FastifyRequest } from 'fastify';
|
|
2
|
+
import type { WebSocket } from 'ws';
|
|
3
|
+
import type { FleetQueryService } from '../application/fleet-query-service.js';
|
|
4
|
+
import type { RoleRepository } from '../application/role-repository.js';
|
|
5
|
+
import type { RoleSessionControl } from '../application/session-control.js';
|
|
6
|
+
import type { StructuredLogService } from '../application/log-service.js';
|
|
7
|
+
import type { RoleCommandService } from '../application/role-command-service.js';
|
|
8
|
+
import type { RoleCreationService } from '../application/role-creation-service.js';
|
|
9
|
+
import { AuditSink } from './audit.js';
|
|
10
|
+
import { WebAuth } from './auth.js';
|
|
11
|
+
import { FleetEventBus } from './events.js';
|
|
12
|
+
export interface WebServices {
|
|
13
|
+
query: FleetQueryService;
|
|
14
|
+
repository: RoleRepository;
|
|
15
|
+
session(roleId: string): Promise<RoleSessionControl>;
|
|
16
|
+
logs: StructuredLogService;
|
|
17
|
+
commands: RoleCommandService;
|
|
18
|
+
creation: RoleCreationService;
|
|
19
|
+
audit?: AuditSink;
|
|
20
|
+
events?: FleetEventBus;
|
|
21
|
+
terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface WebServer {
|
|
24
|
+
app: FastifyInstance;
|
|
25
|
+
auth: WebAuth;
|
|
26
|
+
audit: AuditSink;
|
|
27
|
+
events: FleetEventBus;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare function buildWebServer(services: WebServices, boundary: {
|
|
31
|
+
origin: string;
|
|
32
|
+
host: string;
|
|
33
|
+
}, options?: {
|
|
34
|
+
auth?: WebAuth;
|
|
35
|
+
}): Promise<WebServer>;
|
|
@@ -0,0 +1,261 @@
|
|
|
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/events', { websocket: true }, (socket, request) => {
|
|
190
|
+
requireSubprotocol(request, 'ours-fleet-events.v1');
|
|
191
|
+
authorizeSocket(socket, request, async (hello) => {
|
|
192
|
+
const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'events');
|
|
193
|
+
auth.bindSocket(session.id, socket);
|
|
194
|
+
const detach = events.attach(socket, typeof hello.lastEventId === 'string' ? hello.lastEventId : undefined);
|
|
195
|
+
socket.on('close', detach);
|
|
196
|
+
socket.send(JSON.stringify({ kind: 'ready', at: new Date().toISOString() }));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
app.get('/api/v1/roles/:id/terminal', { websocket: true }, (socket, request) => {
|
|
200
|
+
requireSubprotocol(request, 'ours-fleet-terminal.v1');
|
|
201
|
+
authorizeSocket(socket, request, async (hello) => {
|
|
202
|
+
const ticket = String(hello.ticket ?? '');
|
|
203
|
+
const session = auth.consumeTicket(request, ticket, 'terminal', request.params.id);
|
|
204
|
+
auth.bindSocket(session.id, socket);
|
|
205
|
+
if (!services.terminalUpgrade)
|
|
206
|
+
throw new FleetError('capability_unavailable', 'terminal PTY support is unavailable');
|
|
207
|
+
await services.terminalUpgrade(socket, request, request.params.id, ticket, hello);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
const staticRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
|
|
211
|
+
if (existsSync(staticRoot)) {
|
|
212
|
+
await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
|
|
213
|
+
app.setNotFoundHandler((request, reply) => {
|
|
214
|
+
if (request.url.startsWith('/api/'))
|
|
215
|
+
return reply.code(404).send({ error: 'not found' });
|
|
216
|
+
return reply.sendFile('index.html');
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
app, auth, audit, events,
|
|
221
|
+
async close() {
|
|
222
|
+
auth.shutdown();
|
|
223
|
+
events.close();
|
|
224
|
+
await app.close();
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function setAuthCookies(reply, session, device) {
|
|
229
|
+
reply.header('Set-Cookie', [
|
|
230
|
+
`ofs_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=28800`,
|
|
231
|
+
`ofs_device=${encodeURIComponent(device)}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=2592000`,
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
function clearAuthCookies(reply) {
|
|
235
|
+
reply.header('Set-Cookie', [
|
|
236
|
+
'ofs_session=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
|
|
237
|
+
'ofs_device=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
|
|
238
|
+
]);
|
|
239
|
+
}
|
|
240
|
+
function cryptoRandomId() { return randomBytes(12).toString('hex'); }
|
|
241
|
+
function requireSubprotocol(request, expected) {
|
|
242
|
+
const protocols = String(request.headers['sec-websocket-protocol'] ?? '')
|
|
243
|
+
.split(',').map(value => value.trim());
|
|
244
|
+
if (!protocols.includes(expected))
|
|
245
|
+
throw new FleetError('forbidden', `required subprotocol ${expected}`);
|
|
246
|
+
}
|
|
247
|
+
function authorizeSocket(socket, request, authorize) {
|
|
248
|
+
const timer = setTimeout(() => socket.close(4401, 'authorization timeout'), 5_000);
|
|
249
|
+
socket.once('message', data => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
void (async () => {
|
|
252
|
+
try {
|
|
253
|
+
const hello = JSON.parse(data.toString());
|
|
254
|
+
await authorize(hello);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
socket.close(4403, normalizeError(error).message.slice(0, 120));
|
|
258
|
+
}
|
|
259
|
+
})();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -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 {};
|