@lamalibre/create-lamaste 4.5.0 → 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 (105) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +34 -2
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +2 -2
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +5 -5
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/02-guides/static-sites.md +4 -0
  19. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  20. package/vendor/docs/03-architecture/installer.md +4 -4
  21. package/vendor/docs/03-architecture/management-flow.md +1 -1
  22. package/vendor/docs/03-architecture/nginx-configuration.md +65 -7
  23. package/vendor/docs/03-architecture/overview.md +5 -5
  24. package/vendor/docs/03-architecture/panel-client.md +6 -6
  25. package/vendor/docs/03-architecture/panel-server.md +2 -2
  26. package/vendor/docs/03-architecture/state-management.md +2 -2
  27. package/vendor/docs/03-architecture/system-overview.md +1 -1
  28. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +112 -37
  29. package/vendor/docs/04-api-reference/app-host.md +3 -3
  30. package/vendor/docs/04-api-reference/gatekeeper.md +19 -1
  31. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  32. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  33. package/vendor/docs/04-api-reference/sites.md +4 -0
  34. package/vendor/docs/04-api-reference/tunnels.md +21 -19
  35. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  36. package/vendor/docs/05-operations/uninstalling.md +1 -1
  37. package/vendor/docs/05-operations/upgrades.md +15 -6
  38. package/vendor/docs/06-reference/config-files.md +19 -15
  39. package/vendor/docs/06-reference/glossary.md +3 -3
  40. package/vendor/docs/06-reference/installer-flags.md +13 -13
  41. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  42. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  43. package/vendor/docs/gatekeeper-design.md +8 -8
  44. package/vendor/docs/identity-directory-design.md +3 -3
  45. package/vendor/docs/package.json +1 -1
  46. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  47. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  48. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  49. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  50. package/vendor/gatekeeper/dist/index.js +2 -2
  51. package/vendor/gatekeeper/dist/index.js.map +1 -1
  52. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  53. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  54. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  55. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  56. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  57. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  58. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  59. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  60. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  61. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  63. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  65. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  67. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  68. package/vendor/gatekeeper/dist/server/index.js +29 -10
  69. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  70. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/server/routes/authz.js +103 -22
  72. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  73. package/vendor/gatekeeper/package.json +2 -2
  74. package/vendor/server/package.json +2 -2
  75. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  76. package/vendor/server/src/commands/plugins.js +63 -6
  77. package/vendor/server/src/commands/sites.js +15 -1
  78. package/vendor/server/src/plugin-approval.js +80 -1
  79. package/vendor/server/src/plugin-web-surface.js +307 -0
  80. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  81. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  82. package/vendor/server-ui/dist/index.html +2 -2
  83. package/vendor/serverd/package.json +2 -2
  84. package/vendor/serverd/src/index.js +9 -4
  85. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  86. package/vendor/serverd/src/lib/chisel.js +10 -2
  87. package/vendor/serverd/src/lib/gatekeeper-client.js +18 -7
  88. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  89. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  90. package/vendor/serverd/src/lib/nginx.js +269 -2
  91. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  92. package/vendor/serverd/src/lib/plugin-lifecycle.js +128 -5
  93. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  94. package/vendor/serverd/src/lib/plugins.js +39 -6
  95. package/vendor/serverd/src/lib/state.js +18 -0
  96. package/vendor/serverd/src/lib/user-surface.js +48 -27
  97. package/vendor/serverd/src/lib/vhost-reconcile.js +5 -3
  98. package/vendor/serverd/src/lib/web-surface.js +766 -0
  99. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  100. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  101. package/vendor/serverd/src/routes/management/sites.js +143 -65
  102. package/vendor/serverd/src/routes/management/tunnels.js +11 -20
  103. package/vendor/serverd/src/routes/management.js +2 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -0,0 +1,766 @@
