@lamalibre/create-lamaste 4.4.0 → 4.5.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.
Files changed (150) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +200 -16
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  28. package/vendor/docs/03-architecture/installer.md +4 -4
  29. package/vendor/docs/03-architecture/management-flow.md +3 -1
  30. package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
  31. package/vendor/docs/03-architecture/overview.md +13 -17
  32. package/vendor/docs/03-architecture/panel-client.md +6 -6
  33. package/vendor/docs/03-architecture/panel-server.md +3 -2
  34. package/vendor/docs/03-architecture/state-management.md +4 -8
  35. package/vendor/docs/03-architecture/system-overview.md +1 -1
  36. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
  37. package/vendor/docs/04-api-reference/app-host.md +3 -3
  38. package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
  39. package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
  42. package/vendor/docs/04-api-reference/tunnels.md +67 -29
  43. package/vendor/docs/04-api-reference/users.md +241 -45
  44. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  45. package/vendor/docs/05-operations/monitoring.md +39 -0
  46. package/vendor/docs/05-operations/uninstalling.md +2 -1
  47. package/vendor/docs/05-operations/upgrades.md +15 -6
  48. package/vendor/docs/06-reference/config-files.md +83 -189
  49. package/vendor/docs/06-reference/glossary.md +3 -3
  50. package/vendor/docs/06-reference/installer-flags.md +13 -13
  51. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  52. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  53. package/vendor/docs/gatekeeper-design.md +10 -8
  54. package/vendor/docs/identity-directory-design.md +48 -14
  55. package/vendor/docs/package.json +1 -1
  56. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  57. package/vendor/gatekeeper/dist/index.d.ts +2 -1
  58. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  59. package/vendor/gatekeeper/dist/index.js +2 -0
  60. package/vendor/gatekeeper/dist/index.js.map +1 -1
  61. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  62. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  63. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  64. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  65. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  66. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  68. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  70. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  72. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
  74. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  75. package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
  76. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  77. package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
  78. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  80. package/vendor/gatekeeper/dist/server/index.js +22 -48
  81. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  83. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
  85. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  88. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  89. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  90. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  91. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  92. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  93. package/vendor/gatekeeper/package.json +2 -2
  94. package/vendor/server/bin/lamaste-server.js +15 -1
  95. package/vendor/server/package.json +2 -2
  96. package/vendor/server/src/commands/plugin-identity.js +335 -0
  97. package/vendor/server/src/commands/plugins.js +99 -11
  98. package/vendor/server/src/commands/registry-credential.js +2 -1
  99. package/vendor/server/src/commands/tunnels.js +102 -2
  100. package/vendor/server/src/commands/uninstall.js +3 -1
  101. package/vendor/server/src/commands/users.js +358 -0
  102. package/vendor/server/src/plugin-approval.js +107 -5
  103. package/vendor/server/src/plugin-identity.js +382 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
  106. package/vendor/server-ui/dist/index.html +2 -2
  107. package/vendor/serverd/package.json +2 -2
  108. package/vendor/serverd/src/index.js +25 -1
  109. package/vendor/serverd/src/lib/audience.js +576 -0
  110. package/vendor/serverd/src/lib/authelia.js +78 -7
  111. package/vendor/serverd/src/lib/config.js +68 -43
  112. package/vendor/serverd/src/lib/directory.js +1113 -0
  113. package/vendor/serverd/src/lib/entitlements.js +517 -0
  114. package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
  115. package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
  116. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  117. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  118. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  119. package/vendor/serverd/src/lib/mtls.js +6 -2
  120. package/vendor/serverd/src/lib/nginx.js +279 -48
  121. package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
  122. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  123. package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
  124. package/vendor/serverd/src/lib/plugin-packages.js +30 -22
  125. package/vendor/serverd/src/lib/plugins.js +94 -15
  126. package/vendor/serverd/src/lib/registry-credential.js +46 -6
  127. package/vendor/serverd/src/lib/request-logging.js +19 -0
  128. package/vendor/serverd/src/lib/retained-data.js +50 -4
  129. package/vendor/serverd/src/lib/state.js +18 -0
  130. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  131. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  132. package/vendor/serverd/src/lib/user-caller.js +67 -0
  133. package/vendor/serverd/src/lib/user-surface.js +733 -0
  134. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  135. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  136. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  137. package/vendor/serverd/src/routes/invite.js +53 -12
  138. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  139. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  140. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  141. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  142. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  143. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  144. package/vendor/serverd/src/routes/management/users.js +290 -208
  145. package/vendor/serverd/src/routes/management.js +4 -0
  146. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  147. package/vendor/serverd/src/routes/user-access.js +17 -4
  148. package/vendor/serverd/src/routes/user-surface.js +69 -0
  149. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  150. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
