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