@celilo/console-server 0.5.0 → 0.6.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/package.json +1 -1
- package/spa/assets/index-xOtoHA1j.js +26 -0
- package/spa/index.html +1 -1
- package/src/main.test.ts +38 -0
- package/src/main.ts +26 -7
- package/src/serve.ts +8 -3
- package/src/verbs.ts +55 -12
- package/spa/assets/index-DJJXEYrW.js +0 -26
package/spa/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>celilo</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-xOtoHA1j.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-eLF_dgP-.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/src/main.test.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { startFromEnv } from './main';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The console used to fall back to the bare destination `celilo-api@celilo-mgr`
|
|
6
|
+
* when CELILO_API_DEST was unset. That name resolves only on celilo-mgr itself,
|
|
7
|
+
* so on the console's own box every /api read 502'd while `celilo module health`
|
|
8
|
+
* reported VERIFIED — it drives an OIDC login and never touches the upstream.
|
|
9
|
+
* celilo#1453.
|
|
10
|
+
*
|
|
11
|
+
* A silent fallback to an unreachable default is worse than no default: it turns
|
|
12
|
+
* a missing value into a runtime symptom three layers from its cause. So the
|
|
13
|
+
* behaviour under test is the REFUSAL, not the presence of a constant.
|
|
14
|
+
*/
|
|
15
|
+
const OIDC_ENV = {
|
|
16
|
+
OIDC_ISSUER: 'https://auth.example.test/application/o/console/',
|
|
17
|
+
OIDC_CLIENT_ID: 'console-web',
|
|
18
|
+
OIDC_CLIENT_SECRET: 'not-a-real-secret',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('startFromEnv — the upstream destination', () => {
|
|
22
|
+
test('refuses to start when CELILO_API_DEST is unset', () => {
|
|
23
|
+
expect(() => startFromEnv({ ...OIDC_ENV })).toThrow(/CELILO_API_DEST is not set/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('refuses to start when CELILO_API_DEST is empty', () => {
|
|
27
|
+
expect(() => startFromEnv({ ...OIDC_ENV, CELILO_API_DEST: '' })).toThrow(
|
|
28
|
+
/CELILO_API_DEST is not set/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('says what a usable destination looks like, so the message is actionable', () => {
|
|
33
|
+
// An error naming only the variable sends the reader to grep. This one has to
|
|
34
|
+
// carry the shape of the answer, because the shape IS the bug: a bare
|
|
35
|
+
// hostname looks correct and resolves nowhere.
|
|
36
|
+
expect(() => startFromEnv({ ...OIDC_ENV })).toThrow(/\.infra\.<domain>/);
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/main.ts
CHANGED
|
@@ -23,8 +23,18 @@ import { type AuthGate, type AuthSubject, serve } from './serve';
|
|
|
23
23
|
*/
|
|
24
24
|
const DEFAULT_IDENTITY_FILE = '/etc/celilo-web-console/id_ed25519';
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// There is deliberately no DEFAULT_DEST. It used to be `celilo-api@celilo-mgr`,
|
|
27
|
+
// borrowed from wellspring's DEFAULT_ENDPOINT — a bare name that resolves only on
|
|
28
|
+
// celilo-mgr itself, via its own /etc/hosts. No system in the fleet carries a DNS
|
|
29
|
+
// search domain, so on the console's own box it resolved nowhere, and the server
|
|
30
|
+
// fell back to it silently whenever CELILO_API_DEST was unset. The result was
|
|
31
|
+
// `ssh: Could not resolve hostname celilo-mgr` twice a second and a 502 on every
|
|
32
|
+
// /api read, while `celilo module health` still reported VERIFIED because it
|
|
33
|
+
// drives a login and never touches the upstream. celilo#1453.
|
|
34
|
+
//
|
|
35
|
+
// A missing destination is a misconfiguration, so it fails loudly. The fleet's
|
|
36
|
+
// real name for a system is `<hostname>.infra.<domain>`, which technitium
|
|
37
|
+
// registers for every system (on-system-event.ts:120).
|
|
28
38
|
|
|
29
39
|
export interface OidcGateOptions {
|
|
30
40
|
/** The `iss` claim exactly — for authentik, `https://auth.<domain>/application/o/<slug>/`. */
|
|
@@ -95,12 +105,17 @@ export function oidcGate(options: OidcGateOptions): AuthGate {
|
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
/** Read one required value, or say which one is missing and who writes it. */
|
|
98
|
-
|
|
108
|
+
const OIDC_UNSET =
|
|
109
|
+
"The console refuses to serve without an identity provider — see /etc/celilo-web-console-discovered.env, which the module's install hook writes once the OIDC client exists.";
|
|
110
|
+
|
|
111
|
+
function required(
|
|
112
|
+
env: Record<string, string | undefined>,
|
|
113
|
+
name: string,
|
|
114
|
+
why: string = OIDC_UNSET,
|
|
115
|
+
): string {
|
|
99
116
|
const value = env[name];
|
|
100
117
|
if (value === undefined || value === '') {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`${name} is not set. The console refuses to serve without an identity provider — see /etc/celilo-web-console-discovered.env, which the module's install hook writes once the OIDC client exists.`,
|
|
103
|
-
);
|
|
118
|
+
throw new Error(`${name} is not set. ${why}`);
|
|
104
119
|
}
|
|
105
120
|
return value;
|
|
106
121
|
}
|
|
@@ -126,7 +141,11 @@ export function startFromEnv(env: Record<string, string | undefined> = process.e
|
|
|
126
141
|
);
|
|
127
142
|
const server = serve({
|
|
128
143
|
port: Number(env.PORT ?? 8443),
|
|
129
|
-
dest:
|
|
144
|
+
dest: required(
|
|
145
|
+
env,
|
|
146
|
+
'CELILO_API_DEST',
|
|
147
|
+
'The console has no celilo to read through. The module writes it into /etc/celilo-web-console.env from its `api_dest` config; it must be a resolvable destination such as celilo-api@celilo-mgr.infra.<domain>, never a bare hostname.',
|
|
148
|
+
),
|
|
130
149
|
identityFile: env.CELILO_IDENTITY_FILE ?? DEFAULT_IDENTITY_FILE,
|
|
131
150
|
...(env.CONSOLE_SPA_DIR ? { spaDir: env.CONSOLE_SPA_DIR } : {}),
|
|
132
151
|
authenticate: login.authenticate,
|
package/src/serve.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
+
import { API_BASE_PATH } from '@celilo/console-protocol';
|
|
19
20
|
import type { ServiceType } from '@celilo/console-protocol';
|
|
20
21
|
import { CALLBACK_PATH } from './oidc-login';
|
|
21
22
|
import { Upstream, type UpstreamOptions, UpstreamUnavailable } from './upstream';
|
|
@@ -267,7 +268,11 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
|
|
|
267
268
|
// bundle even with every API call refused.
|
|
268
269
|
const subject = await options.authenticate(request);
|
|
269
270
|
if (subject === null) {
|
|
270
|
-
if (
|
|
271
|
+
if (
|
|
272
|
+
options.login &&
|
|
273
|
+
!url.pathname.startsWith(`${API_BASE_PATH}/`) &&
|
|
274
|
+
url.pathname !== CALLBACK_PATH
|
|
275
|
+
) {
|
|
271
276
|
return options.login.redirectToLogin(request);
|
|
272
277
|
}
|
|
273
278
|
return new Response('Unauthorized', {
|
|
@@ -276,8 +281,8 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
|
|
|
276
281
|
});
|
|
277
282
|
}
|
|
278
283
|
|
|
279
|
-
if (url.pathname.startsWith(
|
|
280
|
-
const name = url.pathname.slice(
|
|
284
|
+
if (url.pathname.startsWith(`${API_BASE_PATH}/`)) {
|
|
285
|
+
const name = url.pathname.slice(API_BASE_PATH.length + 1) as ApiName;
|
|
281
286
|
if (!SERVED.includes(name)) {
|
|
282
287
|
return Response.json(
|
|
283
288
|
{ isSucc: false, err: { message: `Unknown verb: ${name}`, type: 'ApiError' } },
|
package/src/verbs.ts
CHANGED
|
@@ -81,7 +81,23 @@ interface CliClosure {
|
|
|
81
81
|
truncated: boolean;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* `celilo alerts list --json`, verbatim — which is NOT the protocol's shape.
|
|
86
|
+
*
|
|
87
|
+
* Every timestamp is `unknown` deliberately. celilo stores them as Drizzle
|
|
88
|
+
* `mode: 'timestamp'` columns, so its rows hold `Date` objects and `--json`
|
|
89
|
+
* prints ISO STRINGS, while the protocol declares epoch ms (also deliberate:
|
|
90
|
+
* ages are computed against the server's `asOf`, so a skewed browser clock
|
|
91
|
+
* cannot answer "how long has this been firing" wrongly).
|
|
92
|
+
*
|
|
93
|
+
* Typing them `number` here declared a shape nobody checked. `upstream.read<T>`
|
|
94
|
+
* is a type ASSERTION, so the strings passed through onto the wire, the
|
|
95
|
+
* browser's own protocol validation rejected the payload, and the dock rendered
|
|
96
|
+
* one word — "unavailable" — for a fleet with six firing alerts (celilo#1455).
|
|
97
|
+
* `unknown` forces every one of them through `epochMs` below, which converts and
|
|
98
|
+
* VALIDATES at the ingest boundary, so an upstream shape change fails here
|
|
99
|
+
* naming the field instead of three layers away as a single word.
|
|
100
|
+
*/
|
|
85
101
|
interface CliAlert {
|
|
86
102
|
id: string;
|
|
87
103
|
key: string;
|
|
@@ -90,18 +106,45 @@ interface CliAlert {
|
|
|
90
106
|
monitor: string | null;
|
|
91
107
|
escalationPolicy: string | null;
|
|
92
108
|
escalationStep: number;
|
|
93
|
-
nextEscalationAt:
|
|
94
|
-
firstFiredAt:
|
|
95
|
-
lastSeenAt:
|
|
109
|
+
nextEscalationAt: unknown;
|
|
110
|
+
firstFiredAt: unknown;
|
|
111
|
+
lastSeenAt: unknown;
|
|
96
112
|
suppressedByAlertId: string | null;
|
|
97
113
|
suppressedByWindowId: string | null;
|
|
98
114
|
awaitingConfirmation: boolean;
|
|
99
115
|
ackedBy: string | null;
|
|
100
|
-
ackedAt:
|
|
101
|
-
silencedUntil:
|
|
116
|
+
ackedAt: unknown;
|
|
117
|
+
silencedUntil: unknown;
|
|
102
118
|
message: string;
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
const ALERTS_ARGV = ['alerts', 'list', '--json'] as const;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* An upstream timestamp as epoch ms, or a `malformed` failure naming the field.
|
|
125
|
+
*
|
|
126
|
+
* Accepts both forms on purpose: epoch ms is what the protocol wants and what an
|
|
127
|
+
* older or future celilo may print directly, and an ISO string is what today's
|
|
128
|
+
* Drizzle-serialised rows carry.
|
|
129
|
+
*/
|
|
130
|
+
function epochMs(value: unknown, field: string): number {
|
|
131
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
132
|
+
if (typeof value === 'string') {
|
|
133
|
+
const parsed = Date.parse(value);
|
|
134
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
135
|
+
}
|
|
136
|
+
throw new UpstreamUnavailable(
|
|
137
|
+
'malformed',
|
|
138
|
+
ALERTS_ARGV,
|
|
139
|
+
`\`celilo alerts list --json\` returned ${field}=${JSON.stringify(value)}; expected epoch ms or an ISO timestamp.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** As `epochMs`, for a field celilo legitimately leaves unset. */
|
|
144
|
+
function epochMsOrNull(value: unknown, field: string): number | null {
|
|
145
|
+
return value === null || value === undefined ? null : epochMs(value, field);
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
/** `celilo person list --json`, verbatim. */
|
|
106
149
|
interface CliPerson {
|
|
107
150
|
name: string;
|
|
@@ -278,17 +321,17 @@ export async function readAlerts(upstream: Upstream, moduleId?: string): Promise
|
|
|
278
321
|
state: alertState(row.state),
|
|
279
322
|
severity: severity(row.severity),
|
|
280
323
|
policy: row.escalationPolicy,
|
|
281
|
-
firstFiredAt: row.firstFiredAt,
|
|
282
|
-
lastSeenAt: row.lastSeenAt,
|
|
324
|
+
firstFiredAt: epochMs(row.firstFiredAt, 'firstFiredAt'),
|
|
325
|
+
lastSeenAt: epochMs(row.lastSeenAt, 'lastSeenAt'),
|
|
283
326
|
suppressedByAlertId: row.suppressedByAlertId,
|
|
284
327
|
suppressedByWindowId: row.suppressedByWindowId,
|
|
285
|
-
silencedUntil: row.silencedUntil,
|
|
328
|
+
silencedUntil: epochMsOrNull(row.silencedUntil, 'silencedUntil'),
|
|
286
329
|
// `alerts --json` resolves the person to a name. There is no id on the
|
|
287
330
|
// wire, so the name serves as both — which is what the console shows.
|
|
288
331
|
ackedBy: row.ackedBy ? { id: row.ackedBy, name: row.ackedBy } : null,
|
|
289
|
-
ackedAt: row.ackedAt,
|
|
332
|
+
ackedAt: epochMsOrNull(row.ackedAt, 'ackedAt'),
|
|
290
333
|
escalationStep: row.escalationStep,
|
|
291
|
-
nextEscalationAt: row.nextEscalationAt,
|
|
334
|
+
nextEscalationAt: epochMsOrNull(row.nextEscalationAt, 'nextEscalationAt'),
|
|
292
335
|
}),
|
|
293
336
|
),
|
|
294
337
|
};
|
|
@@ -467,5 +510,5 @@ export async function ackAlert(
|
|
|
467
510
|
);
|
|
468
511
|
}
|
|
469
512
|
|
|
470
|
-
return { ackedAt: acked.ackedAt, ackedByName: acked.ackedBy ?? person };
|
|
513
|
+
return { ackedAt: epochMs(acked.ackedAt, 'ackedAt'), ackedByName: acked.ackedBy ?? person };
|
|
471
514
|
}
|