ankka-console 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/auth/oidc.d.ts +64 -0
- package/dist/auth/oidc.js +199 -0
- package/dist/client/control-plane.d.ts +67 -0
- package/dist/client/control-plane.js +208 -0
- package/dist/client/errors.d.ts +26 -0
- package/dist/client/errors.js +59 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +3 -0
- package/dist/client/schemas.d.ts +365 -0
- package/dist/client/schemas.js +221 -0
- package/dist/context.d.ts +83 -0
- package/dist/context.js +156 -0
- package/dist/extensions/render.d.ts +20 -0
- package/dist/extensions/render.js +49 -0
- package/dist/extensions/types.d.ts +44 -0
- package/dist/extensions/types.js +30 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/middleware.d.ts +14 -0
- package/dist/middleware.js +48 -0
- package/dist/options.d.ts +45 -0
- package/dist/options.js +39 -0
- package/dist/routes/auth.callback.d.ts +11 -0
- package/dist/routes/auth.callback.js +43 -0
- package/dist/routes/auth.sign-in.d.ts +11 -0
- package/dist/routes/auth.sign-in.js +29 -0
- package/dist/routes/auth.sign-out.d.ts +19 -0
- package/dist/routes/auth.sign-out.js +37 -0
- package/dist/routes/front.d.ts +41 -0
- package/dist/routes/front.js +25 -0
- package/dist/routes/logs.d.ts +67 -0
- package/dist/routes/logs.js +72 -0
- package/dist/routes/members.d.ts +45 -0
- package/dist/routes/members.js +59 -0
- package/dist/routes/organization-new.d.ts +14 -0
- package/dist/routes/organization-new.js +30 -0
- package/dist/routes/organization.d.ts +43 -0
- package/dist/routes/organization.js +82 -0
- package/dist/routes/project-new.d.ts +27 -0
- package/dist/routes/project-new.js +27 -0
- package/dist/routes/project.d.ts +59 -0
- package/dist/routes/project.js +61 -0
- package/dist/routes/service-apply.d.ts +44 -0
- package/dist/routes/service-apply.js +65 -0
- package/dist/routes/service.d.ts +71 -0
- package/dist/routes/service.js +73 -0
- package/dist/routes/stream.project.d.ts +3 -0
- package/dist/routes/stream.project.js +25 -0
- package/dist/routes/stream.service.d.ts +9 -0
- package/dist/routes/stream.service.js +68 -0
- package/dist/routes/tokens.d.ts +47 -0
- package/dist/routes/tokens.js +50 -0
- package/dist/routes.d.ts +28 -0
- package/dist/routes.js +48 -0
- package/dist/runtime.d.ts +28 -0
- package/dist/runtime.js +77 -0
- package/dist/server/create-console-server.d.ts +33 -0
- package/dist/server/create-console-server.js +203 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +13 -0
- package/dist/server/transport.d.ts +25 -0
- package/dist/server/transport.js +97 -0
- package/dist/session/cookie-store.d.ts +51 -0
- package/dist/session/cookie-store.js +66 -0
- package/dist/session/cookies.d.ts +15 -0
- package/dist/session/cookies.js +33 -0
- package/dist/session/seal.d.ts +12 -0
- package/dist/session/seal.js +38 -0
- package/dist/session/token-cache.d.ts +21 -0
- package/dist/session/token-cache.js +47 -0
- package/dist/session/token-source.d.ts +41 -0
- package/dist/session/token-source.js +63 -0
- package/dist/stream/log-follow.d.ts +18 -0
- package/dist/stream/log-follow.js +40 -0
- package/dist/stream/registry.d.ts +6 -0
- package/dist/stream/registry.js +34 -0
- package/dist/stream/sse.d.ts +14 -0
- package/dist/stream/sse.js +87 -0
- package/dist/styles.css +481 -0
- package/dist/testing/fake-control-plane.d.ts +161 -0
- package/dist/testing/fake-control-plane.js +715 -0
- package/dist/testing/fake-issuer.d.ts +66 -0
- package/dist/testing/fake-issuer.js +303 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/scenarios.d.ts +6 -0
- package/dist/testing/scenarios.js +44 -0
- package/dist/ui/console.d.ts +60 -0
- package/dist/ui/console.js +73 -0
- package/dist/ui/errors.d.ts +6 -0
- package/dist/ui/errors.js +30 -0
- package/dist/ui/refused.d.ts +6 -0
- package/dist/ui/refused.js +12 -0
- package/dist/ui/status.d.ts +5 -0
- package/dist/ui/status.js +23 -0
- package/dist/ui/use-stream.d.ts +59 -0
- package/dist/ui/use-stream.js +67 -0
- package/package.json +79 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Issuer } from "./auth/oidc.ts";
|
|
2
|
+
import { SealedFlash, type SessionStore } from "./session/cookie-store.ts";
|
|
3
|
+
import { SessionTokenSource, type TokenSource } from "./session/token-source.ts";
|
|
4
|
+
import { type FetchLike } from "./server/transport.ts";
|
|
5
|
+
import type { ConsoleOptions } from "./options.ts";
|
|
6
|
+
import type { DeployTokenCreated } from "./client/schemas.ts";
|
|
7
|
+
export interface ConsoleRuntime {
|
|
8
|
+
options: ConsoleOptions;
|
|
9
|
+
mount: string;
|
|
10
|
+
secure: boolean;
|
|
11
|
+
store: SessionStore;
|
|
12
|
+
tokens: TokenSource;
|
|
13
|
+
/** Present when the package runs its own sign-in. */
|
|
14
|
+
issuer?: () => Promise<Issuer>;
|
|
15
|
+
sessionTokens?: SessionTokenSource;
|
|
16
|
+
controlPlaneFetch: FetchLike;
|
|
17
|
+
/** A deploy token's secret, carried across one redirect and shown once. */
|
|
18
|
+
tokenFlash: SealedFlash<DeployTokenCreated>;
|
|
19
|
+
pendingLogin: SealedFlash<{
|
|
20
|
+
state: string;
|
|
21
|
+
nonce: string;
|
|
22
|
+
verifier: string;
|
|
23
|
+
returnTo: string;
|
|
24
|
+
}>;
|
|
25
|
+
log: (line: Record<string, unknown>) => void;
|
|
26
|
+
}
|
|
27
|
+
export declare const normaliseMount: (mount: string | undefined) => string;
|
|
28
|
+
export declare function createRuntime(options: ConsoleOptions): ConsoleRuntime;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one console process holds for its whole life, built once from its options: the identity
|
|
3
|
+
* provider's configuration, the session store, the access token cache, and the transports.
|
|
4
|
+
*/
|
|
5
|
+
import { ControlPlaneClient } from "./client/control-plane.js";
|
|
6
|
+
import { Issuer } from "./auth/oidc.js";
|
|
7
|
+
import { SealedCookieSessionStore, SealedFlash } from "./session/cookie-store.js";
|
|
8
|
+
import { TokenCache } from "./session/token-cache.js";
|
|
9
|
+
import { SessionTokenSource } from "./session/token-source.js";
|
|
10
|
+
import { tlsFetch } from "./server/transport.js";
|
|
11
|
+
export const normaliseMount = (mount) => {
|
|
12
|
+
const m = (mount ?? "/").trim();
|
|
13
|
+
const withLead = m.startsWith("/") ? m : `/${m}`;
|
|
14
|
+
return withLead.endsWith("/") ? withLead : `${withLead}/`;
|
|
15
|
+
};
|
|
16
|
+
export function createRuntime(options) {
|
|
17
|
+
const secure = options.publicOrigin.startsWith("https:");
|
|
18
|
+
const secret = options.sessionSecret;
|
|
19
|
+
if (!secret && (!options.session || !options.tokens)) {
|
|
20
|
+
throw new Error("sessionSecret is required unless both a session store and a token source are supplied");
|
|
21
|
+
}
|
|
22
|
+
const store = options.session ?? new SealedCookieSessionStore({ secret: secret, secure });
|
|
23
|
+
const controlPlaneFetch = tlsFetch(options.controlPlane.tls);
|
|
24
|
+
const authFetch = tlsFetch(options.auth.ca ? { ca: options.auth.ca } : undefined);
|
|
25
|
+
let issuer;
|
|
26
|
+
let sessionTokens;
|
|
27
|
+
let tokens;
|
|
28
|
+
if (options.tokens) {
|
|
29
|
+
tokens = options.tokens;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
// The issuer the console trusts is the one the control plane trusts: asked of the control plane
|
|
33
|
+
// once, unless the host named it.
|
|
34
|
+
let resolved;
|
|
35
|
+
issuer = () => {
|
|
36
|
+
if (!resolved) {
|
|
37
|
+
resolved = (async () => {
|
|
38
|
+
const name = options.auth.issuer ??
|
|
39
|
+
(await new ControlPlaneClient({ baseUrl: options.controlPlane.url, bearer: async () => null, transport: controlPlaneFetch }).authDiscovery())
|
|
40
|
+
.issuer;
|
|
41
|
+
return new Issuer({
|
|
42
|
+
issuer: name,
|
|
43
|
+
clientId: options.auth.clientId,
|
|
44
|
+
clientSecret: options.auth.clientSecret,
|
|
45
|
+
backchannelUrl: options.auth.backchannelUrl,
|
|
46
|
+
fetch: authFetch,
|
|
47
|
+
allowInsecure: options.auth.allowInsecure,
|
|
48
|
+
});
|
|
49
|
+
})().catch((e) => {
|
|
50
|
+
resolved = undefined;
|
|
51
|
+
throw e;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return resolved;
|
|
55
|
+
};
|
|
56
|
+
const getIssuer = issuer;
|
|
57
|
+
sessionTokens = new SessionTokenSource({
|
|
58
|
+
store,
|
|
59
|
+
cache: new TokenCache(),
|
|
60
|
+
issuer: { refresh: async (refreshToken) => (await getIssuer()).refresh(refreshToken) },
|
|
61
|
+
});
|
|
62
|
+
tokens = sessionTokens;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
options,
|
|
66
|
+
mount: normaliseMount(options.mount),
|
|
67
|
+
secure,
|
|
68
|
+
store,
|
|
69
|
+
tokens,
|
|
70
|
+
issuer,
|
|
71
|
+
sessionTokens,
|
|
72
|
+
controlPlaneFetch,
|
|
73
|
+
tokenFlash: new SealedFlash({ secret: secret ?? "unused", secure, name: "ankka_console_flash", maxAgeSeconds: 60 }),
|
|
74
|
+
pendingLogin: new SealedFlash({ secret: secret ?? "unused", secure, name: "ankka_console_login", maxAgeSeconds: 600 }),
|
|
75
|
+
log: options.log ?? ((line) => process.stdout.write(JSON.stringify(line) + "\n")),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console's process: the app on one port and readiness on another.
|
|
3
|
+
*
|
|
4
|
+
* In a cluster (`ANKKA_CONSOLE_TLS_DIR` set) the app port is TLS with the certificate cert-manager
|
|
5
|
+
* issued for the console, re-read when it is renewed, and it serves only a client presenting a
|
|
6
|
+
* certificate from the same authority that names the gateway, `ankka://gateway`: nothing reaches the
|
|
7
|
+
* console except through the installation's front door. Readiness is plain HTTP on its own port,
|
|
8
|
+
* because the kubelet holds no certificate, and says yes only once the certificate is loaded, the
|
|
9
|
+
* app port is bound and the control plane has answered. Locally it is plain HTTP on one port.
|
|
10
|
+
*/
|
|
11
|
+
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
12
|
+
import type { ServerBuild } from "react-router";
|
|
13
|
+
export declare const GatewayUri = "ankka://gateway";
|
|
14
|
+
export interface ConsoleServerOptions {
|
|
15
|
+
build?: ServerBuild | (() => Promise<ServerBuild>);
|
|
16
|
+
/** In place of `build`: any request listener. The server's own tests use it. */
|
|
17
|
+
requestListener?: (req: IncomingMessage, res: ServerResponse) => void;
|
|
18
|
+
/** The client build: `build/client/` beside the server build. */
|
|
19
|
+
clientDir: string | URL;
|
|
20
|
+
env?: Record<string, string | undefined>;
|
|
21
|
+
/** How long to wait for the control plane at startup before refusing to start. */
|
|
22
|
+
startupTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ConsoleServer {
|
|
25
|
+
app: Server;
|
|
26
|
+
probe?: Server;
|
|
27
|
+
url: string;
|
|
28
|
+
ready(): boolean;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function createConsoleServer(options: ConsoleServerOptions): Promise<ConsoleServer>;
|
|
32
|
+
/** Runs the server until SIGTERM or SIGINT, then drains it and exits. */
|
|
33
|
+
export declare function runConsoleServer(options: ConsoleServerOptions): Promise<void>;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console's process: the app on one port and readiness on another.
|
|
3
|
+
*
|
|
4
|
+
* In a cluster (`ANKKA_CONSOLE_TLS_DIR` set) the app port is TLS with the certificate cert-manager
|
|
5
|
+
* issued for the console, re-read when it is renewed, and it serves only a client presenting a
|
|
6
|
+
* certificate from the same authority that names the gateway, `ankka://gateway`: nothing reaches the
|
|
7
|
+
* console except through the installation's front door. Readiness is plain HTTP on its own port,
|
|
8
|
+
* because the kubelet holds no certificate, and says yes only once the certificate is loaded, the
|
|
9
|
+
* app port is bound and the control plane has answered. Locally it is plain HTTP on one port.
|
|
10
|
+
*/
|
|
11
|
+
import { createServer as createHttpServer } from "node:http";
|
|
12
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
13
|
+
import { createReadStream, statSync } from "node:fs";
|
|
14
|
+
import { extname, normalize, resolve, sep } from "node:path";
|
|
15
|
+
import { createSecureContext } from "node:tls";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { createRequestListener } from "@react-router/node";
|
|
18
|
+
import { ControlPlaneClient } from "../client/control-plane.js";
|
|
19
|
+
import { closeAllStreams } from "../stream/registry.js";
|
|
20
|
+
import { RotatingFiles, tlsFetch } from "./transport.js";
|
|
21
|
+
export const GatewayUri = "ankka://gateway";
|
|
22
|
+
const types = {
|
|
23
|
+
".js": "text/javascript; charset=utf-8",
|
|
24
|
+
".css": "text/css; charset=utf-8",
|
|
25
|
+
".svg": "image/svg+xml",
|
|
26
|
+
".png": "image/png",
|
|
27
|
+
".ico": "image/x-icon",
|
|
28
|
+
".json": "application/json",
|
|
29
|
+
".woff2": "font/woff2",
|
|
30
|
+
".txt": "text/plain; charset=utf-8",
|
|
31
|
+
};
|
|
32
|
+
/** Serves a file from the client build, or answers `false` for the app to handle the request. */
|
|
33
|
+
function staticFile(root, req, res) {
|
|
34
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
35
|
+
return false;
|
|
36
|
+
const path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
|
37
|
+
if (path === "/" || path.endsWith("/"))
|
|
38
|
+
return false;
|
|
39
|
+
const file = resolve(root, "." + normalize(path));
|
|
40
|
+
if (!file.startsWith(root + sep))
|
|
41
|
+
return false;
|
|
42
|
+
let size;
|
|
43
|
+
try {
|
|
44
|
+
const stat = statSync(file);
|
|
45
|
+
if (!stat.isFile())
|
|
46
|
+
return false;
|
|
47
|
+
size = stat.size;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
res.writeHead(200, {
|
|
53
|
+
"content-type": types[extname(file)] ?? "application/octet-stream",
|
|
54
|
+
"content-length": size,
|
|
55
|
+
// Fingerprinted by the build: they never change under the same name.
|
|
56
|
+
"cache-control": path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "public, max-age=300",
|
|
57
|
+
});
|
|
58
|
+
if (req.method === "HEAD")
|
|
59
|
+
res.end();
|
|
60
|
+
else
|
|
61
|
+
createReadStream(file).pipe(res);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
function peerNamesGateway(req) {
|
|
65
|
+
const socket = req.socket;
|
|
66
|
+
if (typeof socket.getPeerCertificate !== "function")
|
|
67
|
+
return false;
|
|
68
|
+
const names = socket.getPeerCertificate()?.subjectaltname ?? "";
|
|
69
|
+
return names.split(",").map((n) => n.trim()).includes(`URI:${GatewayUri}`);
|
|
70
|
+
}
|
|
71
|
+
async function waitForControlPlane(url, fetchImpl, timeoutMs) {
|
|
72
|
+
const client = new ControlPlaneClient({ baseUrl: url, bearer: async () => null, transport: fetchImpl });
|
|
73
|
+
const deadline = Date.now() + timeoutMs;
|
|
74
|
+
let last;
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
try {
|
|
77
|
+
await client.authDiscovery();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
last = e;
|
|
82
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`the control plane did not answer GET ${url}/auth: ${last instanceof Error ? last.message : String(last)}`);
|
|
86
|
+
}
|
|
87
|
+
export async function createConsoleServer(options) {
|
|
88
|
+
const env = options.env ?? process.env;
|
|
89
|
+
const tlsDir = env.ANKKA_CONSOLE_TLS_DIR?.trim();
|
|
90
|
+
const port = Number(env.ANKKA_CONSOLE_PORT ?? (tlsDir ? 9000 : 3000));
|
|
91
|
+
const probePort = env.ANKKA_CONSOLE_PROBE_PORT ? Number(env.ANKKA_CONSOLE_PROBE_PORT) : undefined;
|
|
92
|
+
const host = env.ANKKA_CONSOLE_HOST ?? (tlsDir ? "0.0.0.0" : "127.0.0.1");
|
|
93
|
+
const clientRoot = resolve(typeof options.clientDir === "string" ? options.clientDir : fileURLToPath(options.clientDir));
|
|
94
|
+
const cpUrl = env.ANKKA_CONSOLE_CONTROL_PLANE_URL ?? "http://localhost:9000";
|
|
95
|
+
const files = tlsDir ? { ca: `${tlsDir}/ca.crt`, cert: `${tlsDir}/tls.crt`, key: `${tlsDir}/tls.key` } : undefined;
|
|
96
|
+
if (tlsDir && !env.ANKKA_CONSOLE_AUTHORITY?.trim() && !options.requestListener) {
|
|
97
|
+
throw new Error("ANKKA_CONSOLE_AUTHORITY is not set: in a cluster the console must be told its own address");
|
|
98
|
+
}
|
|
99
|
+
let bound = false;
|
|
100
|
+
let controlPlaneAnswered = false;
|
|
101
|
+
let closing = false;
|
|
102
|
+
if (!options.build && !options.requestListener)
|
|
103
|
+
throw new Error("createConsoleServer needs the host's server build");
|
|
104
|
+
const listener = options.requestListener ??
|
|
105
|
+
createRequestListener({ build: options.build, mode: env.NODE_ENV === "development" ? "development" : "production" });
|
|
106
|
+
const handle = (req, res) => {
|
|
107
|
+
if (tlsDir && !peerNamesGateway(req)) {
|
|
108
|
+
res.writeHead(403, { "content-type": "text/plain" }).end("only the installation's gateway may call the console\n");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (staticFile(clientRoot, req, res))
|
|
112
|
+
return;
|
|
113
|
+
void listener(req, res);
|
|
114
|
+
};
|
|
115
|
+
let app;
|
|
116
|
+
let rotation;
|
|
117
|
+
if (files) {
|
|
118
|
+
const rotating = new RotatingFiles(files, 30_000);
|
|
119
|
+
const contextOf = () => {
|
|
120
|
+
const m = rotating.current();
|
|
121
|
+
return { version: m.version, context: createSecureContext({ key: m.key, cert: m.cert, ca: m.ca }) };
|
|
122
|
+
};
|
|
123
|
+
let current = contextOf();
|
|
124
|
+
const server = createHttpsServer({
|
|
125
|
+
key: rotating.current().key,
|
|
126
|
+
cert: rotating.current().cert,
|
|
127
|
+
ca: rotating.current().ca,
|
|
128
|
+
requestCert: true,
|
|
129
|
+
rejectUnauthorized: true,
|
|
130
|
+
minVersion: "TLSv1.2",
|
|
131
|
+
}, handle);
|
|
132
|
+
rotation = setInterval(() => {
|
|
133
|
+
const next = contextOf();
|
|
134
|
+
if (next.version !== current.version) {
|
|
135
|
+
current = next;
|
|
136
|
+
const m = rotating.current();
|
|
137
|
+
server.setSecureContext({ key: m.key, cert: m.cert, ca: m.ca, requestCert: true, rejectUnauthorized: true });
|
|
138
|
+
}
|
|
139
|
+
}, 30_000);
|
|
140
|
+
rotation.unref();
|
|
141
|
+
app = server;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
app = createHttpServer(handle);
|
|
145
|
+
}
|
|
146
|
+
// Streams send keepalives well inside this; a request that says nothing for longer is gone.
|
|
147
|
+
app.keepAliveTimeout = 65_000;
|
|
148
|
+
app.headersTimeout = 66_000;
|
|
149
|
+
await waitForControlPlane(cpUrl, tlsFetch(files), options.startupTimeoutMs ?? 60_000);
|
|
150
|
+
controlPlaneAnswered = true;
|
|
151
|
+
await new Promise((resolveListen, reject) => {
|
|
152
|
+
app.once("error", reject);
|
|
153
|
+
app.listen(port, host, () => {
|
|
154
|
+
bound = true;
|
|
155
|
+
resolveListen();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
const ready = () => bound && controlPlaneAnswered && !closing;
|
|
159
|
+
let probe;
|
|
160
|
+
if (probePort !== undefined) {
|
|
161
|
+
probe = createHttpServer((req, res) => {
|
|
162
|
+
if (req.url === "/ready" || req.url === "/healthz") {
|
|
163
|
+
const ok = req.url === "/healthz" ? !closing : ready();
|
|
164
|
+
res.writeHead(ok ? 200 : 503, { "content-type": "text/plain" }).end(ok ? "ok\n" : "not ready\n");
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
res.writeHead(404).end();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
await new Promise((r) => probe.listen(probePort, host, r));
|
|
171
|
+
}
|
|
172
|
+
const address = app.address();
|
|
173
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
174
|
+
const url = `${tlsDir ? "https" : "http"}://${host === "0.0.0.0" ? "localhost" : host}:${actualPort}`;
|
|
175
|
+
const close = async () => {
|
|
176
|
+
if (closing)
|
|
177
|
+
return;
|
|
178
|
+
closing = true;
|
|
179
|
+
if (rotation)
|
|
180
|
+
clearInterval(rotation);
|
|
181
|
+
// Streams are told first, so browsers reconnect to an instance that is staying.
|
|
182
|
+
closeAllStreams();
|
|
183
|
+
const closed = new Promise((r) => app.close(() => r()));
|
|
184
|
+
app.closeIdleConnections();
|
|
185
|
+
const deadline = new Promise((r) => setTimeout(() => (app.closeAllConnections(), r()), 10_000).unref());
|
|
186
|
+
await Promise.race([closed, deadline]);
|
|
187
|
+
if (probe)
|
|
188
|
+
await new Promise((r) => probe.close(() => r()));
|
|
189
|
+
};
|
|
190
|
+
return { app, probe, url, ready, close };
|
|
191
|
+
}
|
|
192
|
+
/** Runs the server until SIGTERM or SIGINT, then drains it and exits. */
|
|
193
|
+
export async function runConsoleServer(options) {
|
|
194
|
+
const server = await createConsoleServer(options);
|
|
195
|
+
process.stdout.write(JSON.stringify({ at: new Date().toISOString(), event: "listening", url: server.url }) + "\n");
|
|
196
|
+
const stop = async (signal) => {
|
|
197
|
+
process.stdout.write(JSON.stringify({ at: new Date().toISOString(), event: "stopping", signal }) + "\n");
|
|
198
|
+
await server.close();
|
|
199
|
+
process.exit(0);
|
|
200
|
+
};
|
|
201
|
+
process.once("SIGTERM", () => void stop("SIGTERM"));
|
|
202
|
+
process.once("SIGINT", () => void stop("SIGINT"));
|
|
203
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { consoleRoutes } from "../routes.ts";
|
|
2
|
+
export type { ConsoleRoutesOptions, RouteConfigEntry } from "../routes.ts";
|
|
3
|
+
export { consoleMiddleware } from "../middleware.ts";
|
|
4
|
+
export { consoleOptionsFromEnv } from "../options.ts";
|
|
5
|
+
export type { ConsoleOptions } from "../options.ts";
|
|
6
|
+
export { consoleContext, useConsoleContext } from "../context.ts";
|
|
7
|
+
export type { ConsoleContext } from "../context.ts";
|
|
8
|
+
export { SealedCookieSessionStore } from "../session/cookie-store.ts";
|
|
9
|
+
export type { Session, SessionStore } from "../session/cookie-store.ts";
|
|
10
|
+
export { SessionTokenSource } from "../session/token-source.ts";
|
|
11
|
+
export type { TokenSource } from "../session/token-source.ts";
|
|
12
|
+
export { createConsoleServer, runConsoleServer, GatewayUri } from "./create-console-server.ts";
|
|
13
|
+
export type { ConsoleServer, ConsoleServerOptions } from "./create-console-server.ts";
|
|
14
|
+
export { tlsFetch, RotatingFiles } from "./transport.ts";
|
|
15
|
+
export type { FetchLike, TlsFiles } from "./transport.ts";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The server-side entry point: mounting the console in a host, and running it.
|
|
2
|
+
// The routes table, for the host's `app/routes.ts`.
|
|
3
|
+
export { consoleRoutes } from "../routes.js";
|
|
4
|
+
// The middleware and its options, for the host's root route.
|
|
5
|
+
export { consoleMiddleware } from "../middleware.js";
|
|
6
|
+
export { consoleOptionsFromEnv } from "../options.js";
|
|
7
|
+
export { consoleContext, useConsoleContext } from "../context.js";
|
|
8
|
+
// Keeping sessions another way.
|
|
9
|
+
export { SealedCookieSessionStore } from "../session/cookie-store.js";
|
|
10
|
+
export { SessionTokenSource } from "../session/token-source.js";
|
|
11
|
+
// The process.
|
|
12
|
+
export { createConsoleServer, runConsoleServer, GatewayUri } from "./create-console-server.js";
|
|
13
|
+
export { tlsFetch, RotatingFiles } from "./transport.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface TlsFiles {
|
|
2
|
+
/** The authority to trust; none means the system's roots. */
|
|
3
|
+
ca?: string;
|
|
4
|
+
/** The certificate and key to present as a client. */
|
|
5
|
+
cert?: string;
|
|
6
|
+
key?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Reads a set of PEM files, and reads them again when any has changed since, at most once every `pollMs`. */
|
|
9
|
+
export declare class RotatingFiles {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(files: TlsFiles, pollMs?: number);
|
|
12
|
+
/** The current contents, and a number that changes whenever they do. */
|
|
13
|
+
current(): {
|
|
14
|
+
ca?: Buffer;
|
|
15
|
+
cert?: Buffer;
|
|
16
|
+
key?: Buffer;
|
|
17
|
+
version: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
|
|
21
|
+
/**
|
|
22
|
+
* `fetch`, over TLS material that is re-read on rotation. With no files it is the platform's own
|
|
23
|
+
* `fetch`. Bodies are buffered on the way out; every call this is used for is small JSON or a form.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tlsFetch(files: TlsFiles | undefined, pollMs?: number): FetchLike;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `fetch` for the console's outbound calls, over `node:https` so it can trust one authority and
|
|
3
|
+
* present one certificate — the control plane requires the console's own certificate in a cluster,
|
|
4
|
+
* and the identity provider's in-cluster address is verified against the installation's service
|
|
5
|
+
* authority. The certificate files are re-read when cert-manager rewrites them.
|
|
6
|
+
*/
|
|
7
|
+
import { request as httpRequest } from "node:http";
|
|
8
|
+
import { request as httpsRequest } from "node:https";
|
|
9
|
+
import { readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { Readable } from "node:stream";
|
|
11
|
+
/** Reads a set of PEM files, and reads them again when any has changed since, at most once every `pollMs`. */
|
|
12
|
+
export class RotatingFiles {
|
|
13
|
+
#files;
|
|
14
|
+
#pollMs;
|
|
15
|
+
#checkedAt = 0;
|
|
16
|
+
#stamp = "";
|
|
17
|
+
#loaded = {};
|
|
18
|
+
#version = 0;
|
|
19
|
+
constructor(files, pollMs = 30_000) {
|
|
20
|
+
this.#files = files;
|
|
21
|
+
this.#pollMs = pollMs;
|
|
22
|
+
}
|
|
23
|
+
/** The current contents, and a number that changes whenever they do. */
|
|
24
|
+
current() {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
if (now - this.#checkedAt >= this.#pollMs || this.#version === 0) {
|
|
27
|
+
this.#checkedAt = now;
|
|
28
|
+
const paths = [this.#files.ca, this.#files.cert, this.#files.key].filter((p) => !!p);
|
|
29
|
+
const stamp = paths.map((p) => `${p}:${statSync(p).mtimeMs}`).join("|");
|
|
30
|
+
if (stamp !== this.#stamp) {
|
|
31
|
+
this.#loaded = {
|
|
32
|
+
ca: this.#files.ca ? readFileSync(this.#files.ca) : undefined,
|
|
33
|
+
cert: this.#files.cert ? readFileSync(this.#files.cert) : undefined,
|
|
34
|
+
key: this.#files.key ? readFileSync(this.#files.key) : undefined,
|
|
35
|
+
};
|
|
36
|
+
this.#stamp = stamp;
|
|
37
|
+
this.#version += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { ...this.#loaded, version: this.#version };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `fetch`, over TLS material that is re-read on rotation. With no files it is the platform's own
|
|
45
|
+
* `fetch`. Bodies are buffered on the way out; every call this is used for is small JSON or a form.
|
|
46
|
+
*/
|
|
47
|
+
export function tlsFetch(files, pollMs) {
|
|
48
|
+
if (!files || (!files.ca && !files.cert))
|
|
49
|
+
return (url, init) => fetch(url, init);
|
|
50
|
+
const rotating = new RotatingFiles(files, pollMs);
|
|
51
|
+
return async (input, init = {}) => {
|
|
52
|
+
const url = new URL(String(input));
|
|
53
|
+
const material = rotating.current();
|
|
54
|
+
const headers = new Headers(init.headers);
|
|
55
|
+
let body;
|
|
56
|
+
if (init.body !== undefined && init.body !== null) {
|
|
57
|
+
body = Buffer.from(await new Response(init.body).arrayBuffer());
|
|
58
|
+
headers.set("content-length", String(body.length));
|
|
59
|
+
}
|
|
60
|
+
const options = {
|
|
61
|
+
method: init.method ?? "GET",
|
|
62
|
+
host: url.hostname,
|
|
63
|
+
port: url.port,
|
|
64
|
+
path: url.pathname + url.search,
|
|
65
|
+
headers: Object.fromEntries(headers.entries()),
|
|
66
|
+
ca: material.ca,
|
|
67
|
+
cert: material.cert,
|
|
68
|
+
key: material.key,
|
|
69
|
+
servername: url.hostname,
|
|
70
|
+
signal: init.signal ?? undefined,
|
|
71
|
+
};
|
|
72
|
+
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const req = send(options, (res) => {
|
|
75
|
+
const out = new Headers();
|
|
76
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
77
|
+
if (Array.isArray(v))
|
|
78
|
+
v.forEach((x) => out.append(k, x));
|
|
79
|
+
else if (v !== undefined)
|
|
80
|
+
out.set(k, v);
|
|
81
|
+
}
|
|
82
|
+
const status = res.statusCode ?? 502;
|
|
83
|
+
const nullBody = status === 204 || status === 304 || options.method === "HEAD";
|
|
84
|
+
resolve(new Response(nullBody ? null : Readable.toWeb(res), {
|
|
85
|
+
status,
|
|
86
|
+
statusText: res.statusMessage,
|
|
87
|
+
headers: out,
|
|
88
|
+
}));
|
|
89
|
+
});
|
|
90
|
+
req.on("error", reject);
|
|
91
|
+
if (body)
|
|
92
|
+
req.end(body);
|
|
93
|
+
else
|
|
94
|
+
req.end();
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** What a session is to the console: enough to obtain a fresh access token, and nothing more. */
|
|
2
|
+
export interface Session {
|
|
3
|
+
refreshToken: string;
|
|
4
|
+
/** When this value was written, in epoch seconds. */
|
|
5
|
+
issuedAt: number;
|
|
6
|
+
}
|
|
7
|
+
/** Where sessions are kept. The sealed cookie is the default; a host with a database may keep them there. */
|
|
8
|
+
export interface SessionStore {
|
|
9
|
+
read(request: Request): Promise<Session | null>;
|
|
10
|
+
/** Appends whatever the response needs — a `Set-Cookie`, typically — to `headers`. */
|
|
11
|
+
write(session: Session, headers: Headers): Promise<void>;
|
|
12
|
+
clear(headers: Headers): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface SealedCookieSessionStoreOptions {
|
|
15
|
+
secret: string;
|
|
16
|
+
/** `true` in a cluster: the cookie is `__Host-`-prefixed and `Secure`. */
|
|
17
|
+
secure: boolean;
|
|
18
|
+
/** The realm's longest SSO session, in seconds; a cookie older than this is not opened. Default 36000. */
|
|
19
|
+
maxAgeSeconds?: number;
|
|
20
|
+
name?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare class SealedCookieSessionStore implements SessionStore {
|
|
23
|
+
#private;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
constructor(options: SealedCookieSessionStoreOptions);
|
|
26
|
+
read(request: Request): Promise<Session | null>;
|
|
27
|
+
write(session: Session, headers: Headers): Promise<void>;
|
|
28
|
+
clear(headers: Headers): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** A sign-in in progress: what the callback needs to finish it, sealed into a short-lived cookie. */
|
|
31
|
+
export interface PendingLogin {
|
|
32
|
+
state: string;
|
|
33
|
+
nonce: string;
|
|
34
|
+
verifier: string;
|
|
35
|
+
returnTo: string;
|
|
36
|
+
iat: number;
|
|
37
|
+
}
|
|
38
|
+
/** A value shown on exactly one page after a redirect — a deploy token's secret, say. */
|
|
39
|
+
export declare class SealedFlash<T> {
|
|
40
|
+
#private;
|
|
41
|
+
readonly name: string;
|
|
42
|
+
constructor(options: {
|
|
43
|
+
secret: string;
|
|
44
|
+
secure: boolean;
|
|
45
|
+
name: string;
|
|
46
|
+
maxAgeSeconds: number;
|
|
47
|
+
});
|
|
48
|
+
write(value: T, headers: Headers): Promise<void>;
|
|
49
|
+
/** The value, if one was written and has not expired; the cookie is cleared either way. */
|
|
50
|
+
take(request: Request, headers: Headers): Promise<T | null>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions as sealed cookies. The console keeps no session store of its own: a session is the
|
|
3
|
+
* refresh token the identity provider issued, sealed into a cookie script cannot read, plus what the
|
|
4
|
+
* identity provider holds. Any instance can open any cookie, so any instance serves any session.
|
|
5
|
+
*/
|
|
6
|
+
import { cookieName, readCookie, serializeCookie } from "./cookies.js";
|
|
7
|
+
import { deriveKey, seal, unseal } from "./seal.js";
|
|
8
|
+
export class SealedCookieSessionStore {
|
|
9
|
+
name;
|
|
10
|
+
#secure;
|
|
11
|
+
#maxAge;
|
|
12
|
+
#key;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.name = cookieName(options.name ?? "ankka_console", options.secure);
|
|
15
|
+
this.#secure = options.secure;
|
|
16
|
+
this.#maxAge = options.maxAgeSeconds ?? 36_000;
|
|
17
|
+
this.#key = deriveKey(options.secret, "ankka-console session v1");
|
|
18
|
+
}
|
|
19
|
+
async read(request) {
|
|
20
|
+
const raw = readCookie(request, this.name);
|
|
21
|
+
if (!raw)
|
|
22
|
+
return null;
|
|
23
|
+
const value = await unseal(raw, await this.#key, this.name);
|
|
24
|
+
if (!value || value.v !== 1 || typeof value.rt !== "string")
|
|
25
|
+
return null;
|
|
26
|
+
if (Date.now() / 1000 - value.iat > this.#maxAge)
|
|
27
|
+
return null;
|
|
28
|
+
return { refreshToken: value.rt, issuedAt: value.iat };
|
|
29
|
+
}
|
|
30
|
+
async write(session, headers) {
|
|
31
|
+
const value = { v: 1, rt: session.refreshToken, iat: session.issuedAt };
|
|
32
|
+
const sealed = await seal(value, await this.#key, this.name);
|
|
33
|
+
headers.append("set-cookie", serializeCookie(this.name, sealed, { maxAge: this.#maxAge, secure: this.#secure }));
|
|
34
|
+
}
|
|
35
|
+
async clear(headers) {
|
|
36
|
+
headers.append("set-cookie", serializeCookie(this.name, "", { maxAge: 0, secure: this.#secure }));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A value shown on exactly one page after a redirect — a deploy token's secret, say. */
|
|
40
|
+
export class SealedFlash {
|
|
41
|
+
name;
|
|
42
|
+
#secure;
|
|
43
|
+
#maxAge;
|
|
44
|
+
#key;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.name = cookieName(options.name, options.secure);
|
|
47
|
+
this.#secure = options.secure;
|
|
48
|
+
this.#maxAge = options.maxAgeSeconds;
|
|
49
|
+
this.#key = deriveKey(options.secret, `ankka-console ${options.name} v1`);
|
|
50
|
+
}
|
|
51
|
+
async write(value, headers) {
|
|
52
|
+
const sealed = await seal({ value, iat: Math.floor(Date.now() / 1000) }, await this.#key, this.name);
|
|
53
|
+
headers.append("set-cookie", serializeCookie(this.name, sealed, { maxAge: this.#maxAge, secure: this.#secure }));
|
|
54
|
+
}
|
|
55
|
+
/** The value, if one was written and has not expired; the cookie is cleared either way. */
|
|
56
|
+
async take(request, headers) {
|
|
57
|
+
const raw = readCookie(request, this.name);
|
|
58
|
+
if (!raw)
|
|
59
|
+
return null;
|
|
60
|
+
headers.append("set-cookie", serializeCookie(this.name, "", { maxAge: 0, secure: this.#secure }));
|
|
61
|
+
const opened = await unseal(raw, await this.#key, this.name);
|
|
62
|
+
if (!opened || Date.now() / 1000 - opened.iat > this.#maxAge)
|
|
63
|
+
return null;
|
|
64
|
+
return opened.value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Reading and writing cookies on web `Request`/`Headers`, with the attributes the console needs. */
|
|
2
|
+
export declare function readCookie(request: Request, name: string): string | undefined;
|
|
3
|
+
export interface CookieAttributes {
|
|
4
|
+
maxAge: number;
|
|
5
|
+
secure: boolean;
|
|
6
|
+
/** `Lax` for the session: sent on top-level navigation to the console, never on a cross-site POST. */
|
|
7
|
+
sameSite?: "Lax" | "Strict";
|
|
8
|
+
path?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function serializeCookie(name: string, value: string, attributes: CookieAttributes): string;
|
|
11
|
+
/**
|
|
12
|
+
* The name a cookie gets. `__Host-` over HTTPS binds it to exactly this origin — no `Domain`, path
|
|
13
|
+
* `/`, `Secure` — which browsers enforce; a plain-HTTP development console cannot use the prefix.
|
|
14
|
+
*/
|
|
15
|
+
export declare function cookieName(base: string, secure: boolean): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Reading and writing cookies on web `Request`/`Headers`, with the attributes the console needs. */
|
|
2
|
+
export function readCookie(request, name) {
|
|
3
|
+
const header = request.headers.get("cookie");
|
|
4
|
+
if (!header)
|
|
5
|
+
return undefined;
|
|
6
|
+
for (const part of header.split(";")) {
|
|
7
|
+
const eq = part.indexOf("=");
|
|
8
|
+
if (eq < 0)
|
|
9
|
+
continue;
|
|
10
|
+
if (part.slice(0, eq).trim() === name)
|
|
11
|
+
return part.slice(eq + 1).trim();
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function serializeCookie(name, value, attributes) {
|
|
16
|
+
const parts = [
|
|
17
|
+
`${name}=${value}`,
|
|
18
|
+
`Path=${attributes.path ?? "/"}`,
|
|
19
|
+
`Max-Age=${Math.max(0, Math.floor(attributes.maxAge))}`,
|
|
20
|
+
"HttpOnly",
|
|
21
|
+
`SameSite=${attributes.sameSite ?? "Lax"}`,
|
|
22
|
+
];
|
|
23
|
+
if (attributes.secure)
|
|
24
|
+
parts.push("Secure");
|
|
25
|
+
return parts.join("; ");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The name a cookie gets. `__Host-` over HTTPS binds it to exactly this origin — no `Domain`, path
|
|
29
|
+
* `/`, `Secure` — which browsers enforce; a plain-HTTP development console cannot use the prefix.
|
|
30
|
+
*/
|
|
31
|
+
export function cookieName(base, secure) {
|
|
32
|
+
return secure ? `__Host-${base}` : base;
|
|
33
|
+
}
|