@@ -0,0 +1,733 @@
1
+ /**
2
+ * Plugins' user surfaces on the relay (slice 6): which host serves an
3
+ * agent-hosted plugin's `/api/<route>/app/*`, and the panel's side of every
4
+ * such request.
5
+ *
6
+ * **Binding.** A tunnel is bound as a plugin's user surface (core
7
+ * `bindUserSurface`; `TunnelEntry.userSurface` in `tunnels.json`, read with
8
+ * core `tunnelUserSurfaceRoutes`): an enabled app tunnel, `authenticated` or
9
+ * `restricted`, carried by the agent that hosts the plugin, its route held
10
+ * by an installed agent-hosted plugin; a route on one host at most. A
11
+ * plugin may be bound before it declares `identity`: its host *serves* the
12
+ * route — locations that ask Gatekeeper and pass the request to the panel —
13
+ * only while the plugin's installed, approved version declares it and the
14
+ * tunnel's agent is the plugin's hosting agent ({@link servedUserSurfaceRoutes}).
15
+ * Before that requests go down the tunnel as before, so a plugin's
16
+ * switch-over is a plain update. Whether a route is served changes with the
17
+ * plugin's approval, update, reversal, hosting and uninstall, and with the
18
+ * tunnel's agent: each re-renders the vhosts it is bound to
19
+ * ({@link refreshUserSurfaceVhosts}, {@link refreshTunnelUserSurfaceVhosts}
20
+ * after a revocation; a move re-renders in core); an uninstall also
21
+ * releases the binding everywhere — and any binding left for a route no
22
+ * installed plugin holds ({@link releasePluginUserSurface}).
23
+ *
24
+ * **Requests.** The panel's user-surface context (`routes/user-surface.js`,
25
+ * core `userSurfacePlugin`) admits a request only with a relay assertion
26
+ * Gatekeeper signed for the panel ({@link verifyUserSurfaceRelay}),
27
+ * forwards it to the plugin's agent half on the internal tunnel of the
28
+ * route's hosting ({@link resolveUserSurfaceTarget} — only from a host its
29
+ * hosting agent carries, never while the hosting is down) as the
30
+ * directory's user (`lib/user-caller.js`), and checks `Origin` against the
31
+ * bound host ({@link userSurfaceOrigin}) — never the request's `Host`.
32
+ * Every response still being sent is re-checked every few seconds
33
+ * ({@link sweepOpenUserSurfaceResponses}) and ended as soon as its user may
34
+ * no longer use the plugin, holds other roles, or the host no longer serves
35
+ * the route.
36
+ *
37
+ * Audit: `tunnel.user-surface.bound` and `tunnel.user-surface.unbound`
38
+ * (with the reason: `unbound`, `tunnel-deleted`, `plugin-uninstalled`).
39
+ */
40
+
41
+ import crypto from 'node:crypto';
42
+ import {
43
+ NonceStore,
44
+ RELAY_ASSERTION_HEADER,
45
+ RELAY_PANEL_AUDIENCE,
46
+ RELAY_PANEL_PORT,
47
+ isAgentHostedPlugin,
48
+ tunnelUserSurfaceRoutes,
49
+ userSurfaceHostingAgent,
50
+ verifyRelayAssertion,
51
+ } from '@lamalibre/lamaste';
52
+ import {
53
+ TUNNEL_MAX_BODY_MB,
54
+ TunnelError,
55
+ bindUserSurface,
56
+ releaseUserSurfaceRoute,
57
+ unbindUserSurface,
58
+ } from '@lamalibre/lamaste/server';
59
+ import { httpError } from './app-error.js';
60
+ import { recordAuditBestEffort } from './audit.js';
61
+ import { getConfig } from './config.js';
62
+ import { installedVersionApproved } from './plugin-hooks.js';
63
+ import { findPluginByRoute } from './plugins.js';
64
+ import { relaySigner } from './relay-signing.js';
65
+ import { readTunnels } from './state.js';
66
+ import { tunnelCertbotDeps, tunnelNginxDeps, tunnelStateDeps } from './tunnel-deps.js';
67
+
68
+ /**
69
+ * The user surface's own rate limit (Fastify route config): per client
70
+ * address — nginx sets `X-Forwarded-For` to it and the panel trusts that
71
+ * one loopback hop — and plugin route. An application's page makes many
72
+ * requests; the panel's root limit (100/min) is for its administrators.
73
+ */
74
+ const USER_SURFACE_RATE_LIMIT = Object.freeze({
75
+ max: 1200,
76
+ timeWindow: '1 minute',
77
+ keyGenerator: (request) => `${request.ip} ${request.params?.route ?? ''}`,
78
+ });
79
+
80
+ /**
81
+ * The user surface's route config: its rate limit, and no CORS — the
82
+ * panel's CORS (for its own origins) never answers for a user surface, so
83
+ * no other origin reads a user's answers or gets a preflight through.
84
+ */
85
+ export const USER_SURFACE_ROUTE_CONFIG = Object.freeze({
86
+ rateLimit: USER_SURFACE_RATE_LIMIT,
87
+ cors: false,
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Which bound routes a host serves
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * The agent hosting installed plugin `plugin`, or null: its own hosting
96
+ * record names the agent, and `tunnels.json` agrees — that agent carries
97
+ * the route's internal tunnel (core `userSurfaceHostingAgent`, what
98
+ * Gatekeeper reads). A hosting whose agent was revoked (its internal
99
+ * tunnel then has no agent) hosts nothing here.
100
+ *
101
+ * @param {{ name: string, route: string | null }} plugin
102
+ * @param {readonly object[]} tunnels
103
+ * @returns {Promise<string | null>}
104
+ */
105
+ async function hostingAgentOf(plugin, tunnels) {
106
+ if (!plugin.route) return null;
107
+ const { ownHosting } = await import('./plugin-hosting.js');
108
+ const record = await ownHosting(plugin);
109
+ if (!record) return null;
110
+ const agent = userSurfaceHostingAgent(tunnels, plugin.route);
111
+ return agent !== null && agent === record.agentLabel ? agent : null;
112
+ }
113
+
114
+ /**
115
+ * The agent hosting the plugin that holds `route`, or null (core
116
+ * `UserSurfaceDeps.hostingAgent`: binding refuses a tunnel any other agent
117
+ * carries). Called under the tunnel lock: reads the state without taking it.
118
+ *
119
+ * @param {string} route
120
+ * @returns {Promise<string | null>}
121
+ */
122
+ async function userSurfaceHostingAgentOf(route) {
123
+ const plugin = await findPluginByRoute(route);
124
+ return plugin ? hostingAgentOf(plugin, await readTunnels()) : null;
125
+ }
126
+
127
+ /**
128
+ * Whether plugin `route`'s user surface is served on a host it is bound to
129
+ * — the tunnel carried by `agentLabel` — and why not: `not_installed` (no
130
+ * installed plugin holds the route), `no_identity` (its installed version
131
+ * declares none), `not_approved` (that version is not approved),
132
+ * `not_hosting_agent` (the tunnel's agent is not the plugin's hosting
133
+ * agent: the tunnel was moved or its agent revoked, or the plugin is hosted
134
+ * elsewhere or not at all). Not the agent half's protocol answer: the panel
135
+ * checks that per request (503 until it answered).
136
+ *
137
+ * @param {string} route
138
+ * @param {string | undefined} agentLabel the agent carrying the bound tunnel
139
+ * @param {readonly object[]} tunnels the tunnel state
140
+ * @returns {Promise<{ served: true, plugin: object } | { served: false, reason: 'not_installed' | 'no_identity' | 'not_approved' | 'not_hosting_agent' }>}
141
+ */
142
+ async function routeServing(route, agentLabel, tunnels) {
143
+ const plugin = await findPluginByRoute(route);
144
+ if (!plugin) return { served: false, reason: 'not_installed' };
145
+ if (!plugin.identity) return { served: false, reason: 'no_identity' };
146
+ // A version whose approval cannot even be judged is not approved.
147
+ if (!(await installedVersionApproved(plugin).catch(() => false))) {
148
+ return { served: false, reason: 'not_approved' };
149
+ }
150
+ const hosting = await hostingAgentOf(plugin, tunnels);
151
+ if (hosting === null || agentLabel !== hosting) {
152
+ return { served: false, reason: 'not_hosting_agent' };
153
+ }
154
+ return { served: true, plugin };
155
+ }
156
+
157
+ /**
158
+ * The body limit of the plugin's internal tunnel, in MiB — the default while it is not hosted.
159
+ *
160
+ * @param {object} plugin
161
+ * @param {readonly object[]} tunnels the tunnel state
162
+ */
163
+ async function internalTunnelBodyLimit(plugin, tunnels) {
164
+ const { ownHosting } = await import('./plugin-hosting.js');
165
+ const record = await ownHosting(plugin);
166
+ if (!record) return TUNNEL_MAX_BODY_MB.default;
167
+ const tunnel = tunnels.find((t) => t.id === record.tunnelId);
168
+ return tunnel?.maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default;
169
+ }
170
+
171
+ /**
172
+ * Of `routes` (bound to `tunnel`), the ones its vhost serves, in order,
173
+ * each with the body limit of its locations (the plugin's internal
174
+ * tunnel's). Core's `UserSurfaceDeps.servedRoutes` — called under the
175
+ * tunnel lock, so it reads the tunnel state without taking it.
176
+ *
177
+ * @param {readonly string[]} routes
178
+ * @param {{ agentLabel?: string }} tunnel the bound tunnel (its agent, as it is or is about to be)
179
+ * @returns {Promise<Array<{ route: string, maxBodySizeMb: number }>>}
180
+ */
181
+ export async function servedUserSurfaceRoutes(routes, tunnel) {
182
+ if (routes.length === 0) return [];
183
+ const tunnels = await readTunnels();
184
+ const served = [];
185
+ for (const route of routes) {
186
+ const serving = await routeServing(route, tunnel?.agentLabel, tunnels);
187
+ if (!serving.served) continue;
188
+ served.push({ route, maxBodySizeMb: await internalTunnelBodyLimit(serving.plugin, tunnels) });
189
+ }
190
+ return served;
191
+ }
192
+
193
+ /** What core's tunnel workflows ask the panel about user surfaces. */
194
+ export const userSurfaceDeps = Object.freeze({
195
+ servedRoutes: servedUserSurfaceRoutes,
196
+ hostingAgent: userSurfaceHostingAgentOf,
197
+ });
198
+
199
+ /**
200
+ * A tunnel as the panel lists it: with `userSurfaceStatus`, how each route
201
+ * bound to it is served — `{ route, served, reason }`, `reason` null when
202
+ * served — on a tunnel that has a binding.
203
+ *
204
+ * @template {object} T
205
+ * @param {T} tunnel
206
+ * @returns {Promise<T>}
207
+ */
208
+ export async function withUserSurfaceStatus(tunnel) {
209
+ const routes = tunnelUserSurfaceRoutes(tunnel);
210
+ if (routes.length === 0) return tunnel;
211
+ const tunnels = await readTunnels();
212
+ const status = [];
213
+ for (const route of routes) {
214
+ const serving = await routeServing(route, tunnel.agentLabel, tunnels);
215
+ status.push({ route, served: serving.served, reason: serving.served ? null : serving.reason });
216
+ }
217
+ return { ...tunnel, userSurfaceStatus: status };
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Binding
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * What core's binding workflows need from the panel — among them its domain
226
+ * and Let's Encrypt email, which a vhost rewrite needs.
227
+ */
228
+ function bindingOptions(id, route, logger) {
229
+ const config = getConfig();
230
+ if (!config.domain || !config.email) {
231
+ throw httpError('Domain and email must be configured', 400);
232
+ }
233
+ return {
234
+ id,
235
+ route,
236
+ domain: config.domain,
237
+ email: config.email,
238
+ nginx: tunnelNginxDeps,
239
+ certbot: tunnelCertbotDeps,
240
+ state: tunnelStateDeps,
241
+ userSurface: userSurfaceDeps,
242
+ logger,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Audit a change of `route`'s binding on `tunnel`. `plugin` defaults to the
248
+ * installed plugin that holds the route.
249
+ *
250
+ * @param {'tunnel.user-surface.bound' | 'tunnel.user-surface.unbound'} action
251
+ * @param {object} tunnel
252
+ * @param {string} route
253
+ * @param {{ actor: string, logger: import('pino').Logger, plugin?: string, reason?: string }} opts
254
+ */
255
+ async function auditBinding(action, tunnel, route, { actor, logger, plugin, reason }) {
256
+ await recordAuditBestEffort(
257
+ {
258
+ actor,
259
+ action,
260
+ plugin: plugin ?? (await findPluginByRoute(route))?.name ?? null,
261
+ details: {
262
+ tunnelId: tunnel.id,
263
+ fqdn: tunnel.fqdn ?? null,
264
+ route,
265
+ ...(reason === undefined ? {} : { reason }),
266
+ },
267
+ },
268
+ logger,
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Throw (a core `TunnelError`) unless an installed agent-hosted plugin holds
274
+ * `route` — what binding requires, and what a binding is checked against
275
+ * again once it is written.
276
+ *
277
+ * @param {string} route
278
+ */
279
+ async function assertBindableRoute(route) {
280
+ const plugin = await findPluginByRoute(route);
281
+ if (!plugin) {
282
+ throw new TunnelError(`No installed plugin holds the route '${route}'`, 'INVALID_SETTING');
283
+ }
284
+ if (!isAgentHostedPlugin(plugin)) {
285
+ throw new TunnelError(
286
+ `Plugin "${plugin.name}" is not agent-hosted — only an agent-hosted plugin has a user surface`,
287
+ 'INVALID_SETTING',
288
+ );
289
+ }
290
+ }
291
+
292
+ /** Whether an installed agent-hosted plugin holds `route`. */
293
+ async function isBindableRoute(route) {
294
+ return assertBindableRoute(route).then(
295
+ () => true,
296
+ () => false,
297
+ );
298
+ }
299
+
300
+ /**
301
+ * Bind plugin `route` as a user surface of tunnel `id`. The route must be
302
+ * held by an installed agent-hosted plugin — whether its installed version
303
+ * declares `identity` yet or not — hosted by the agent that carries the
304
+ * tunnel (core refuses with 400 otherwise). Idempotent. Throws a core
305
+ * `TunnelError` (or an `httpError`) to refuse.
306
+ *
307
+ * The plugin is checked under the tunnel lock, but its uninstall does not
308
+ * take that lock: should it be uninstalled while the binding is written,
309
+ * the binding is released again here (and by the uninstall's own release,
310
+ * which runs after its registry entry is gone) and the bind refused with
311
+ * 409 — no binding outlives the plugin it was made for.
312
+ *
313
+ * @param {string} id
314
+ * @param {string} route
315
+ * @param {{ actor: string, logger: import('pino').Logger }} opts
316
+ * @returns {Promise<{ changed: boolean, tunnel: object }>} the tunnel with its `userSurfaceStatus`
317
+ */
318
+ export async function bindTunnelUserSurface(id, route, { actor, logger }) {
319
+ const result = await bindUserSurface({
320
+ ...bindingOptions(id, route, logger),
321
+ assertRoute: assertBindableRoute,
322
+ });
323
+ if (result.changed && !(await isBindableRoute(route))) {
324
+ await pruneUserSurfaceBindings(route, { plugin: null, logger });
325
+ throw httpError(
326
+ `The plugin holding the route '${route}' was uninstalled while it was being bound — nothing is bound`,
327
+ 409,
328
+ { code: 'user_surface_conflict' },
329
+ );
330
+ }
331
+ if (result.changed) {
332
+ await auditBinding('tunnel.user-surface.bound', result.tunnel, route, { actor, logger });
333
+ }
334
+ return { changed: result.changed, tunnel: await withUserSurfaceStatus(result.tunnel) };
335
+ }
336
+
337
+ /**
338
+ * Unbind plugin `route` from tunnel `id`: requests for the route go down
339
+ * the tunnel again. Throws a core `TunnelError` (`NOT_FOUND` when it is not
340
+ * bound there) or an `httpError`.
341
+ *
342
+ * @param {string} id
343
+ * @param {string} route
344
+ * @param {{ actor: string, logger: import('pino').Logger }} opts
345
+ * @returns {Promise<{ tunnel: object }>} the tunnel with its `userSurfaceStatus`
346
+ */
347
+ export async function unbindTunnelUserSurface(id, route, { actor, logger }) {
348
+ const result = await unbindUserSurface(bindingOptions(id, route, logger));
349
+ await auditBinding('tunnel.user-surface.unbound', result.tunnel, route, {
350
+ actor,
351
+ logger,
352
+ reason: 'unbound',
353
+ });
354
+ return { tunnel: await withUserSurfaceStatus(result.tunnel) };
355
+ }
356
+
357
+ /**
358
+ * A deleted tunnel's bindings went with it: audit each.
359
+ *
360
+ * @param {object} tunnel the removed entry
361
+ * @param {{ actor: string, logger: import('pino').Logger }} opts
362
+ */
363
+ export async function auditDeletedTunnelUserSurface(tunnel, opts) {
364
+ for (const route of tunnelUserSurfaceRoutes(tunnel)) {
365
+ await auditBinding('tunnel.user-surface.unbound', tunnel, route, {
366
+ ...opts,
367
+ reason: 'tunnel-deleted',
368
+ });
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Following the plugins
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /** Replaces the reconcile's nginx and state access (tests: no nginx to read or write). */
377
+ let reconcileDepsOverride = null;
378
+
379
+ /** @param {object | null} deps the deps of `reconcileUserSurfaceVhosts` */
380
+ export function setUserSurfaceReconcileDepsForTests(deps) {
381
+ reconcileDepsOverride = deps;
382
+ }
383
+
384
+ async function reconcileVhostsOf(route, ids, logger) {
385
+ const { reconcileUserSurfaceVhosts } = await import('./vhost-reconcile.js');
386
+ // `undefined` leaves the reconcile its own deps.
387
+ return reconcileUserSurfaceVhosts(logger, route, { ids }, reconcileDepsOverride ?? undefined);
388
+ }
389
+
390
+ /**
391
+ * Tunnels `ids` lost their agent (its certificate was revoked: core
392
+ * `releaseAgentTunnels`): those bound as a user surface keep the binding
393
+ * but serve none of it — Gatekeeper and the panel refuse from the state
394
+ * already — and their vhosts lose its locations here. Best effort —
395
+ * logged, never thrown; the panel's start reconciles every vhost again.
396
+ *
397
+ * @param {readonly string[]} ids
398
+ * @param {import('pino').Logger} logger
399
+ */
400
+ export async function refreshTunnelUserSurfaceVhosts(ids, logger) {
401
+ if (!ids || ids.length === 0) return;
402
+ try {
403
+ await reconcileVhostsOf(null, ids, logger);
404
+ } catch (err) {
405
+ logger?.warn?.(
406
+ { err: err instanceof Error ? err.message : String(err) },
407
+ 'Could not bring the vhosts of released tunnels in line',
408
+ );
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Whether plugin `route`'s user surface is served may have changed — its
414
+ * version was approved, updated or reverted, it was hosted or unhosted (the
415
+ * body limit is its internal tunnel's): bring the vhosts of the tunnels it
416
+ * is bound to in line. Best effort — logged, never thrown; the panel's
417
+ * start reconciles every vhost again.
418
+ *
419
+ * @param {string | null | undefined} route
420
+ * @param {import('pino').Logger} logger
421
+ */
422
+ export async function refreshUserSurfaceVhosts(route, logger) {
423
+ if (!route) return;
424
+ try {
425
+ await reconcileVhostsOf(route, [], logger);
426
+ } catch (err) {
427
+ logger?.warn?.(
428
+ { route, err: err instanceof Error ? err.message : String(err) },
429
+ 'Could not bring the user-surface vhosts of a plugin in line',
430
+ );
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Release `route`'s binding from every tunnel — and, as a second check, the
436
+ * binding of every other route no installed agent-hosted plugin holds any
437
+ * more — the state first, so Gatekeeper and the panel refuse at once, then
438
+ * those tunnels' vhosts lose the locations. Audited per route and tunnel
439
+ * (`plugin-uninstalled`). Throws.
440
+ *
441
+ * @param {string} route
442
+ * @param {{ plugin: string | null, logger: import('pino').Logger }} opts
443
+ */
444
+ async function pruneUserSurfaceBindings(route, { plugin, logger }) {
445
+ const ids = new Set();
446
+ const release = async (r, owner) => {
447
+ const { released } = await releaseUserSurfaceRoute({
448
+ route: r,
449
+ state: tunnelStateDeps,
450
+ logger,
451
+ });
452
+ for (const tunnel of released) {
453
+ ids.add(tunnel.id);
454
+ await auditBinding('tunnel.user-surface.unbound', tunnel, r, {
455
+ actor: 'lamaste',
456
+ logger,
457
+ ...(owner ? { plugin: owner } : {}),
458
+ reason: 'plugin-uninstalled',
459
+ });
460
+ }
461
+ };
462
+ await release(route, plugin);
463
+ const orphans = new Set();
464
+ for (const tunnel of await readTunnels()) {
465
+ for (const r of tunnelUserSurfaceRoutes(tunnel)) {
466
+ if (r !== route && !(await isBindableRoute(r))) orphans.add(r);
467
+ }
468
+ }
469
+ for (const r of orphans) await release(r, null);
470
+ if (ids.size > 0) await reconcileVhostsOf(route, [...ids], logger);
471
+ }
472
+
473
+ /**
474
+ * Plugin `plugin`, which held `route`, is uninstalled — its registry entry
475
+ * is gone: the route's binding goes from every tunnel, and so does any
476
+ * binding left for a route no installed plugin holds (a bind that raced an
477
+ * uninstall) — the state first, so Gatekeeper and the panel refuse the
478
+ * route at once — then those tunnels' vhosts lose its locations. Best
479
+ * effort — logged, never thrown: the uninstall happened.
480
+ *
481
+ * @param {string | null | undefined} route
482
+ * @param {{ plugin: string, logger: import('pino').Logger }} opts
483
+ */
484
+ export async function releasePluginUserSurface(route, { plugin, logger }) {
485
+ if (!route) return;
486
+ try {
487
+ await pruneUserSurfaceBindings(route, { plugin, logger });
488
+ } catch (err) {
489
+ logger?.error?.(
490
+ { route, err: err instanceof Error ? err.message : String(err) },
491
+ 'Could not release the user surface of an uninstalled plugin',
492
+ );
493
+ }
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Requests
498
+ // ---------------------------------------------------------------------------
499
+
500
+ /** One per process: a relay assertion is accepted once. */
501
+ const relayNonces = new NonceStore();
502
+
503
+ /** The verification key of the relay signing key, by key id (the current key only). */
504
+ const verifyKeys = new Map();
505
+
506
+ /**
507
+ * The keys a relay assertion for the panel verifies with: the public half of
508
+ * the relay signing key — the file Gatekeeper signs with — re-read when it
509
+ * changes. Null when there is none; throws when it is unusable.
510
+ */
511
+ async function panelVerifyKeys() {
512
+ const key = await relaySigner.current();
513
+ if (!key) return null;
514
+ if (!verifyKeys.has(key.kid)) {
515
+ verifyKeys.clear();
516
+ verifyKeys.set(key.kid, crypto.createPublicKey(key.privateKey));
517
+ }
518
+ return verifyKeys;
519
+ }
520
+
521
+ /**
522
+ * The relay check of a user-surface request (core `userSurfacePlugin`'s
523
+ * `verifyRelay`): an assertion Gatekeeper signed for the panel
524
+ * (`RELAY_PANEL_AUDIENCE`, the panel's port), bound to this request's
525
+ * method and raw request target, not expired, never seen before. Returns
526
+ * the username Gatekeeper attested and the route the assertion is for.
527
+ *
528
+ * @param {import('fastify').FastifyRequest} request
529
+ * @returns {Promise<{ ok: true, username: string, route: string } | { ok: false, reason: string }>}
530
+ */
531
+ export async function verifyUserSurfaceRelay(request) {
532
+ let keys;
533
+ try {
534
+ keys = await panelVerifyKeys();
535
+ } catch (err) {
536
+ request.log.error(
537
+ { err: err instanceof Error ? err.message : String(err) },
538
+ 'Relay signing key unusable — user-surface requests are refused',
539
+ );
540
+ return { ok: false, reason: 'relay_key_unusable' };
541
+ }
542
+ if (!keys) return { ok: false, reason: 'relay_key_missing' };
543
+ const header = request.headers[RELAY_ASSERTION_HEADER];
544
+ const result = verifyRelayAssertion(typeof header === 'string' ? header : undefined, {
545
+ keys,
546
+ aud: RELAY_PANEL_AUDIENCE,
547
+ port: RELAY_PANEL_PORT,
548
+ method: request.method,
549
+ url: request.raw.url ?? '',
550
+ nonces: relayNonces,
551
+ });
552
+ if (!result.ok) {
553
+ request.log.info({ reason: result.reason }, 'User-surface request without a valid assertion');
554
+ return { ok: false, reason: result.reason };
555
+ }
556
+ const { caller, target } = result.payload;
557
+ // A panel-audience payload is a username and a route with its path.
558
+ if (caller.type !== 'user' || !('route' in target)) return { ok: false, reason: 'malformed' };
559
+ return { ok: true, username: caller.username, route: target.route };
560
+ }
561
+
562
+ /**
563
+ * The enabled tunnel whose host serves `route`'s user surface, or null:
564
+ * bound to the route, and carried by the agent hosting its plugin — the
565
+ * check of every request, whatever a vhost not yet re-rendered still
566
+ * routes here.
567
+ *
568
+ * @param {string} route
569
+ */
570
+ async function servedUserSurfaceTunnel(route) {
571
+ const tunnels = await readTunnels();
572
+ const tunnel =
573
+ tunnels.find(
574
+ (t) =>
575
+ t.enabled !== false &&
576
+ typeof t.fqdn === 'string' &&
577
+ tunnelUserSurfaceRoutes(t).includes(route),
578
+ ) ?? null;
579
+ if (!tunnel) return null;
580
+ const plugin = await findPluginByRoute(route);
581
+ if (!plugin) return null;
582
+ const hosting = await hostingAgentOf(plugin, tunnels);
583
+ return hosting !== null && tunnel.agentLabel === hosting ? tunnel : null;
584
+ }
585
+
586
+ /**
587
+ * The forward target of `route`'s user surface (core `userSurfacePlugin`'s
588
+ * `resolveTarget`): null when no enabled tunnel its hosting agent carries
589
+ * is bound to it, else what the admin forwarder resolves — the hosting's
590
+ * internal tunnel, or why not (not found, disabled, not hosted) — and
591
+ * `down` while the hosting's health is down: nothing of a user's request
592
+ * reaches the relay-loopback port then (503).
593
+ *
594
+ * @param {string} route
595
+ */
596
+ export async function resolveUserSurfaceTarget(route) {
597
+ if (!(await servedUserSurfaceTunnel(route))) return null;
598
+ const { resolveForwardTarget } = await import('./plugin-hosting.js');
599
+ return resolveForwardTarget(route);
600
+ }
601
+
602
+ /**
603
+ * The origin a request that may change something on `route`'s user surface
604
+ * must come from (core `userSurfacePlugin`'s `originFor`): the bound host,
605
+ * as the tunnel state names it — never the request's `Host`. Null when no
606
+ * enabled tunnel its hosting agent carries is bound to the route.
607
+ *
608
+ * @param {string} route
609
+ * @returns {Promise<string | null>}
610
+ */
611
+ export async function userSurfaceOrigin(route) {
612
+ const tunnel = await servedUserSurfaceTunnel(route);
613
+ return tunnel ? `https://${tunnel.fqdn.toLowerCase()}` : null;
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Open responses: none outlives its user's access
618
+ // ---------------------------------------------------------------------------
619
+
620
+ /** How often every open user-surface response is re-checked. */
621
+ const OPEN_RESPONSE_SWEEP_INTERVAL_MS = 5_000;
622
+
623
+ /** The responses being sent now (core `OpenUserSurfaceResponse`). */
624
+ const openResponses = new Set();
625
+
626
+ let sweepTimer = null;
627
+ let sweeping = null;
628
+ let sweepLogger = null;
629
+
630
+ /**
631
+ * Core `userSurfacePlugin`'s `trackResponse`, logging with `logger`: keep
632
+ * each response until it is over (the function it returns), and re-check it
633
+ * every {@link OPEN_RESPONSE_SWEEP_INTERVAL_MS} meanwhile. The timer runs
634
+ * only while a response is open, and never keeps the process alive.
635
+ *
636
+ * @param {import('pino').Logger} logger
637
+ * @returns {(response: import('@lamalibre/lamaste/server').OpenUserSurfaceResponse) => () => void}
638
+ */
639
+ export function userSurfaceResponseTracker(logger) {
640
+ sweepLogger = logger;
641
+ return trackUserSurfaceResponse;
642
+ }
643
+
644
+ /**
645
+ * Keep `response` until it is over (the returned function) — see
646
+ * {@link userSurfaceResponseTracker}.
647
+ *
648
+ * @param {import('@lamalibre/lamaste/server').OpenUserSurfaceResponse} response
649
+ * @returns {() => void}
650
+ */
651
+ export function trackUserSurfaceResponse(response) {
652
+ openResponses.add(response);
653
+ if (!sweepTimer) {
654
+ sweepTimer = setInterval(() => {
655
+ void sweepOpenUserSurfaceResponses(sweepLogger ?? undefined);
656
+ }, OPEN_RESPONSE_SWEEP_INTERVAL_MS);
657
+ sweepTimer.unref();
658
+ }
659
+ return () => {
660
+ openResponses.delete(response);
661
+ if (openResponses.size === 0 && sweepTimer) {
662
+ clearInterval(sweepTimer);
663
+ sweepTimer = null;
664
+ }
665
+ };
666
+ }
667
+
668
+ /** How many user-surface responses are open now (tests, diagnostics). */
669
+ export function openUserSurfaceResponseCount() {
670
+ return openResponses.size;
671
+ }
672
+
673
+ function sameRoles(a, b) {
674
+ if (a.length !== b.length) return false;
675
+ const set = new Set(a);
676
+ return b.every((role) => set.has(role));
677
+ }
678
+
679
+ /**
680
+ * Re-check every open user-surface response and end those that may not go
681
+ * on: its user is no longer allowed on the plugin (`lib/user-caller.js`:
682
+ * disabled, removed, no longer entitled, the plugin not ready — or another
683
+ * user now holds the username) — `access_revoked`; holds other roles than
684
+ * the response was started with — `roles_changed`; or the route is no
685
+ * longer served on its host (unbound, the tunnel disabled or moved, the
686
+ * plugin unhosted) — `surface_withdrawn`. Each user and route is
687
+ * resolved once per sweep. One sweep at a time; a failed check keeps the
688
+ * response (the next sweep tries again) and is logged by the request's own
689
+ * line when it ends. Returns how many it ended.
690
+ *
691
+ * @param {import('pino').Logger} [logger]
692
+ * @returns {Promise<number>}
693
+ */
694
+ export async function sweepOpenUserSurfaceResponses(logger) {
695
+ if (sweeping) return sweeping;
696
+ sweeping = (async () => {
697
+ const { resolveUserCaller } = await import('./user-caller.js');
698
+ const callers = new Map();
699
+ const served = new Map();
700
+ let ended = 0;
701
+ for (const response of [...openResponses]) {
702
+ const { route, caller } = response;
703
+ let verdict;
704
+ try {
705
+ if (!served.has(route)) served.set(route, (await servedUserSurfaceTunnel(route)) !== null);
706
+ const key = `${route}\n${caller.username}`;
707
+ if (!callers.has(key)) callers.set(key, await resolveUserCaller(route, caller.username));
708
+ const resolution = callers.get(key);
709
+ if (!resolution.ok || resolution.caller.userId !== caller.userId) {
710
+ verdict = 'access_revoked';
711
+ } else if (!sameRoles(resolution.caller.roles, caller.roles)) {
712
+ verdict = 'roles_changed';
713
+ } else if (!served.get(route)) verdict = 'surface_withdrawn';
714
+ } catch (err) {
715
+ logger?.warn?.(
716
+ { route, err: err instanceof Error ? err.message : String(err) },
717
+ 'Could not re-check an open user-surface response',
718
+ );
719
+ continue;
720
+ }
721
+ if (verdict) {
722
+ response.end(verdict);
723
+ ended += 1;
724
+ }
725
+ }
726
+ return ended;
727
+ })();
728
+ try {
729
+ return await sweeping;
730
+ } finally {
731
+ sweeping = null;
732
+ }
733
+ }