@checkstack/satellite-backend 0.2.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/CHANGELOG.md +44 -0
- package/drizzle/0000_melted_gargoyle.sql +10 -0
- package/drizzle/meta/0000_snapshot.json +83 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +35 -0
- package/src/config-relay.ts +31 -0
- package/src/heartbeat-monitor.test.ts +175 -0
- package/src/heartbeat-monitor.ts +70 -0
- package/src/hooks.ts +17 -0
- package/src/index.ts +159 -0
- package/src/router.ts +83 -0
- package/src/satellite-ws-handler.test.ts +265 -0
- package/src/satellite-ws-handler.ts +222 -0
- package/src/schema.ts +27 -0
- package/src/service.test.ts +292 -0
- package/src/service.ts +171 -0
- package/tsconfig.json +6 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { implement } from "@orpc/server";
|
|
2
|
+
import {
|
|
3
|
+
satelliteContract,
|
|
4
|
+
SATELLITE_STATUS_CHANGED,
|
|
5
|
+
SATELLITE_CONFIG_CHANGED,
|
|
6
|
+
} from "@checkstack/satellite-common";
|
|
7
|
+
import {
|
|
8
|
+
autoAuthMiddleware,
|
|
9
|
+
type RpcContext,
|
|
10
|
+
type Logger,
|
|
11
|
+
} from "@checkstack/backend-api";
|
|
12
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
13
|
+
import type { SatelliteService } from "./service";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* RPC router for satellite management.
|
|
17
|
+
* Implements the satelliteContract from satellite-common using oRPC's
|
|
18
|
+
* implement pattern with autoAuthMiddleware.
|
|
19
|
+
*/
|
|
20
|
+
export function createSatelliteRouter(props: {
|
|
21
|
+
service: SatelliteService;
|
|
22
|
+
signalService: SignalService;
|
|
23
|
+
logger: Logger;
|
|
24
|
+
}) {
|
|
25
|
+
const { service, signalService, logger } = props;
|
|
26
|
+
|
|
27
|
+
const os = implement(satelliteContract)
|
|
28
|
+
.$context<RpcContext>()
|
|
29
|
+
.use(autoAuthMiddleware);
|
|
30
|
+
|
|
31
|
+
return os.router({
|
|
32
|
+
listSatellites: os.listSatellites.handler(async () => {
|
|
33
|
+
const satellites = await service.listSatellites();
|
|
34
|
+
return { satellites };
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
getSatellite: os.getSatellite.handler(async ({ input }) => {
|
|
38
|
+
const satellite = await service.getSatellite(input.id);
|
|
39
|
+
// eslint-disable-next-line unicorn/no-null -- oRPC contract uses nullable()
|
|
40
|
+
return satellite ?? null;
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
createSatellite: os.createSatellite.handler(async ({ input }) => {
|
|
44
|
+
const { satellite, plaintextToken } =
|
|
45
|
+
await service.createSatellite(input);
|
|
46
|
+
|
|
47
|
+
logger.info(
|
|
48
|
+
`Satellite created: ${satellite.name} (${satellite.region})`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Signal config change so UI refreshes
|
|
52
|
+
await signalService.broadcast(SATELLITE_CONFIG_CHANGED, {
|
|
53
|
+
satelliteId: satellite.id,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return { satellite, token: plaintextToken };
|
|
57
|
+
}),
|
|
58
|
+
|
|
59
|
+
deleteSatellite: os.deleteSatellite.handler(async ({ input }) => {
|
|
60
|
+
const satellite = await service.getSatellite(input.id);
|
|
61
|
+
if (!satellite) return;
|
|
62
|
+
|
|
63
|
+
await service.deleteSatellite(input.id);
|
|
64
|
+
|
|
65
|
+
logger.info(
|
|
66
|
+
`Satellite deleted: ${satellite.name} (${satellite.region})`,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Signal status change (satellite gone)
|
|
70
|
+
await signalService.broadcast(SATELLITE_STATUS_CHANGED, {
|
|
71
|
+
satelliteId: input.id,
|
|
72
|
+
status: "offline",
|
|
73
|
+
name: satellite.name,
|
|
74
|
+
region: satellite.region,
|
|
75
|
+
});
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
getOnlineSatelliteIds: os.getOnlineSatelliteIds.handler(async () => {
|
|
79
|
+
const satelliteIds = await service.getOnlineSatelliteIds();
|
|
80
|
+
return { satelliteIds };
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
SatelliteWsHandler,
|
|
4
|
+
type SatelliteResultHandler,
|
|
5
|
+
} from "./satellite-ws-handler";
|
|
6
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
7
|
+
import type { SatelliteService } from "./service";
|
|
8
|
+
import type { ConfigRelay } from "./config-relay";
|
|
9
|
+
import type { SatelliteWithStatus } from "@checkstack/satellite-common";
|
|
10
|
+
|
|
11
|
+
const MOCK_SATELLITE: SatelliteWithStatus = {
|
|
12
|
+
id: "sat-1",
|
|
13
|
+
name: "EU West",
|
|
14
|
+
region: "eu-west-1",
|
|
15
|
+
tags: {},
|
|
16
|
+
status: "online",
|
|
17
|
+
createdAt: new Date(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MOCK_ASSIGNMENTS = [
|
|
21
|
+
{
|
|
22
|
+
configId: "config-1",
|
|
23
|
+
systemId: "system-1",
|
|
24
|
+
strategyId: "http",
|
|
25
|
+
config: { url: "https://example.com" },
|
|
26
|
+
intervalSeconds: 60,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function createMockService(
|
|
31
|
+
validSatellite?: SatelliteWithStatus,
|
|
32
|
+
): SatelliteService {
|
|
33
|
+
return {
|
|
34
|
+
validateToken: mock(async (props: { clientId: string; token: string }) => {
|
|
35
|
+
if (
|
|
36
|
+
validSatellite &&
|
|
37
|
+
props.clientId === validSatellite.id &&
|
|
38
|
+
props.token === "csat_valid-token"
|
|
39
|
+
) {
|
|
40
|
+
return validSatellite;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}),
|
|
44
|
+
updateHeartbeat: mock(async () => {}),
|
|
45
|
+
} as unknown as SatelliteService;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createMockConfigRelay(): ConfigRelay {
|
|
49
|
+
return {
|
|
50
|
+
getAssignmentsForSatellite: mock(async () => MOCK_ASSIGNMENTS),
|
|
51
|
+
} as unknown as ConfigRelay;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createMockResultHandler(): SatelliteResultHandler {
|
|
55
|
+
return {
|
|
56
|
+
handleResult: mock(async () => {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createMockWs() {
|
|
61
|
+
const messages: string[] = [];
|
|
62
|
+
return {
|
|
63
|
+
send: mock((data: string) => messages.push(data)),
|
|
64
|
+
close: mock(() => {}),
|
|
65
|
+
messages,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("SatelliteWsHandler", () => {
|
|
70
|
+
let handler: SatelliteWsHandler;
|
|
71
|
+
let service: ReturnType<typeof createMockService>;
|
|
72
|
+
let configRelay: ReturnType<typeof createMockConfigRelay>;
|
|
73
|
+
let resultHandler: SatelliteResultHandler;
|
|
74
|
+
let logger: ReturnType<typeof createMockLogger>;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
service = createMockService(MOCK_SATELLITE);
|
|
78
|
+
configRelay = createMockConfigRelay();
|
|
79
|
+
resultHandler = createMockResultHandler();
|
|
80
|
+
logger = createMockLogger();
|
|
81
|
+
handler = new SatelliteWsHandler(
|
|
82
|
+
service,
|
|
83
|
+
configRelay,
|
|
84
|
+
resultHandler,
|
|
85
|
+
logger,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("authentication", () => {
|
|
90
|
+
it("should authenticate with valid clientId and token", async () => {
|
|
91
|
+
const ws = createMockWs();
|
|
92
|
+
const { onMessage } = handler.onConnection(ws);
|
|
93
|
+
|
|
94
|
+
await onMessage(
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
type: "authenticate",
|
|
97
|
+
clientId: "sat-1",
|
|
98
|
+
token: "csat_valid-token",
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(ws.close).not.toHaveBeenCalled();
|
|
103
|
+
expect(ws.messages).toHaveLength(1);
|
|
104
|
+
|
|
105
|
+
const response = JSON.parse(ws.messages[0]);
|
|
106
|
+
expect(response.type).toBe("authenticated");
|
|
107
|
+
expect(response.satelliteId).toBe("sat-1");
|
|
108
|
+
expect(response.assignments).toEqual(MOCK_ASSIGNMENTS);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should reject invalid credentials", async () => {
|
|
112
|
+
const ws = createMockWs();
|
|
113
|
+
const { onMessage } = handler.onConnection(ws);
|
|
114
|
+
|
|
115
|
+
await onMessage(
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
type: "authenticate",
|
|
118
|
+
clientId: "sat-1",
|
|
119
|
+
token: "csat_invalid-token",
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(ws.close).toHaveBeenCalled();
|
|
124
|
+
expect(ws.messages).toHaveLength(1);
|
|
125
|
+
const response = JSON.parse(ws.messages[0]);
|
|
126
|
+
expect(response.type).toBe("auth_failed");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should reject non-authenticate messages before auth", async () => {
|
|
130
|
+
const ws = createMockWs();
|
|
131
|
+
const { onMessage } = handler.onConnection(ws);
|
|
132
|
+
|
|
133
|
+
await onMessage(
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
type: "heartbeat",
|
|
136
|
+
version: "1.0.0",
|
|
137
|
+
uptimeSeconds: 60,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(ws.close).toHaveBeenCalled();
|
|
142
|
+
const response = JSON.parse(ws.messages[0]);
|
|
143
|
+
expect(response.type).toBe("auth_failed");
|
|
144
|
+
expect(response.reason).toBe("Must authenticate first");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("post-authentication", () => {
|
|
149
|
+
async function authenticateWs() {
|
|
150
|
+
const ws = createMockWs();
|
|
151
|
+
const { onMessage, onClose } = handler.onConnection(ws);
|
|
152
|
+
await onMessage(
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
type: "authenticate",
|
|
155
|
+
clientId: "sat-1",
|
|
156
|
+
token: "csat_valid-token",
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
// Clear the authenticated message from history
|
|
160
|
+
ws.messages.length = 0;
|
|
161
|
+
return { ws, onMessage, onClose };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it("should handle heartbeat messages", async () => {
|
|
165
|
+
const { onMessage } = await authenticateWs();
|
|
166
|
+
|
|
167
|
+
await onMessage(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
type: "heartbeat",
|
|
170
|
+
version: "1.2.3",
|
|
171
|
+
uptimeSeconds: 120,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(service.updateHeartbeat).toHaveBeenCalledWith("sat-1", {
|
|
176
|
+
version: "1.2.3",
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should handle result messages", async () => {
|
|
181
|
+
const { onMessage } = await authenticateWs();
|
|
182
|
+
|
|
183
|
+
const resultMsg = {
|
|
184
|
+
type: "result",
|
|
185
|
+
configId: "config-1",
|
|
186
|
+
systemId: "system-1",
|
|
187
|
+
status: "healthy",
|
|
188
|
+
latencyMs: 42,
|
|
189
|
+
result: {
|
|
190
|
+
status: "healthy",
|
|
191
|
+
latencyMs: 42,
|
|
192
|
+
metadata: {
|
|
193
|
+
connected: true,
|
|
194
|
+
connectionTimeMs: 40,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
executedAt: new Date().toISOString(),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
await onMessage(JSON.stringify(resultMsg));
|
|
201
|
+
|
|
202
|
+
expect(resultHandler.handleResult).toHaveBeenCalledWith({
|
|
203
|
+
satelliteId: "sat-1",
|
|
204
|
+
sourceLabel: "EU West (eu-west-1)",
|
|
205
|
+
result: expect.objectContaining({
|
|
206
|
+
type: "result",
|
|
207
|
+
configId: "config-1",
|
|
208
|
+
systemId: "system-1",
|
|
209
|
+
status: "healthy",
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should log strategy errors", async () => {
|
|
215
|
+
const { onMessage } = await authenticateWs();
|
|
216
|
+
|
|
217
|
+
await onMessage(
|
|
218
|
+
JSON.stringify({
|
|
219
|
+
type: "strategy_error",
|
|
220
|
+
strategyId: "grpc",
|
|
221
|
+
message: "Strategy not available",
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should clean up connection on close", async () => {
|
|
229
|
+
const { onClose } = await authenticateWs();
|
|
230
|
+
onClose();
|
|
231
|
+
|
|
232
|
+
expect(handler.getConnectedSatelliteIds()).not.toContain("sat-1");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("pushConfigUpdate", () => {
|
|
237
|
+
it("should send config update to connected satellites", async () => {
|
|
238
|
+
const ws = createMockWs();
|
|
239
|
+
const { onMessage } = handler.onConnection(ws);
|
|
240
|
+
|
|
241
|
+
// Authenticate
|
|
242
|
+
await onMessage(
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
type: "authenticate",
|
|
245
|
+
clientId: "sat-1",
|
|
246
|
+
token: "csat_valid-token",
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
ws.messages.length = 0;
|
|
250
|
+
|
|
251
|
+
// Push config update
|
|
252
|
+
await handler.pushConfigUpdate("sat-1");
|
|
253
|
+
|
|
254
|
+
expect(ws.messages).toHaveLength(1);
|
|
255
|
+
const update = JSON.parse(ws.messages[0]);
|
|
256
|
+
expect(update.type).toBe("config_updated");
|
|
257
|
+
expect(update.assignments).toEqual(MOCK_ASSIGNMENTS);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("should silently skip disconnected satellites", async () => {
|
|
261
|
+
// No satellite connected — should not throw
|
|
262
|
+
await handler.pushConfigUpdate("non-existent");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
2
|
+
import type {
|
|
3
|
+
WebSocketRouteHandler,
|
|
4
|
+
WsConnection,
|
|
5
|
+
WsConnectionHandlers,
|
|
6
|
+
} from "@checkstack/backend-api";
|
|
7
|
+
import type { SatelliteService } from "./service";
|
|
8
|
+
import type { ConfigRelay } from "./config-relay";
|
|
9
|
+
import {
|
|
10
|
+
SatelliteToCoreMessageSchema,
|
|
11
|
+
type CoreToSatelliteMessage,
|
|
12
|
+
type ResultMessage,
|
|
13
|
+
type SatelliteWithStatus,
|
|
14
|
+
} from "@checkstack/satellite-common";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Callback for handling health check results received from satellites.
|
|
18
|
+
*/
|
|
19
|
+
export interface SatelliteResultHandler {
|
|
20
|
+
handleResult(props: {
|
|
21
|
+
satelliteId: string;
|
|
22
|
+
sourceLabel: string;
|
|
23
|
+
result: ResultMessage;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Active satellite connection tracking.
|
|
29
|
+
*/
|
|
30
|
+
interface SatelliteConnection {
|
|
31
|
+
satellite: SatelliteWithStatus;
|
|
32
|
+
ws: WsConnection;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* WebSocket handler for satellite connections.
|
|
37
|
+
* Manages authentication, heartbeats, result ingestion, and config pushes.
|
|
38
|
+
*/
|
|
39
|
+
export class SatelliteWsHandler implements WebSocketRouteHandler {
|
|
40
|
+
/** Map of satelliteId → active WebSocket connection */
|
|
41
|
+
private connections = new Map<string, SatelliteConnection>();
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
private service: SatelliteService,
|
|
45
|
+
private configRelay: ConfigRelay,
|
|
46
|
+
private resultHandler: SatelliteResultHandler,
|
|
47
|
+
private logger: Logger,
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle a new WebSocket connection (pre-authentication).
|
|
52
|
+
* The satellite must send an `authenticate` message as its first message.
|
|
53
|
+
* Implements WebSocketRouteHandler.onConnection.
|
|
54
|
+
*/
|
|
55
|
+
onConnection(ws: WsConnection): WsConnectionHandlers {
|
|
56
|
+
let authenticatedSatellite: SatelliteWithStatus | undefined;
|
|
57
|
+
|
|
58
|
+
const onMessage = async (message: string) => {
|
|
59
|
+
let parsed: ReturnType<typeof SatelliteToCoreMessageSchema.parse>;
|
|
60
|
+
try {
|
|
61
|
+
parsed = SatelliteToCoreMessageSchema.parse(JSON.parse(message));
|
|
62
|
+
} catch {
|
|
63
|
+
this.logger.warn("Invalid satellite message received");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Pre-authentication: only accept `authenticate`
|
|
68
|
+
if (!authenticatedSatellite) {
|
|
69
|
+
if (parsed.type !== "authenticate") {
|
|
70
|
+
this.sendMessage(ws, {
|
|
71
|
+
type: "auth_failed",
|
|
72
|
+
reason: "Must authenticate first",
|
|
73
|
+
});
|
|
74
|
+
ws.close();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const satellite = await this.service.validateToken({
|
|
79
|
+
clientId: parsed.clientId,
|
|
80
|
+
token: parsed.token,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!satellite) {
|
|
84
|
+
this.sendMessage(ws, {
|
|
85
|
+
type: "auth_failed",
|
|
86
|
+
reason: "Invalid client ID or token",
|
|
87
|
+
});
|
|
88
|
+
ws.close();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
authenticatedSatellite = satellite;
|
|
93
|
+
|
|
94
|
+
// Track connection
|
|
95
|
+
this.connections.set(satellite.id, { satellite, ws });
|
|
96
|
+
|
|
97
|
+
// Update heartbeat on connect
|
|
98
|
+
await this.service.updateHeartbeat(satellite.id, {});
|
|
99
|
+
|
|
100
|
+
// Send authenticated response with full config
|
|
101
|
+
const assignments =
|
|
102
|
+
await this.configRelay.getAssignmentsForSatellite(satellite.id);
|
|
103
|
+
|
|
104
|
+
this.sendMessage(ws, {
|
|
105
|
+
type: "authenticated",
|
|
106
|
+
satelliteId: satellite.id,
|
|
107
|
+
assignments,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.logger.info(
|
|
111
|
+
`Satellite authenticated: ${satellite.name} (${satellite.region})`,
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Post-authentication: handle all message types
|
|
117
|
+
switch (parsed.type) {
|
|
118
|
+
case "heartbeat": {
|
|
119
|
+
await this.service.updateHeartbeat(authenticatedSatellite.id, {
|
|
120
|
+
version: parsed.version,
|
|
121
|
+
});
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case "result": {
|
|
125
|
+
await this.resultHandler.handleResult({
|
|
126
|
+
satelliteId: authenticatedSatellite.id,
|
|
127
|
+
sourceLabel: `${authenticatedSatellite.name} (${authenticatedSatellite.region})`,
|
|
128
|
+
result: parsed,
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "strategy_error": {
|
|
133
|
+
this.logger.warn(
|
|
134
|
+
`Satellite ${authenticatedSatellite.name} reports strategy error: ${parsed.strategyId} - ${parsed.message}`,
|
|
135
|
+
);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case "authenticate": {
|
|
139
|
+
// Already authenticated, ignore duplicate auth attempts
|
|
140
|
+
this.logger.debug(
|
|
141
|
+
`Satellite ${authenticatedSatellite.name} sent duplicate authenticate`,
|
|
142
|
+
);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onClose = () => {
|
|
149
|
+
if (authenticatedSatellite) {
|
|
150
|
+
this.connections.delete(authenticatedSatellite.id);
|
|
151
|
+
this.logger.info(
|
|
152
|
+
`Satellite disconnected: ${authenticatedSatellite.name} (${authenticatedSatellite.region})`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return { onMessage, onClose };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Push a config update to a specific satellite.
|
|
162
|
+
*/
|
|
163
|
+
async pushConfigUpdate(satelliteId: string): Promise<void> {
|
|
164
|
+
const conn = this.connections.get(satelliteId);
|
|
165
|
+
if (!conn) return;
|
|
166
|
+
|
|
167
|
+
const assignments =
|
|
168
|
+
await this.configRelay.getAssignmentsForSatellite(satelliteId);
|
|
169
|
+
|
|
170
|
+
this.sendMessage(conn.ws, {
|
|
171
|
+
type: "config_updated",
|
|
172
|
+
assignments,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.logger.debug(
|
|
176
|
+
`Pushed config update to satellite ${conn.satellite.name}: ${assignments.length} assignments`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Send a shutdown message to a specific satellite (e.g., on token revocation).
|
|
182
|
+
*/
|
|
183
|
+
sendShutdown(satelliteId: string, reason: string): void {
|
|
184
|
+
const conn = this.connections.get(satelliteId);
|
|
185
|
+
if (!conn) return;
|
|
186
|
+
|
|
187
|
+
this.sendMessage(conn.ws, { type: "shutdown", reason });
|
|
188
|
+
conn.ws.close();
|
|
189
|
+
this.connections.delete(satelliteId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Push config updates to ALL currently connected satellites.
|
|
194
|
+
* Called after association changes to ensure live satellites get updated assignments.
|
|
195
|
+
*/
|
|
196
|
+
async pushConfigUpdateToAll(): Promise<void> {
|
|
197
|
+
const connectedIds = this.getConnectedSatelliteIds();
|
|
198
|
+
if (connectedIds.length === 0) return;
|
|
199
|
+
|
|
200
|
+
this.logger.debug(
|
|
201
|
+
`Pushing config updates to ${connectedIds.length} connected satellite(s)`,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await Promise.all(
|
|
205
|
+
connectedIds.map((id) => this.pushConfigUpdate(id)),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get the IDs of all currently connected satellites.
|
|
211
|
+
*/
|
|
212
|
+
getConnectedSatelliteIds(): string[] {
|
|
213
|
+
return [...this.connections.keys()];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private sendMessage(
|
|
217
|
+
ws: WsConnection,
|
|
218
|
+
message: CoreToSatelliteMessage,
|
|
219
|
+
): void {
|
|
220
|
+
ws.send(JSON.stringify(message));
|
|
221
|
+
}
|
|
222
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
text,
|
|
4
|
+
jsonb,
|
|
5
|
+
uuid,
|
|
6
|
+
timestamp,
|
|
7
|
+
} from "drizzle-orm/pg-core";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Satellites table — each record represents a registered satellite node.
|
|
11
|
+
* tokenHash stores a bcrypt hash of the pre-shared API token.
|
|
12
|
+
* The satellite's UUID (id) serves as the clientId for authentication.
|
|
13
|
+
*/
|
|
14
|
+
export const satellites = pgTable("satellites", {
|
|
15
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
16
|
+
name: text("name").notNull(),
|
|
17
|
+
region: text("region").notNull(),
|
|
18
|
+
/** Key-value tags for flexible grouping and filtering */
|
|
19
|
+
tags: jsonb("tags").$type<Record<string, string>>().default({}).notNull(),
|
|
20
|
+
/** Bcrypt hash of the satellite's API token */
|
|
21
|
+
tokenHash: text("token_hash").notNull(),
|
|
22
|
+
/** Last heartbeat timestamp — null means never connected */
|
|
23
|
+
lastHeartbeatAt: timestamp("last_heartbeat_at"),
|
|
24
|
+
/** Satellite version reported on connect/heartbeat */
|
|
25
|
+
version: text("version"),
|
|
26
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
27
|
+
});
|