@babylonjs-toolkit/server 0.0.0-stage → 9.35.1
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/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- package/templates/init/render.yaml +19 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// The alert engine (game-server-admin-console D24, DR §7.9, §8 rule table): built-in rules with stored settings, their
|
|
2
|
+
// evaluation on every fleet leader tick, delivery to the rules' channels, and the alert history with acknowledge and
|
|
3
|
+
// silence. Only the leader evaluates, so each alert fires once per fleet. Alerts live in the "alertHistory" stream as
|
|
4
|
+
// snapshots; the newest snapshot of an alertId is the alert's current state.
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { getLogger } from "../GameServerLogger.js";
|
|
7
|
+
import { AdminRequestError } from "./AdminApiRouter.js";
|
|
8
|
+
import { ADMIN_PAGE_SIZE, NEW_ADMIN_ADDRESS_LOOKBACK_DAYS } from "./AdminConstants.js";
|
|
9
|
+
import { AdminConflictError, RECORD_QUERY_MAX_LIMIT } from "./AdminStore.js";
|
|
10
|
+
import { AlertChannels, alertChannelView } from "./AlertChannels.js";
|
|
11
|
+
import { metricsStreamOf } from "./MetricsSampler.js";
|
|
12
|
+
/** HTTP statuses of the engine's refusals. */
|
|
13
|
+
const HTTP_BAD_REQUEST = 400;
|
|
14
|
+
const HTTP_NOT_FOUND = 404;
|
|
15
|
+
/** Milliseconds per second, minute and day. */
|
|
16
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
17
|
+
const MILLISECONDS_PER_MINUTE = 60000;
|
|
18
|
+
const MILLISECONDS_PER_DAY = 86400000;
|
|
19
|
+
/** Seconds per minute, for per-minute rates. */
|
|
20
|
+
const SECONDS_PER_MINUTE = 60;
|
|
21
|
+
/** Bytes per megabyte (the memoryHigh threshold unit). */
|
|
22
|
+
const BYTES_PER_MEGABYTE = 1048576;
|
|
23
|
+
/** Percent of a whole. */
|
|
24
|
+
const FULL_PERCENT = 100;
|
|
25
|
+
/** The readiness reason of an instance whose Redis does not answer. */
|
|
26
|
+
const REDIS_UNREACHABLE_REASON = "redis unreachable";
|
|
27
|
+
/** The audit actions the event rules read. */
|
|
28
|
+
const SECOND_FACTOR_ACTION = "auth.secondFactor";
|
|
29
|
+
const LOCKOUT_ACTION = "auth.lockout";
|
|
30
|
+
/** Rules that fire once per event (an audit record) and are stored resolved at once, so no resolve is sent. */
|
|
31
|
+
const EVENT_RULES = new Set(["newAdminAddress", "accountLockout"]);
|
|
32
|
+
/** The rule id a channel test alert carries. */
|
|
33
|
+
const TEST_ALERT_RULE_ID = "instanceLost";
|
|
34
|
+
/** Alert channel kinds. */
|
|
35
|
+
const ALERT_CHANNEL_KINDS = new Set(["webhook", "slack", "discord", "teams", "pagerduty"]);
|
|
36
|
+
/** Alert severities. */
|
|
37
|
+
const ALERT_SEVERITIES = new Set(["info", "warning", "critical"]);
|
|
38
|
+
/** The built-in rules (DR §8). Every rule starts enabled. */
|
|
39
|
+
export const ALERT_RULE_DEFINITIONS = {
|
|
40
|
+
instanceLost: { label: "An instance is lost", threshold: null, thresholdUnit: null, forMinutes: 0, severity: "critical" },
|
|
41
|
+
instanceNotReady: { label: "An instance is not ready", threshold: null, thresholdUnit: null, forMinutes: 5, severity: "warning" },
|
|
42
|
+
redisUnreachable: { label: "Redis is unreachable", threshold: null, thresholdUnit: null, forMinutes: 0, severity: "critical" },
|
|
43
|
+
refusalSpike: { label: "Join refusals spike", threshold: 60, thresholdUnit: "refusals per minute", forMinutes: 5, severity: "warning" },
|
|
44
|
+
authFailureSpike: { label: "Player auth failures spike", threshold: 30, thresholdUnit: "failures per minute", forMinutes: 5, severity: "warning" },
|
|
45
|
+
eventLoopLag: { label: "Event loop lag", threshold: 200, thresholdUnit: "milliseconds", forMinutes: 5, severity: "warning" },
|
|
46
|
+
memoryHigh: { label: "Memory use is high", threshold: 1536, thresholdUnit: "MB", forMinutes: 5, severity: "warning" },
|
|
47
|
+
dedicatedOverruns: { label: "Dedicated tick overruns", threshold: 30, thresholdUnit: "overruns per minute", forMinutes: 5, severity: "warning" },
|
|
48
|
+
certificateExpiring: { label: "Certificate expiring", threshold: 14, thresholdUnit: "days", forMinutes: 0, severity: "warning" },
|
|
49
|
+
versionMismatch: { label: "Instances run different versions", threshold: null, thresholdUnit: null, forMinutes: 30, severity: "warning" },
|
|
50
|
+
nearLimits: { label: "Near the session limit", threshold: 90, thresholdUnit: "% of limits.maxSessions", forMinutes: 5, severity: "warning" },
|
|
51
|
+
newAdminAddress: { label: "Admin sign-in from a new address", threshold: null, thresholdUnit: null, forMinutes: 0, severity: "info" },
|
|
52
|
+
accountLockout: { label: "Admin account locked", threshold: null, thresholdUnit: null, forMinutes: 0, severity: "warning" }
|
|
53
|
+
};
|
|
54
|
+
/** Every rule id, in the table's order. */
|
|
55
|
+
const ALERT_RULE_IDS = Object.keys(ALERT_RULE_DEFINITIONS);
|
|
56
|
+
/** The rules, channels and alert history of the fleet. */
|
|
57
|
+
export class AlertEngine {
|
|
58
|
+
/** The shared core services. */
|
|
59
|
+
services;
|
|
60
|
+
/** The fleet directory (heartbeats). */
|
|
61
|
+
fleet;
|
|
62
|
+
/** The activity feed's server events. */
|
|
63
|
+
serverEvents;
|
|
64
|
+
/** This instance's metrics history (dedicated overruns without Redis). */
|
|
65
|
+
metricsSampler;
|
|
66
|
+
/** The channel deliverer. */
|
|
67
|
+
channels = new AlertChannels();
|
|
68
|
+
/** When each rule started breaching on this leader, in milliseconds. */
|
|
69
|
+
breachingSince = new Map();
|
|
70
|
+
/** Listeners of fired alerts. */
|
|
71
|
+
firedListeners = [];
|
|
72
|
+
/** The newest audit sequence already read, or null before the first evaluation. */
|
|
73
|
+
lastAuditSequence = null;
|
|
74
|
+
/** True once the default rules were written to the store. */
|
|
75
|
+
hasSeededRules = false;
|
|
76
|
+
/**
|
|
77
|
+
* Creates the engine; nothing runs until the leader calls evaluate().
|
|
78
|
+
* @param services The shared core services.
|
|
79
|
+
* @param fleet The fleet directory.
|
|
80
|
+
* @param serverEvents The activity feed's server events.
|
|
81
|
+
* @param metricsSampler This instance's metrics history.
|
|
82
|
+
*/
|
|
83
|
+
constructor(services, fleet, serverEvents, metricsSampler) {
|
|
84
|
+
this.services = services;
|
|
85
|
+
this.fleet = fleet;
|
|
86
|
+
this.serverEvents = serverEvents;
|
|
87
|
+
this.metricsSampler = metricsSampler;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Calls back whenever an alert fires.
|
|
91
|
+
* @param listener The listener.
|
|
92
|
+
*/
|
|
93
|
+
onAlertFired(listener) {
|
|
94
|
+
this.firedListeners.push(listener);
|
|
95
|
+
}
|
|
96
|
+
/** One leader evaluation (DR §7.9): every enabled, unsilenced rule is checked, fired or resolved. */
|
|
97
|
+
async evaluate() {
|
|
98
|
+
await this.seedRulesOnce();
|
|
99
|
+
const context = await this.buildContext();
|
|
100
|
+
const firing = await this.firingAlertsByRule();
|
|
101
|
+
for (const rule of await this.ruleRecords()) {
|
|
102
|
+
await this.evaluateRule(rule, context, firing.get(rule.ruleId) ?? null);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every rule with its settings (stored settings over the defaults).
|
|
107
|
+
* @returns The rules, in the table's order.
|
|
108
|
+
*/
|
|
109
|
+
async listRules() {
|
|
110
|
+
const stored = await this.storedRules();
|
|
111
|
+
return ALERT_RULE_IDS.map((ruleId) => ruleView(ruleId, stored.get(ruleId) ?? null));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Changes a rule's settings (PUT alerts/rules/:ruleId).
|
|
115
|
+
* @param ruleId The rule.
|
|
116
|
+
* @param request The new settings.
|
|
117
|
+
* @returns The rule.
|
|
118
|
+
* @throws AdminRequestError 404 for an unknown rule, 400 for an unknown channel or severity.
|
|
119
|
+
*/
|
|
120
|
+
async updateRule(ruleId, request) {
|
|
121
|
+
const current = await this.requireRule(ruleId);
|
|
122
|
+
if (!ALERT_SEVERITIES.has(request.severity))
|
|
123
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "severity must be info, warning or critical");
|
|
124
|
+
if (request.forMinutes < 0)
|
|
125
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "forMinutes may not be negative");
|
|
126
|
+
await this.requireChannels(request.channelIds);
|
|
127
|
+
const base = current?.value ?? defaultRuleRecord(ruleId);
|
|
128
|
+
const record = Object.assign({}, base, {
|
|
129
|
+
enabled: request.enabled,
|
|
130
|
+
threshold: request.threshold,
|
|
131
|
+
forMinutes: request.forMinutes,
|
|
132
|
+
severity: request.severity,
|
|
133
|
+
channelIds: request.channelIds.slice()
|
|
134
|
+
});
|
|
135
|
+
const stored = await this.services.store.putDocument("alertRules", ruleId, record, null);
|
|
136
|
+
return ruleView(record.ruleId, stored);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Silences a rule for some minutes (0 ends a silence).
|
|
140
|
+
* @param ruleId The rule.
|
|
141
|
+
* @param minutes How long.
|
|
142
|
+
* @returns The rule.
|
|
143
|
+
* @throws AdminRequestError 404 for an unknown rule, 400 for a negative duration.
|
|
144
|
+
*/
|
|
145
|
+
async silenceRule(ruleId, minutes) {
|
|
146
|
+
const current = await this.requireRule(ruleId);
|
|
147
|
+
if (minutes < 0)
|
|
148
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "minutes may not be negative");
|
|
149
|
+
const base = current?.value ?? defaultRuleRecord(ruleId);
|
|
150
|
+
const silencedUntil = (minutes > 0) ? new Date(this.services.clock.now() + minutes * MILLISECONDS_PER_MINUTE).toISOString() : null;
|
|
151
|
+
const record = Object.assign({}, base, { silencedUntil: silencedUntil });
|
|
152
|
+
const stored = await this.services.store.putDocument("alertRules", ruleId, record, null);
|
|
153
|
+
return ruleView(record.ruleId, stored);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Every channel, secrets masked.
|
|
157
|
+
* @returns The channels, oldest first.
|
|
158
|
+
*/
|
|
159
|
+
async listChannels() {
|
|
160
|
+
const records = await this.channelRecords();
|
|
161
|
+
return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt)).map(alertChannelView);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Adds a channel (POST alerts/channels).
|
|
165
|
+
* @param request The channel.
|
|
166
|
+
* @param createdBy Who adds it.
|
|
167
|
+
* @returns The channel, secrets masked.
|
|
168
|
+
* @throws AdminRequestError 400 for an unknown kind, a missing name, URL or routing key.
|
|
169
|
+
*/
|
|
170
|
+
async createChannel(request, createdBy) {
|
|
171
|
+
validateChannelRequest(request);
|
|
172
|
+
const record = {
|
|
173
|
+
channelId: randomUUID(),
|
|
174
|
+
kind: request.kind,
|
|
175
|
+
name: request.name,
|
|
176
|
+
url: (request.kind === "pagerduty") ? null : request.url,
|
|
177
|
+
routingKey: (request.kind === "pagerduty") ? request.routingKey : null,
|
|
178
|
+
createdBy: createdBy,
|
|
179
|
+
createdAt: new Date(this.services.clock.now()).toISOString()
|
|
180
|
+
};
|
|
181
|
+
await this.services.store.putDocument("alertChannels", record.channelId, record, 0);
|
|
182
|
+
return alertChannelView(record);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Removes a channel and takes it off every rule.
|
|
186
|
+
* @param channelId The channel.
|
|
187
|
+
* @throws AdminRequestError 404 for an unknown channel.
|
|
188
|
+
*/
|
|
189
|
+
async deleteChannel(channelId) {
|
|
190
|
+
if (!(await this.services.store.deleteDocument("alertChannels", channelId)))
|
|
191
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such alert channel");
|
|
192
|
+
for (const stored of (await this.storedRules()).values()) {
|
|
193
|
+
if (!stored.value.channelIds.includes(channelId))
|
|
194
|
+
continue;
|
|
195
|
+
const channelIds = stored.value.channelIds.filter((candidate) => candidate !== channelId);
|
|
196
|
+
await this.services.store.putDocument("alertRules", stored.id, Object.assign({}, stored.value, { channelIds: channelIds }), null);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Sends a test alert to a channel (POST alerts/channels/:channelId/test).
|
|
201
|
+
* @param channelId The channel.
|
|
202
|
+
* @returns Whether it was delivered.
|
|
203
|
+
* @throws AdminRequestError 404 for an unknown channel.
|
|
204
|
+
*/
|
|
205
|
+
async testChannel(channelId) {
|
|
206
|
+
const stored = await this.services.store.getDocument("alertChannels", channelId);
|
|
207
|
+
if (stored == null)
|
|
208
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such alert channel");
|
|
209
|
+
const now = new Date(this.services.clock.now()).toISOString();
|
|
210
|
+
const alert = {
|
|
211
|
+
alertId: "test-" + randomUUID(),
|
|
212
|
+
ruleId: TEST_ALERT_RULE_ID,
|
|
213
|
+
severity: "info",
|
|
214
|
+
summary: "Test alert from the Babylon Game Server console",
|
|
215
|
+
processId: this.services.processId(),
|
|
216
|
+
state: "firing",
|
|
217
|
+
firedAt: now,
|
|
218
|
+
resolvedAt: null,
|
|
219
|
+
acknowledgedBy: null,
|
|
220
|
+
acknowledgedAt: null,
|
|
221
|
+
deliveries: []
|
|
222
|
+
};
|
|
223
|
+
const outcome = await this.channels.deliver(stored.value, alert, "firing", this.services.pagerDutyUrl);
|
|
224
|
+
return { delivered: outcome.ok, error: outcome.error };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* One page of the alert history, newest first.
|
|
228
|
+
* @param state Only "firing" or "resolved" alerts, or null for both.
|
|
229
|
+
* @param page The page, from 1.
|
|
230
|
+
* @returns The page and the number of matches.
|
|
231
|
+
*/
|
|
232
|
+
async listAlerts(state, page) {
|
|
233
|
+
const matching = (await this.currentAlerts()).filter((alert) => state == null || alert.state === state);
|
|
234
|
+
const firstIndex = (Math.max(1, Math.floor(page)) - 1) * ADMIN_PAGE_SIZE;
|
|
235
|
+
return { items: matching.slice(firstIndex, firstIndex + ADMIN_PAGE_SIZE), total: matching.length };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Acknowledges an alert: records who saw it and when.
|
|
239
|
+
* @param alertId The alert.
|
|
240
|
+
* @param acknowledgedBy The admin's name.
|
|
241
|
+
* @returns The alert.
|
|
242
|
+
* @throws AdminRequestError 404 for an unknown alert.
|
|
243
|
+
*/
|
|
244
|
+
async acknowledge(alertId, acknowledgedBy) {
|
|
245
|
+
const alert = (await this.currentAlerts()).find((candidate) => candidate.alertId === alertId);
|
|
246
|
+
if (alert == null)
|
|
247
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such alert");
|
|
248
|
+
const acknowledged = Object.assign({}, alert, { acknowledgedBy: acknowledgedBy, acknowledgedAt: new Date(this.services.clock.now()).toISOString() });
|
|
249
|
+
await this.services.store.appendRecord("alertHistory", acknowledged);
|
|
250
|
+
return acknowledged;
|
|
251
|
+
}
|
|
252
|
+
/** Writes the default rules the store does not have yet, once record keeping is on (R3). */
|
|
253
|
+
async seedRulesOnce() {
|
|
254
|
+
if (this.hasSeededRules || !(await this.serverEvents.isRecordKeepingOn()))
|
|
255
|
+
return;
|
|
256
|
+
const stored = await this.storedRules();
|
|
257
|
+
for (const ruleId of ALERT_RULE_IDS) {
|
|
258
|
+
if (stored.has(ruleId))
|
|
259
|
+
continue;
|
|
260
|
+
try {
|
|
261
|
+
await this.services.store.putDocument("alertRules", ruleId, defaultRuleRecord(ruleId), 0);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
// Another instance seeded it first.
|
|
265
|
+
if (!(error instanceof AdminConflictError))
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
this.hasSeededRules = true;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Reads what every rule needs once: the instances, the new audit records and the dedicated overrun rate.
|
|
273
|
+
* @returns The context.
|
|
274
|
+
*/
|
|
275
|
+
async buildContext() {
|
|
276
|
+
const records = await this.fleet.listInstanceRecords();
|
|
277
|
+
const upInstances = records.filter((record) => record.view.status === "up").map((record) => record.view);
|
|
278
|
+
return {
|
|
279
|
+
now: this.services.clock.now(),
|
|
280
|
+
records: records,
|
|
281
|
+
upInstances: upInstances,
|
|
282
|
+
newAuditRecords: await this.readNewAuditRecords(),
|
|
283
|
+
dedicatedOverrunsPerMinute: await this.dedicatedOverrunsPerMinute(upInstances)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Checks one rule and fires or resolves its alert (DR §7.9).
|
|
288
|
+
* @param rule The rule's settings.
|
|
289
|
+
* @param context The evaluation context.
|
|
290
|
+
* @param firing The rule's firing alert, or null.
|
|
291
|
+
*/
|
|
292
|
+
async evaluateRule(rule, context, firing) {
|
|
293
|
+
if (!rule.enabled) {
|
|
294
|
+
this.breachingSince.delete(rule.ruleId);
|
|
295
|
+
if (firing != null)
|
|
296
|
+
await this.resolve(rule, firing);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (rule.silencedUntil != null && Date.parse(rule.silencedUntil) > context.now)
|
|
300
|
+
return;
|
|
301
|
+
const breach = await this.breachOf(rule, context);
|
|
302
|
+
if (EVENT_RULES.has(rule.ruleId)) {
|
|
303
|
+
if (breach != null)
|
|
304
|
+
await this.fire(rule, breach, context.now);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (breach == null) {
|
|
308
|
+
this.breachingSince.delete(rule.ruleId);
|
|
309
|
+
if (firing != null)
|
|
310
|
+
await this.resolve(rule, firing);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const since = this.breachingSince.get(rule.ruleId) ?? context.now;
|
|
314
|
+
this.breachingSince.set(rule.ruleId, since);
|
|
315
|
+
if (firing != null || context.now - since < rule.forMinutes * MILLISECONDS_PER_MINUTE)
|
|
316
|
+
return;
|
|
317
|
+
await this.fire(rule, breach, context.now);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Whether a rule breaches now (DR §8 conditions).
|
|
321
|
+
* @param rule The rule's settings.
|
|
322
|
+
* @param context The evaluation context.
|
|
323
|
+
* @returns The breach, or null.
|
|
324
|
+
*/
|
|
325
|
+
async breachOf(rule, context) {
|
|
326
|
+
const threshold = rule.threshold ?? ALERT_RULE_DEFINITIONS[rule.ruleId].threshold ?? 0;
|
|
327
|
+
switch (rule.ruleId) {
|
|
328
|
+
case "instanceLost":
|
|
329
|
+
return instancesBreach(context.records.filter((record) => record.view.status === "lost").map((record) => record.view), "is lost", "are lost");
|
|
330
|
+
case "instanceNotReady":
|
|
331
|
+
return instancesBreach(context.upInstances.filter((instance) => !instance.ready && !instance.draining), "is not ready", "are not ready");
|
|
332
|
+
case "redisUnreachable":
|
|
333
|
+
return instancesBreach(context.upInstances.filter((instance) => instance.readinessReasons.includes(REDIS_UNREACHABLE_REASON)), "cannot reach Redis", "cannot reach Redis");
|
|
334
|
+
case "refusalSpike":
|
|
335
|
+
return rateBreach(sumRate(context.records, (heartbeat) => heartbeat.joinsRefused), threshold, "join refusals per minute");
|
|
336
|
+
case "authFailureSpike":
|
|
337
|
+
return rateBreach(sumRate(context.records, (heartbeat) => heartbeat.authFailures), threshold, "player auth failures per minute");
|
|
338
|
+
case "eventLoopLag":
|
|
339
|
+
return instancesBreach(context.upInstances.filter((instance) => instance.eventLoopLagMilliseconds > threshold), "has event loop lag above " + threshold + " ms", "have event loop lag above " + threshold + " ms");
|
|
340
|
+
case "memoryHigh":
|
|
341
|
+
return instancesBreach(context.upInstances.filter((instance) => instance.residentMemoryBytes / BYTES_PER_MEGABYTE > threshold), "uses more than " + threshold + " MB", "use more than " + threshold + " MB");
|
|
342
|
+
case "dedicatedOverruns":
|
|
343
|
+
return rateBreach(context.dedicatedOverrunsPerMinute, threshold, "dedicated tick overruns per minute");
|
|
344
|
+
case "certificateExpiring":
|
|
345
|
+
return instancesBreach(context.upInstances.filter((instance) => instance.certificateDaysLeft != null && instance.certificateDaysLeft < threshold), "has a certificate expiring within " + threshold + " days", "have certificates expiring within " + threshold + " days");
|
|
346
|
+
case "versionMismatch":
|
|
347
|
+
return versionBreach(context.upInstances);
|
|
348
|
+
case "nearLimits":
|
|
349
|
+
return this.nearLimitsBreach(context.upInstances, threshold);
|
|
350
|
+
case "newAdminAddress":
|
|
351
|
+
return this.newAdminAddressBreach(context);
|
|
352
|
+
case "accountLockout":
|
|
353
|
+
return lockoutBreach(context.newAuditRecords);
|
|
354
|
+
default:
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* nearLimits: an instance hosts at least threshold percent of limits.maxSessions.
|
|
360
|
+
* @param upInstances The live instances.
|
|
361
|
+
* @param thresholdPercent The threshold, in percent.
|
|
362
|
+
* @returns The breach, or null.
|
|
363
|
+
*/
|
|
364
|
+
nearLimitsBreach(upInstances, thresholdPercent) {
|
|
365
|
+
const maxSessions = this.services.config.limits.maxSessions;
|
|
366
|
+
if (maxSessions <= 0)
|
|
367
|
+
return null;
|
|
368
|
+
const near = upInstances.filter((instance) => (instance.sessions / maxSessions) * FULL_PERCENT >= thresholdPercent);
|
|
369
|
+
return instancesBreach(near, "hosts at least " + thresholdPercent + "% of its session limit", "host at least " + thresholdPercent + "% of their session limit");
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* newAdminAddress: a new second-factor sign-in from an address the account did not use in the lookback period. An
|
|
373
|
+
* account's first sign-in never counts.
|
|
374
|
+
* @param context The evaluation context.
|
|
375
|
+
* @returns The breach, or null.
|
|
376
|
+
*/
|
|
377
|
+
async newAdminAddressBreach(context) {
|
|
378
|
+
const signIns = context.newAuditRecords.filter((record) => record.action === SECOND_FACTOR_ACTION && record.result.ok && record.target != null);
|
|
379
|
+
if (signIns.length === 0)
|
|
380
|
+
return null;
|
|
381
|
+
const history = await this.services.store.readRecords("audit", { limit: RECORD_QUERY_MAX_LIMIT, newestFirst: true });
|
|
382
|
+
const lookbackStart = context.now - NEW_ADMIN_ADDRESS_LOOKBACK_DAYS * MILLISECONDS_PER_DAY;
|
|
383
|
+
for (const signIn of signIns) {
|
|
384
|
+
const earlier = history.map((stored) => stored.value).filter((record) => record.sequence < signIn.sequence && record.action === SECOND_FACTOR_ACTION && record.result.ok && record.target === signIn.target && Date.parse(record.at) >= lookbackStart);
|
|
385
|
+
const isNewAddress = earlier.length > 0 && !earlier.some((record) => record.clientAddress === signIn.clientAddress);
|
|
386
|
+
if (isNewAddress)
|
|
387
|
+
return { summary: "Admin " + signIn.target + " signed in from a new address " + (signIn.clientAddress ?? "unknown"), processId: signIn.processId };
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Audit records appended since the last evaluation. The first evaluation only notes where the stream ends.
|
|
393
|
+
* @returns The new records, oldest first.
|
|
394
|
+
*/
|
|
395
|
+
async readNewAuditRecords() {
|
|
396
|
+
if (this.lastAuditSequence == null) {
|
|
397
|
+
const newest = await this.services.store.readRecords("audit", { limit: 1, newestFirst: true });
|
|
398
|
+
this.lastAuditSequence = newest[0]?.sequence ?? 0;
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
const records = await this.services.store.readRecords("audit", { afterSequence: this.lastAuditSequence, limit: RECORD_QUERY_MAX_LIMIT, newestFirst: false });
|
|
402
|
+
if (records.length > 0)
|
|
403
|
+
this.lastAuditSequence = records[records.length - 1].sequence;
|
|
404
|
+
return records.map((record) => record.value);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The fleet's dedicated tick overruns per minute between each live instance's last two metrics samples.
|
|
408
|
+
* @param upInstances The live instances.
|
|
409
|
+
* @returns The rate.
|
|
410
|
+
*/
|
|
411
|
+
async dedicatedOverrunsPerMinute(upInstances) {
|
|
412
|
+
let total = 0;
|
|
413
|
+
for (const instance of upInstances) {
|
|
414
|
+
const samples = await this.lastTwoSamples(instance);
|
|
415
|
+
if (samples.length < 2)
|
|
416
|
+
continue;
|
|
417
|
+
const [older, newer] = samples;
|
|
418
|
+
const seconds = (Date.parse(newer.at) - Date.parse(older.at)) / MILLISECONDS_PER_SECOND;
|
|
419
|
+
const growth = newer.dedicatedTickOverruns - older.dedicatedTickOverruns;
|
|
420
|
+
if (seconds > 0 && growth > 0)
|
|
421
|
+
total += (growth / seconds) * SECONDS_PER_MINUTE;
|
|
422
|
+
}
|
|
423
|
+
return total;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* An instance's last two metrics samples: from this instance's ring, or from the store stream with Redis.
|
|
427
|
+
* @param instance The instance.
|
|
428
|
+
* @returns At most two samples, oldest first.
|
|
429
|
+
*/
|
|
430
|
+
async lastTwoSamples(instance) {
|
|
431
|
+
if (instance.isThisInstance)
|
|
432
|
+
return this.metricsSampler.samples(Number.POSITIVE_INFINITY).slice(-2);
|
|
433
|
+
if (this.services.store.kind !== "redis")
|
|
434
|
+
return [];
|
|
435
|
+
const stored = await this.services.store.readRecords(metricsStreamOf(instance.processId), { limit: 2, newestFirst: true });
|
|
436
|
+
return stored.map((record) => record.value).reverse();
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Fires an alert: stores it, delivers it to the rule's channels, records a server event and calls the listeners.
|
|
440
|
+
* Event rules store it resolved at once (one alert per event).
|
|
441
|
+
* @param rule The rule.
|
|
442
|
+
* @param breach What breaches.
|
|
443
|
+
* @param now The evaluation time.
|
|
444
|
+
*/
|
|
445
|
+
async fire(rule, breach, now) {
|
|
446
|
+
const firedAt = new Date(now).toISOString();
|
|
447
|
+
const isOneShot = EVENT_RULES.has(rule.ruleId);
|
|
448
|
+
const alert = {
|
|
449
|
+
alertId: randomUUID(),
|
|
450
|
+
ruleId: rule.ruleId,
|
|
451
|
+
severity: rule.severity,
|
|
452
|
+
summary: breach.summary,
|
|
453
|
+
processId: breach.processId,
|
|
454
|
+
state: isOneShot ? "resolved" : "firing",
|
|
455
|
+
firedAt: firedAt,
|
|
456
|
+
resolvedAt: isOneShot ? firedAt : null,
|
|
457
|
+
acknowledgedBy: null,
|
|
458
|
+
acknowledgedAt: null,
|
|
459
|
+
deliveries: []
|
|
460
|
+
};
|
|
461
|
+
alert.deliveries = await this.deliverToRule(rule, alert, "firing");
|
|
462
|
+
await this.services.store.appendRecord("alertHistory", alert);
|
|
463
|
+
this.serverEvents.record("alert fired: " + alert.summary, alert.processId);
|
|
464
|
+
for (const listener of this.firedListeners)
|
|
465
|
+
listener(alert);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Resolves a firing alert and delivers the resolve.
|
|
469
|
+
* @param rule The rule.
|
|
470
|
+
* @param firing The firing alert.
|
|
471
|
+
*/
|
|
472
|
+
async resolve(rule, firing) {
|
|
473
|
+
const resolved = Object.assign({}, firing, { state: "resolved", resolvedAt: new Date(this.services.clock.now()).toISOString() });
|
|
474
|
+
resolved.deliveries = firing.deliveries.concat(await this.deliverToRule(rule, resolved, "resolved"));
|
|
475
|
+
await this.services.store.appendRecord("alertHistory", resolved);
|
|
476
|
+
this.serverEvents.record("alert resolved: " + resolved.summary, resolved.processId);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Delivers an alert to every channel of its rule (missing channels are skipped).
|
|
480
|
+
* @param rule The rule.
|
|
481
|
+
* @param alert The alert.
|
|
482
|
+
* @param state Fired or resolved.
|
|
483
|
+
* @returns One delivery entry per channel.
|
|
484
|
+
*/
|
|
485
|
+
async deliverToRule(rule, alert, state) {
|
|
486
|
+
const channels = (await this.channelRecords()).filter((channel) => rule.channelIds.includes(channel.channelId));
|
|
487
|
+
return Promise.all(channels.map(async (channel) => {
|
|
488
|
+
const outcome = await this.channels.deliver(channel, alert, state, this.services.pagerDutyUrl);
|
|
489
|
+
if (!outcome.ok)
|
|
490
|
+
getLogger().warn("an alert could not be delivered", { channel: channel.name, error: outcome.error });
|
|
491
|
+
return { channelId: channel.channelId, ok: outcome.ok, error: outcome.error, at: new Date(this.services.clock.now()).toISOString() };
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Every alert in its newest state, newest fired first.
|
|
496
|
+
* @returns The alerts.
|
|
497
|
+
*/
|
|
498
|
+
async currentAlerts() {
|
|
499
|
+
const records = await this.services.store.readRecords("alertHistory", { limit: RECORD_QUERY_MAX_LIMIT, newestFirst: true });
|
|
500
|
+
const newestById = new Map();
|
|
501
|
+
for (const record of records) {
|
|
502
|
+
if (!newestById.has(record.value.alertId))
|
|
503
|
+
newestById.set(record.value.alertId, record.value);
|
|
504
|
+
}
|
|
505
|
+
return Array.from(newestById.values()).sort((left, right) => right.firedAt.localeCompare(left.firedAt));
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* The firing alert of each rule.
|
|
509
|
+
* @returns Rule id → firing alert.
|
|
510
|
+
*/
|
|
511
|
+
async firingAlertsByRule() {
|
|
512
|
+
const firing = new Map();
|
|
513
|
+
for (const alert of await this.currentAlerts()) {
|
|
514
|
+
if (alert.state === "firing" && !firing.has(alert.ruleId))
|
|
515
|
+
firing.set(alert.ruleId, alert);
|
|
516
|
+
}
|
|
517
|
+
return firing;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Every rule's settings, stored or default.
|
|
521
|
+
* @returns The records, in the table's order.
|
|
522
|
+
*/
|
|
523
|
+
async ruleRecords() {
|
|
524
|
+
const stored = await this.storedRules();
|
|
525
|
+
return ALERT_RULE_IDS.map((ruleId) => stored.get(ruleId)?.value ?? defaultRuleRecord(ruleId));
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* The stored rule documents.
|
|
529
|
+
* @returns Rule id → document.
|
|
530
|
+
*/
|
|
531
|
+
async storedRules() {
|
|
532
|
+
const documents = await this.services.store.listDocuments("alertRules");
|
|
533
|
+
return new Map(documents.map((document) => [document.id, document]));
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* The stored channels.
|
|
537
|
+
* @returns The channels.
|
|
538
|
+
*/
|
|
539
|
+
async channelRecords() {
|
|
540
|
+
const documents = await this.services.store.listDocuments("alertChannels");
|
|
541
|
+
return documents.map((document) => document.value);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* A known rule's stored document.
|
|
545
|
+
* @param ruleId The rule.
|
|
546
|
+
* @returns The document, or null while the rule has its defaults.
|
|
547
|
+
* @throws AdminRequestError 404 for an unknown rule.
|
|
548
|
+
*/
|
|
549
|
+
async requireRule(ruleId) {
|
|
550
|
+
if (!Object.hasOwn(ALERT_RULE_DEFINITIONS, ruleId))
|
|
551
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such alert rule");
|
|
552
|
+
return this.services.store.getDocument("alertRules", ruleId);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Checks that every channel id exists.
|
|
556
|
+
* @param channelIds The channel ids.
|
|
557
|
+
* @throws AdminRequestError 400 naming the first unknown channel.
|
|
558
|
+
*/
|
|
559
|
+
async requireChannels(channelIds) {
|
|
560
|
+
const known = new Set((await this.channelRecords()).map((channel) => channel.channelId));
|
|
561
|
+
const unknown = channelIds.find((channelId) => !known.has(channelId));
|
|
562
|
+
if (unknown != null)
|
|
563
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "no such alert channel: " + unknown);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* A rule's default settings.
|
|
568
|
+
* @param ruleId The rule.
|
|
569
|
+
* @returns The record.
|
|
570
|
+
*/
|
|
571
|
+
function defaultRuleRecord(ruleId) {
|
|
572
|
+
const definition = ALERT_RULE_DEFINITIONS[ruleId];
|
|
573
|
+
return { ruleId: ruleId, enabled: true, threshold: definition.threshold, forMinutes: definition.forMinutes, severity: definition.severity, channelIds: [], silencedUntil: null };
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* A rule as the console sees it.
|
|
577
|
+
* @param ruleId The rule.
|
|
578
|
+
* @param stored Its stored document, or null for the defaults (revision 0).
|
|
579
|
+
* @returns The view.
|
|
580
|
+
*/
|
|
581
|
+
function ruleView(ruleId, stored) {
|
|
582
|
+
const record = stored?.value ?? defaultRuleRecord(ruleId);
|
|
583
|
+
const definition = ALERT_RULE_DEFINITIONS[ruleId];
|
|
584
|
+
return {
|
|
585
|
+
ruleId: ruleId,
|
|
586
|
+
label: definition.label,
|
|
587
|
+
enabled: record.enabled,
|
|
588
|
+
threshold: record.threshold,
|
|
589
|
+
thresholdUnit: definition.thresholdUnit,
|
|
590
|
+
forMinutes: record.forMinutes,
|
|
591
|
+
severity: record.severity,
|
|
592
|
+
channelIds: record.channelIds.slice(),
|
|
593
|
+
silencedUntil: record.silencedUntil,
|
|
594
|
+
revision: stored?.revision ?? 0
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* A breach naming the instances it concerns.
|
|
599
|
+
* @param instances The breaching instances.
|
|
600
|
+
* @param singularText What one instance does ("is lost").
|
|
601
|
+
* @param pluralText What several do ("are lost").
|
|
602
|
+
* @returns The breach, or null when no instance breaches.
|
|
603
|
+
*/
|
|
604
|
+
function instancesBreach(instances, singularText, pluralText) {
|
|
605
|
+
if (instances.length === 0)
|
|
606
|
+
return null;
|
|
607
|
+
if (instances.length === 1)
|
|
608
|
+
return { summary: "Instance " + instances[0].processId + " " + singularText, processId: instances[0].processId };
|
|
609
|
+
return { summary: instances.length + " instances " + pluralText, processId: null };
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* A breach of a fleet rate above its threshold.
|
|
613
|
+
* @param rate The rate.
|
|
614
|
+
* @param threshold The threshold.
|
|
615
|
+
* @param unit What the rate counts.
|
|
616
|
+
* @returns The breach, or null at or below the threshold.
|
|
617
|
+
*/
|
|
618
|
+
function rateBreach(rate, threshold, unit) {
|
|
619
|
+
if (rate <= threshold)
|
|
620
|
+
return null;
|
|
621
|
+
return { summary: Math.round(rate) + " " + unit + " (threshold " + threshold + ")", processId: null };
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The fleet's per-minute rate of a heartbeat counter between each live instance's last two heartbeats.
|
|
625
|
+
* @param records The instances with their earlier heartbeats.
|
|
626
|
+
* @param read Reads the counter.
|
|
627
|
+
* @returns The summed rate, never negative per instance.
|
|
628
|
+
*/
|
|
629
|
+
function sumRate(records, read) {
|
|
630
|
+
let total = 0;
|
|
631
|
+
for (const record of records) {
|
|
632
|
+
if (record.view.status !== "up" || record.previous == null)
|
|
633
|
+
continue;
|
|
634
|
+
const seconds = (Date.parse(record.view.sentAt) - Date.parse(record.previous.sentAt)) / MILLISECONDS_PER_SECOND;
|
|
635
|
+
const growth = read(record.view) - read(record.previous);
|
|
636
|
+
if (seconds > 0 && growth > 0)
|
|
637
|
+
total += (growth / seconds) * SECONDS_PER_MINUTE;
|
|
638
|
+
}
|
|
639
|
+
return total;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* versionMismatch: the live instances run more than one server or protocol version.
|
|
643
|
+
* @param upInstances The live instances.
|
|
644
|
+
* @returns The breach, or null.
|
|
645
|
+
*/
|
|
646
|
+
function versionBreach(upInstances) {
|
|
647
|
+
const versions = new Set(upInstances.map((instance) => instance.serverVersion + " (protocol " + instance.protocolVersion + ")"));
|
|
648
|
+
if (versions.size <= 1)
|
|
649
|
+
return null;
|
|
650
|
+
return { summary: "Instances run different versions: " + Array.from(versions).sort().join(", "), processId: null };
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* accountLockout: a new auth.lockout audit record.
|
|
654
|
+
* @param newAuditRecords The audit records since the last evaluation.
|
|
655
|
+
* @returns The breach, or null.
|
|
656
|
+
*/
|
|
657
|
+
function lockoutBreach(newAuditRecords) {
|
|
658
|
+
const lockouts = newAuditRecords.filter((record) => record.action === LOCKOUT_ACTION);
|
|
659
|
+
if (lockouts.length === 0)
|
|
660
|
+
return null;
|
|
661
|
+
const names = Array.from(new Set(lockouts.map((record) => record.target ?? "unknown")));
|
|
662
|
+
return { summary: "Admin account locked after failed sign-ins: " + names.join(", "), processId: lockouts[0].processId };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Checks a channel request.
|
|
666
|
+
* @param request The request.
|
|
667
|
+
* @throws AdminRequestError 400 for an unknown kind, a missing name, a missing or non-http(s) URL or routing key.
|
|
668
|
+
*/
|
|
669
|
+
function validateChannelRequest(request) {
|
|
670
|
+
if (!ALERT_CHANNEL_KINDS.has(request.kind))
|
|
671
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "kind must be webhook, slack, discord, teams or pagerduty");
|
|
672
|
+
if (request.name.trim() === "")
|
|
673
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "name must be text");
|
|
674
|
+
if (request.kind === "pagerduty") {
|
|
675
|
+
if (request.routingKey == null || request.routingKey === "")
|
|
676
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "a PagerDuty channel needs a routingKey");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (request.url == null || !/^https?:\/\//.test(request.url))
|
|
680
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "url must be an http:// or https:// URL");
|
|
681
|
+
}
|