1
+ /**
2
+ * Plugins' web surfaces on the relay (slice 7): the static shell an
3
+ * agent-hosted plugin ships in its server package (`userSurface.web`),
4
+ * served on a site the plugin owns beside its user surface's
5
+ * `/api/<route>/app/*` — one host, one Authelia session.
6
+ *
7
+ * **Trees.** The relay serves only the approved web tree of the installed
8
+ * version: once that version is approved (and after every update, revert or
9
+ * restart) its tree is copied out of the installed package into the store
10
+ * (core `stageWebTree`, checked against the approved digest) and switched
11
+ * to (core `switchWebTree`: `current` and `previous` replaced by a rename)
12
+ * — {@link syncWebSurfaceTree}. Until then the store keeps serving what it
13
+ * served.
14
+ *
15
+ * **The site.** A plugin owns at most one site (`owner: { plugin }` in
16
+ * sites.json), created on its declared subdomain once an administrator
17
+ * confirms ({@link createWebSurface}) or taken over from the tunnel bound
18
+ * as its user surface ({@link moveWebSurfaceFromTunnel}), and deleted
19
+ * ({@link deleteWebSurface}). Its files are never uploaded: the files API
20
+ * and `lamaste-agent deploy` refuse it. Its vhost serves the shell and the
21
+ * API while the surface is served ({@link webSurfaceServing}: the installed
22
+ * version approved, declaring identity and a web surface, hosted, its tree
23
+ * current) and answers 503 otherwise; {@link refreshWebSurface} brings it
24
+ * in line whenever that may have changed.
25
+ *
26
+ * **Locks.** Giving a site a hostname takes core's tunnel lock, then the
27
+ * sites lock (`withSitesLock`), as every such workflow does.
28
+ *
29
+ * Audit: `plugin.web-surface.created`, `.deleted`, `.moved`,
30
+ * `plugin.web-tree.switched`.
31
+ */
32
+
33
+ import crypto from 'node:crypto';
34
+ import {
35
+ KeyedPromiseChainMutex,
36
+ isWebSurfaceSubdomain,
37
+ siteUserSurfaceRoute,
38
+ tunnelUserSurfaceRoutes,
39
+ WebTreeError,
40
+ } from '@lamalibre/lamaste';
41
+ import {
42
+ TUNNEL_MAX_BODY_MB,
43
+ WEB_TREES_ROOT,
44
+ readWebTreeStore,
45
+ removeWebTreeStore,
46
+ stageWebTree,
47
+ switchWebTree,
48
+ webTreeStoreDir,
49
+ withTunnelLock,
50
+ } from '@lamalibre/lamaste/server';
51
+ import { httpError } from './app-error.js';
52
+ import { recordAuditBestEffort } from './audit.js';
53
+ import { getCertPath, issueTunnelCert } from './certbot.js';
54
+ import { syncChisel } from './chisel.js';
55
+ import { getConfig } from './config.js';
56
+ import {
57
+ createGatekeeperGrant,
58
+ listResourceGrants,
59
+ revokeGatekeeperGrant,
60
+ } from './gatekeeper-client.js';
61
+ import {
62
+ installVhost,
63
+ moveAppVhostToWebSurface,
64
+ readInstalledVhost,
65
+ reload as reloadNginx,
66
+ removeStaticSiteVhost,
67
+ renderWebSurfaceVhost,
68
+ unlinkMovedAppVhost,
69
+ vhostCertDir,
70
+ } from './nginx.js';
71
+ import { installedVersionApproved } from './plugin-hooks.js';
72
+ import { installedWebTreeDir } from './plugin-packages.js';
73
+ import { findPluginByRoute } from './plugins.js';
74
+ import { readSites, readTunnels, withSitesLock, writeSites, writeTunnels } from './state.js';
75
+ import { hostingAgentOf, internalTunnelBodyLimit } from './user-surface.js';
76
+
77
+ /** Where the web trees live (a test points it elsewhere). */
78
+ const TREES_ROOT = process.env.LAMALIBRE_LAMASTE_WEB_TREES_ROOT || WEB_TREES_ROOT;
79
+
80
+ /** The tree store of plugin `route`. */
81
+ export function webTreesDir(route) {
82
+ return webTreeStoreDir(route, TREES_ROOT);
83
+ }
84
+
85
+ /** What the workflows below do to nginx, certificates and chisel (tests replace them). */
86
+ const defaultDeps = Object.freeze({
87
+ getCertPath,
88
+ issueTunnelCert,
89
+ installVhost,
90
+ moveAppVhostToWebSurface,
91
+ readInstalledVhost,
92
+ reloadNginx,
93
+ removeStaticSiteVhost,
94
+ syncChisel,
95
+ unlinkMovedAppVhost,
96
+ });
97
+ let deps = defaultDeps;
98
+
99
+ /** @param {Partial<typeof defaultDeps> | null} override */
100
+ export function setWebSurfaceDepsForTests(override) {
101
+ deps = override ? { ...defaultDeps, ...override } : defaultDeps;
102
+ }
103
+
104
+ const siteVhostName = (site) => `lamalibre-lamaste-site-${site.id}`;
105
+
106
+ /** One change of a plugin's tree store at a time (a copy, a switch, a removal). */
107
+ const treeMutex = new KeyedPromiseChainMutex();
108
+
109
+ /** The site plugin `route` owns among `sites`, or null. */
110
+ export function ownedSiteOf(route, sites) {
111
+ return sites.find((site) => siteUserSurfaceRoute(site) === route) ?? null;
112
+ }
113
+
114
+ /**
115
+ * Whether `site` was created for the installed plugin `plugin` — its route
116
+ * and its package. A site outlives its plugin's uninstall; a different
117
+ * package installed later under the same route does not inherit it (its
118
+ * hostname, its grants, the old tree) — an administrator deletes it first.
119
+ */
120
+ function siteOwnedBy(site, plugin) {
121
+ return siteUserSurfaceRoute(site) === plugin.route && site.owner?.package === plugin.packageName;
122
+ }
123
+
124
+ function refuse(message, statusCode, code) {
125
+ return httpError(message, statusCode, { code });
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Trees
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Serve the approved web tree of the version of plugin `route` installed
134
+ * now: copy it out of the installed package into the store when it is not
135
+ * there (checked against the approved digest), then switch `current` to
136
+ * it. The plugin is read inside the store's lock, so two refreshes never
137
+ * switch to an older version's tree. Nothing happens while the version
138
+ * declares no web surface or is not approved, or — unless `withoutSite`
139
+ * (a site being created for it) — while the plugin owns no site: the
140
+ * store keeps serving what it served, and a deleted store stays deleted.
141
+ * Returns whether `current` moved. Throws.
142
+ *
143
+ * @param {string} route
144
+ * @param {{ logger?: import('pino').Logger, withoutSite?: boolean }} [opts]
145
+ */
146
+ export async function syncWebSurfaceTree(route, { logger, withoutSite = false } = {}) {
147
+ const switched = await treeMutex.run(route, async () => {
148
+ const plugin = await findPluginByRoute(route);
149
+ if (!plugin?.userSurface?.web || !plugin.webTreeSha256) return null;
150
+ if (!withoutSite) {
151
+ const site = ownedSiteOf(route, await readSites());
152
+ if (!site || !siteOwnedBy(site, plugin)) return null;
153
+ }
154
+ if (!(await installedVersionApproved(plugin).catch(() => false))) return null;
155
+ const dir = webTreesDir(route);
156
+ const source = await installedWebTreeDir(plugin.packageName, plugin);
157
+ await stageWebTree(dir, source, plugin.webTreeSha256);
158
+ return (await switchWebTree(dir, plugin.webTreeSha256)) ? plugin : null;
159
+ });
160
+ if (switched) {
161
+ await recordAuditBestEffort(
162
+ {
163
+ actor: 'lamaste',
164
+ action: 'plugin.web-tree.switched',
165
+ plugin: switched.name,
166
+ details: { version: switched.version, treeSha256: switched.webTreeSha256 },
167
+ },
168
+ logger,
169
+ );
170
+ }
171
+ return switched !== null;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Serving
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Whether plugin `route`'s web surface is served — its shell and API on the
180
+ * site it owns — and why not: `not_installed`, `no_identity`,
181
+ * `no_web_surface` (its installed version declares none), `not_owner` (its
182
+ * route's site was created for another package), `not_approved`,
183
+ * `not_hosted`, `tree_not_current` (the approved tree is not the one the
184
+ * store serves yet).
185
+ *
186
+ * @param {string} route
187
+ * @param {readonly object[]} tunnels the tunnel state
188
+ * @returns {Promise<{ served: true, plugin: object } | { served: false, reason: string, plugin: object | null }>}
189
+ */
190
+ export async function webSurfaceServing(route, tunnels) {
191
+ const plugin = await findPluginByRoute(route);
192
+ if (!plugin) return { served: false, reason: 'not_installed', plugin: null };
193
+ if (!plugin.identity) return { served: false, reason: 'no_identity', plugin };
194
+ if (!plugin.userSurface?.web) return { served: false, reason: 'no_web_surface', plugin };
195
+ const site = ownedSiteOf(route, await readSites());
196
+ if (site && !siteOwnedBy(site, plugin)) return { served: false, reason: 'not_owner', plugin };
197
+ if (!(await installedVersionApproved(plugin).catch(() => false))) {
198
+ return { served: false, reason: 'not_approved', plugin };
199
+ }
200
+ if ((await hostingAgentOf(plugin, tunnels)) === null) {
201
+ return { served: false, reason: 'not_hosted', plugin };
202
+ }
203
+ const { current } = await readWebTreeStore(webTreesDir(route));
204
+ if (current !== plugin.webTreeSha256) {
205
+ return { served: false, reason: 'tree_not_current', plugin };
206
+ }
207
+ return { served: true, plugin };
208
+ }
209
+
210
+ /**
211
+ * The vhost of the site `site` (owned by plugin `route`) as it should be
212
+ * now, on certificate directory `certDir`.
213
+ */
214
+ async function renderOwnedSite(site, route, certDir, tunnels) {
215
+ const serving = await webSurfaceServing(route, tunnels);
216
+ const maxBodySizeMb = serving.plugin
217
+ ? await internalTunnelBodyLimit(serving.plugin, tunnels)
218
+ : undefined;
219
+ return renderWebSurfaceVhost(site, certDir, getConfig().domain, {
220
+ route,
221
+ served: serving.served,
222
+ maxBodySizeMb: maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default,
223
+ treesDir: webTreesDir(route),
224
+ clearSiteData: !serving.served && serving.reason === 'not_installed',
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Rewrite the vhost of the site plugin `route` owns when what it should be
230
+ * differs from what is installed (rendered on the certificate the installed
231
+ * vhost names). Returns whether it wrote.
232
+ */
233
+ async function reconcileOwnedSiteVhost(site, route, tunnels) {
234
+ // A move stopped between its state writes and the handover leaves the
235
+ // tunnel's vhost linked beside the site's, on the same hostname.
236
+ const moved = tunnels.find((t) => t.fqdn === site.fqdn && t.enabled === false && t.subdomain);
237
+ const unlinked = moved ? await deps.unlinkMovedAppVhost(moved.subdomain) : false;
238
+ const installed = await deps.readInstalledVhost(siteVhostName(site));
239
+ const certDir =
240
+ (installed && vhostCertDir(installed)) ||
241
+ (await deps.getCertPath(site.fqdn, getConfig().domain));
242
+ const { name, fqdn, config } = await renderOwnedSite(site, route, certDir, tunnels);
243
+ if (installed === config) {
244
+ if (unlinked) await deps.reloadNginx();
245
+ return unlinked;
246
+ }
247
+ await deps.installVhost(name, config, fqdn, { enabled: true });
248
+ return true;
249
+ }
250
+
251
+ /**
252
+ * Whether plugin `route`'s web surface is served may have changed — its
253
+ * version was approved, updated or reverted, it was hosted, unhosted or
254
+ * uninstalled: serve its approved tree, then bring its site's vhost in
255
+ * line. Best effort — logged, never thrown; the panel's start reconciles
256
+ * again.
257
+ *
258
+ * @param {string | null | undefined} route
259
+ * @param {import('pino').Logger} [logger]
260
+ */
261
+ export async function refreshWebSurface(route, logger) {
262
+ if (!route) return;
263
+ try {
264
+ await syncWebSurfaceTree(route, { logger });
265
+ } catch (err) {
266
+ logger?.error?.(
267
+ { route, err: err instanceof Error ? err.message : String(err) },
268
+ 'Could not serve the approved web tree of a plugin',
269
+ );
270
+ }
271
+ try {
272
+ // Under the sites lock: a site deleted meanwhile never gets its vhost back.
273
+ await withSitesLock(async () => {
274
+ const site = ownedSiteOf(route, await readSites());
275
+ if (site) await reconcileOwnedSiteVhost(site, route, await readTunnels());
276
+ });
277
+ } catch (err) {
278
+ logger?.warn?.(
279
+ { route, err: err instanceof Error ? err.message : String(err) },
280
+ 'Could not bring the vhost of a web surface in line',
281
+ );
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Every web surface at the panel's start: each plugin's approved tree
287
+ * served, each owned site's vhost in line. Logged, never thrown.
288
+ *
289
+ * @param {import('pino').Logger} logger
290
+ */
291
+ export async function reconcileWebSurfaces(logger) {
292
+ const sites = await readSites().catch(() => []);
293
+ for (const site of sites) {
294
+ const route = siteUserSurfaceRoute(site);
295
+ if (route) await refreshWebSurface(route, logger);
296
+ }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Status
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /**
304
+ * Plugin `route`'s web surface as the panel shows it: what its installed
305
+ * version declares, the site it owns, whether it is served (and why not),
306
+ * the trees the store holds, and — when it owns no site — the subdomain
307
+ * Lamaste offers and whether it is free.
308
+ *
309
+ * @param {string} route
310
+ */
311
+ export async function webSurfaceStatus(route) {
312
+ const plugin = await findPluginByRoute(route);
313
+ const [sites, tunnels] = await Promise.all([readSites(), readTunnels()]);
314
+ const site = ownedSiteOf(route, sites);
315
+ // A site outlives its plugin: its status stays readable, so that it can
316
+ // be deleted.
317
+ if (!plugin && !site) {
318
+ throw refuse(`No installed plugin or web surface holds the route '${route}'`, 404, 'not_found');
319
+ }
320
+ const serving = await webSurfaceServing(route, tunnels);
321
+ const store = await readWebTreeStore(webTreesDir(route));
322
+ const declared = plugin?.userSurface?.web ?? null;
323
+ // Bound, enabled or not: creating a site is refused either way (a
324
+ // disabled one is unbound, or enabled and moved).
325
+ const boundTunnel = tunnels.find((t) => tunnelUserSurfaceRoutes(t).includes(route)) ?? null;
326
+ // While a tunnel is bound as its user surface, the way to a site is a
327
+ // move (creating one is refused): nothing is offered.
328
+ let offer = null;
329
+ if (!site && declared && !boundTunnel) {
330
+ const subdomain = declared.defaultSubdomain;
331
+ offer = {
332
+ subdomain,
333
+ accessMode: declared.accessMode,
334
+ available: (await hostnameTaken(subdomain, sites, tunnels)) === null,
335
+ };
336
+ }
337
+ return {
338
+ declared,
339
+ site: site
340
+ ? {
341
+ id: site.id,
342
+ name: site.name,
343
+ fqdn: site.fqdn,
344
+ accessMode: site.accessMode ?? null,
345
+ ownerPackage: site.owner?.package ?? null,
346
+ }
347
+ : null,
348
+ served: serving.served,
349
+ reason: serving.served ? null : serving.reason,
350
+ tree: {
351
+ approved: plugin?.webTreeSha256 ?? null,
352
+ current: store.current,
353
+ previous: store.previous,
354
+ },
355
+ boundTunnel: boundTunnel
356
+ ? {
357
+ id: boundTunnel.id,
358
+ fqdn: boundTunnel.fqdn ?? null,
359
+ enabled: boundTunnel.enabled !== false,
360
+ routes: tunnelUserSurfaceRoutes(boundTunnel),
361
+ }
362
+ : null,
363
+ offer,
364
+ };
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Creating, deleting, moving
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /**
372
+ * Why `subdomain` (of the relay's domain) cannot be a new web surface's
373
+ * hostname, or null when it can: reserved, held by a tunnel, or taken by a
374
+ * site (its name, hostname or an alias).
375
+ */
376
+ async function hostnameTaken(subdomain, sites, tunnels) {
377
+ if (!isWebSurfaceSubdomain(subdomain))
378
+ return `'${subdomain}' cannot be a web surface's subdomain`;
379
+ const fqdn = `${subdomain}.${getConfig().domain}`;
380
+ if (tunnels.some((t) => t.subdomain === subdomain || t.fqdn === fqdn)) {
381
+ return `a tunnel holds '${subdomain}'`;
382
+ }
383
+ if (
384
+ sites.some((s) => s.name === subdomain || s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn))
385
+ ) {
386
+ return `a site holds '${subdomain}'`;
387
+ }
388
+ return null;
389
+ }
390
+
391
+ /**
392
+ * The installed plugin at `route` whose installed version declares a web
393
+ * surface, checked for a workflow that gives it a site — or a refusal.
394
+ */
395
+ async function webSurfacePlugin(route) {
396
+ const plugin = await findPluginByRoute(route);
397
+ if (!plugin) throw refuse(`No installed plugin holds the route '${route}'`, 404, 'not_found');
398
+ if (!plugin.userSurface?.web) {
399
+ throw refuse(
400
+ `Plugin "${plugin.name}" ${plugin.version} declares no web surface`,
401
+ 400,
402
+ 'no_web_surface',
403
+ );
404
+ }
405
+ if (!plugin.identity) {
406
+ throw refuse(`Plugin "${plugin.name}" declares no identity`, 400, 'no_identity');
407
+ }
408
+ return plugin;
409
+ }
410
+
411
+ /** A plugin-owned site entry as sites.json stores it. */
412
+ function ownedSiteEntry({ id, subdomain, fqdn, plugin, accessMode }) {
413
+ const route = plugin.route;
414
+ return {
415
+ id,
416
+ name: subdomain,
417
+ fqdn,
418
+ type: 'managed',
419
+ spaMode: true,
420
+ autheliaProtected: false,
421
+ allowedUsers: [],
422
+ dnsVerified: true,
423
+ certIssued: true,
424
+ rootPath: `${webTreesDir(route)}/current`,
425
+ createdAt: new Date().toISOString(),
426
+ totalSize: 0,
427
+ owner: { plugin: route, package: plugin.packageName },
428
+ accessMode,
429
+ };
430
+ }
431
+
432
+ /** Serve the approved tree when there is one; a tree that cannot be served is a refusal. */
433
+ async function serveApprovedTree(plugin, logger) {
434
+ try {
435
+ await syncWebSurfaceTree(plugin.route, { logger, withoutSite: true });
436
+ } catch (err) {
437
+ if (err instanceof WebTreeError) {
438
+ throw refuse(
439
+ `The web tree of "${plugin.name}" cannot be served: ${err.message}`,
440
+ 409,
441
+ 'web_tree',
442
+ );
443
+ }
444
+ throw err;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Give plugin `route` its web surface on `subdomain` of the relay's domain
450
+ * (default: the one its manifest declares) with `accessMode` (default: the
451
+ * declared one): a certificate, the site entry, the vhost. The plugin must
452
+ * declare a web surface and own no site, and no tunnel may be bound as its
453
+ * user surface (move that one instead). The site answers 503 until the
454
+ * surface is served.
455
+ *
456
+ * @param {string} route
457
+ * @param {{ subdomain?: string, accessMode?: 'authenticated' | 'restricted' }} input
458
+ * @param {{ actor: string, logger: import('pino').Logger }} opts
459
+ */
460
+ export function createWebSurface(route, input, { actor, logger }) {
461
+ return withTunnelLock(() =>
462
+ withSitesLock(async () => {
463
+ const plugin = await webSurfacePlugin(route);
464
+ const config = getConfig();
465
+ if (!config.domain || !config.email) {
466
+ throw refuse('Domain and email must be configured', 400, 'domain_not_configured');
467
+ }
468
+ const subdomain = input.subdomain ?? plugin.userSurface.web.defaultSubdomain;
469
+ const accessMode = input.accessMode ?? plugin.userSurface.web.accessMode;
470
+ const [sites, tunnels] = await Promise.all([readSites(), readTunnels()]);
471
+ if (ownedSiteOf(route, sites)) {
472
+ throw refuse(`Plugin "${plugin.name}" has a web surface already`, 409, 'exists');
473
+ }
474
+ const bound = tunnels.find((t) => tunnelUserSurfaceRoutes(t).includes(route));
475
+ if (bound) {
476
+ throw refuse(
477
+ `Tunnel '${bound.fqdn ?? bound.id}' is bound as the user surface of "${plugin.name}" — move it to the web surface instead`,
478
+ 409,
479
+ 'bound_to_tunnel',
480
+ );
481
+ }
482
+ const taken = await hostnameTaken(subdomain, sites, tunnels);
483
+ if (taken) throw refuse(`Cannot use '${subdomain}': ${taken}`, 409, 'hostname_taken');
484
+
485
+ await serveApprovedTree(plugin, logger);
486
+ const fqdn = `${subdomain}.${config.domain}`;
487
+ const cert = await deps.issueTunnelCert(fqdn, config.email);
488
+ const site = ownedSiteEntry({ id: crypto.randomUUID(), subdomain, fqdn, plugin, accessMode });
489
+ await writeSites([...sites, site]);
490
+ try {
491
+ const { name, config: vhost } = await renderOwnedSite(site, route, cert.certPath, tunnels);
492
+ await deps.installVhost(name, vhost, fqdn, { enabled: true });
493
+ } catch (err) {
494
+ await writeSites(sites);
495
+ throw err;
496
+ }
497
+ await recordAuditBestEffort(
498
+ {
499
+ actor,
500
+ action: 'plugin.web-surface.created',
501
+ plugin: plugin.name,
502
+ details: { siteId: site.id, fqdn, accessMode },
503
+ },
504
+ logger,
505
+ );
506
+ return webSurfaceStatus(route);
507
+ }),
508
+ );
509
+ }
510
+
511
+ /**
512
+ * Delete plugin `route`'s web surface: its vhost, its site entry, its
513
+ * tree store and the grants on the site. The plugin stays installed.
514
+ *
515
+ * @param {string} route
516
+ * @param {{ actor: string, logger: import('pino').Logger }} opts
517
+ */
518
+ export function deleteWebSurface(route, { actor, logger }) {
519
+ return withSitesLock(async () => {
520
+ const sites = await readSites();
521
+ const site = ownedSiteOf(route, sites);
522
+ if (!site) throw refuse(`No web surface for the route '${route}'`, 404, 'not_found');
523
+ await deps.removeStaticSiteVhost(site.id);
524
+ await writeSites(sites.filter((s) => s.id !== site.id));
525
+ await treeMutex.run(route, () => removeWebTreeStore(webTreesDir(route)));
526
+ const revoked = await revokeSiteGrants(site.id, logger);
527
+ // What is left on the deleted site: grants Gatekeeper kept, or none
528
+ // known because it could not list them.
529
+ const left = revoked.unlisted
530
+ ? { grantsUnlisted: true }
531
+ : revoked.notRevoked.length > 0
532
+ ? { grantsNotRevoked: revoked.notRevoked }
533
+ : {};
534
+ await recordAuditBestEffort(
535
+ {
536
+ actor,
537
+ action: 'plugin.web-surface.deleted',
538
+ plugin: (await findPluginByRoute(route))?.name ?? null,
539
+ details: { siteId: site.id, fqdn: site.fqdn, ...left },
540
+ },
541
+ logger,
542
+ );
543
+ return { ok: true, ...left };
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Revoke grants `grantIds`; returns the ids Gatekeeper did not revoke
549
+ * (logged), for the caller to report.
550
+ */
551
+ async function revokeGrants(grantIds, logger) {
552
+ const failed = [];
553
+ for (const grantId of grantIds) {
554
+ const result = await revokeGatekeeperGrant(grantId).catch((err) => ({
555
+ ok: false,
556
+ error: err instanceof Error ? err.message : String(err),
557
+ }));
558
+ if (!result.ok) failed.push(grantId);
559
+ }
560
+ if (failed.length > 0) {
561
+ logger?.warn?.({ grantIds: failed }, 'Gatekeeper did not revoke grants of a web surface');
562
+ }
563
+ return failed;
564
+ }
565
+
566
+ /**
567
+ * Revoke every grant on site `siteId`: `{ notRevoked }`, the ids
568
+ * Gatekeeper kept — or `{ unlisted: true }` when it could not list them.
569
+ */
570
+ async function revokeSiteGrants(siteId, logger) {
571
+ const grants = await listResourceGrants('site', siteId);
572
+ if (!grants.ok) {
573
+ logger?.warn?.({ siteId, error: grants.error }, 'Could not list the grants of a web surface');
574
+ return { unlisted: true };
575
+ }
576
+ return {
577
+ notRevoked: await revokeGrants(
578
+ grants.grants.map((g) => g.grantId),
579
+ logger,
580
+ ),
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Copy the grants on tunnel `tunnelId` to the site `siteId` (a restricted
586
+ * host keeps its users). Returns the grants created; on a failure revokes
587
+ * them and throws.
588
+ */
589
+ async function copyTunnelGrants(tunnelId, siteId, logger) {
590
+ const listed = await listResourceGrants('tunnel', tunnelId);
591
+ if (!listed.ok) {
592
+ throw refuse(
593
+ `Could not read the tunnel's grants from Gatekeeper: ${listed.error}`,
594
+ 503,
595
+ 'gatekeeper',
596
+ );
597
+ }
598
+ const created = [];
599
+ for (const grant of listed.grants) {
600
+ const result = await createGatekeeperGrant({
601
+ principalType: grant.principalType,
602
+ principalId: grant.principalId,
603
+ resourceType: 'site',
604
+ resourceId: siteId,
605
+ context: grant.context,
606
+ });
607
+ if (!result.ok) {
608
+ await revokeGrants(
609
+ created.map((g) => g.grantId),
610
+ logger,
611
+ );
612
+ throw refuse(`Could not copy the tunnel's grants: ${result.error}`, 503, 'gatekeeper');
613
+ }
614
+ created.push(result.grant);
615
+ }
616
+ return created;
617
+ }
618
+
619
+ /**
620
+ * Move plugin `route`'s user surface from tunnel `tunnelId` — bound to it,
621
+ * and to nothing else — to the plugin's web surface on the same hostname,
622
+ * in one nginx reload: the shell is then served from the plugin's approved
623
+ * tree on the relay, and the API as before. The tunnel stays in the state,
624
+ * disabled and unbound (its port grant withdrawn once nginx no longer
625
+ * routes to it), for a rollback by hand. A restricted tunnel's grants are
626
+ * copied to the site. The plugin must be served — approved, hosted by the
627
+ * tunnel's agent — and its approved tree is served first.
628
+ *
629
+ * @param {string} route
630
+ * @param {string} tunnelId
631
+ * @param {{ actor: string, logger: import('pino').Logger }} opts
632
+ */
633
+ export function moveWebSurfaceFromTunnel(route, tunnelId, { actor, logger }) {
634
+ return withTunnelLock(() =>
635
+ withSitesLock(async () => {
636
+ const plugin = await webSurfacePlugin(route);
637
+ const [sites, tunnels] = await Promise.all([readSites(), readTunnels()]);
638
+ if (ownedSiteOf(route, sites)) {
639
+ throw refuse(`Plugin "${plugin.name}" has a web surface already`, 409, 'exists');
640
+ }
641
+ const tunnel = tunnels.find((t) => t.id === tunnelId);
642
+ if (!tunnel) throw refuse('Tunnel not found', 404, 'not_found');
643
+ const routes = tunnelUserSurfaceRoutes(tunnel);
644
+ if (!routes.includes(route)) {
645
+ throw refuse(`Tunnel '${tunnel.fqdn}' is not bound to "${plugin.name}"`, 409, 'not_bound');
646
+ }
647
+ if (routes.length > 1) {
648
+ throw refuse(
649
+ `Tunnel '${tunnel.fqdn}' serves other user surfaces too (${routes.filter((r) => r !== route).join(', ')}) — unbind them first`,
650
+ 409,
651
+ 'shared_tunnel',
652
+ );
653
+ }
654
+ if (tunnel.enabled === false || typeof tunnel.fqdn !== 'string') {
655
+ throw refuse(`Tunnel '${tunnel.id}' is disabled`, 409, 'tunnel_disabled');
656
+ }
657
+ // As everywhere else, a tunnel without a known access mode is restricted.
658
+ const accessMode = tunnel.accessMode === 'authenticated' ? 'authenticated' : 'restricted';
659
+ if ((await hostingAgentOf(plugin, tunnels)) !== tunnel.agentLabel) {
660
+ throw refuse(
661
+ `Plugin "${plugin.name}" is not hosted by the agent carrying the tunnel`,
662
+ 409,
663
+ 'not_hosted',
664
+ );
665
+ }
666
+ if (!(await installedVersionApproved(plugin).catch(() => false))) {
667
+ throw refuse(
668
+ `Plugin "${plugin.name}" ${plugin.version} is not approved`,
669
+ 409,
670
+ 'not_approved',
671
+ );
672
+ }
673
+ if (sites.some((s) => s.fqdn === tunnel.fqdn || (s.aliases ?? []).includes(tunnel.fqdn))) {
674
+ throw refuse(`A site answers on '${tunnel.fqdn}' already`, 409, 'hostname_taken');
675
+ }
676
+
677
+ await serveApprovedTree(plugin, logger);
678
+ const site = ownedSiteEntry({
679
+ id: crypto.randomUUID(),
680
+ subdomain: tunnel.subdomain,
681
+ fqdn: tunnel.fqdn,
682
+ plugin,
683
+ accessMode,
684
+ });
685
+ const movedTunnels = tunnels.map((t) => {
686
+ if (t.id !== tunnel.id) return t;
687
+ const moved = { ...t, enabled: false };
688
+ delete moved.userSurface;
689
+ return moved;
690
+ });
691
+
692
+ // Every step from the grants to the handover undoes in reverse order
693
+ // whatever ran before a failure: no grant, site entry or tunnel change
694
+ // outlives a move that did not happen.
695
+ const grants =
696
+ accessMode === 'restricted' ? await copyTunnelGrants(tunnel.id, site.id, logger) : [];
697
+ let wroteSites = false;
698
+ let wroteTunnels = false;
699
+ try {
700
+ const config = getConfig();
701
+ const installed = await deps.readInstalledVhost(
702
+ `lamalibre-lamaste-app-${tunnel.subdomain}`,
703
+ );
704
+ const certDir =
705
+ (installed && vhostCertDir(installed)) ||
706
+ (await deps.getCertPath(tunnel.fqdn, config.domain));
707
+ // The state first — Gatekeeper reads both files on a change of
708
+ // either, so the old vhost's API requests already resolve to the
709
+ // site — then one reload hands the hostname over, then the port
710
+ // grant goes.
711
+ await writeSites([...sites, site]);
712
+ wroteSites = true;
713
+ await writeTunnels(movedTunnels);
714
+ wroteTunnels = true;
715
+ await deps.moveAppVhostToWebSurface(tunnel.subdomain, site, certDir, config.domain, {
716
+ route,
717
+ served: (await webSurfaceServing(route, movedTunnels)).served,
718
+ maxBodySizeMb: await internalTunnelBodyLimit(plugin, movedTunnels),
719
+ treesDir: webTreesDir(route),
720
+ });
721
+ } catch (err) {
722
+ const undo = async (what, step) => {
723
+ try {
724
+ await step();
725
+ } catch (undoErr) {
726
+ logger?.error?.(
727
+ { err: undoErr instanceof Error ? undoErr.message : String(undoErr) },
728
+ `Could not undo a failed move: ${what}`,
729
+ );
730
+ }
731
+ };
732
+ if (wroteTunnels) await undo('tunnels.json', () => writeTunnels(tunnels));
733
+ if (wroteSites) await undo('sites.json', () => writeSites(sites));
734
+ await revokeGrants(
735
+ grants.map((g) => g.grantId),
736
+ logger,
737
+ );
738
+ throw err;
739
+ }
740
+ // The port is withdrawn for new sessions only: nginx routes nothing to
741
+ // it any more, and restarting chisel would drop every agent's session
742
+ // — the plugin's internal tunnel too, its API answering 502 until the
743
+ // agent reconnects — and fail the requests nginx's old workers still
744
+ // send to the tunnel until they finish. The agent drops the binding
745
+ // at its next sync.
746
+ try {
747
+ await deps.syncChisel({ keepLiveSessions: true });
748
+ } catch (err) {
749
+ logger?.warn?.(
750
+ { err: err instanceof Error ? err.message : String(err) },
751
+ 'Could not withdraw the moved tunnel’s port grant — the next sync will',
752
+ );
753
+ }
754
+ await recordAuditBestEffort(
755
+ {
756
+ actor,
757
+ action: 'plugin.web-surface.moved',
758
+ plugin: plugin.name,
759
+ details: { siteId: site.id, fqdn: site.fqdn, tunnelId: tunnel.id, accessMode },
760
+ },
761
+ logger,
762
+ );
763
+ return webSurfaceStatus(route);
764
+ }),
765
+ );
766
+ }