@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
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Lamaste</title>
7
- <script type="module" crossorigin src="/assets/index-DSaDPJaw.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-D-fer5ef.css">
7
+ <script type="module" crossorigin src="/assets/index-BvEfCZYS.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BZwQ_aQc.css">
9
9
  </head>
10
10
  <body style="background-color: #09090b">
11
11
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-serverd",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "Lamaste server daemon — REST API and plugin host",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
@@ -29,7 +29,7 @@
29
29
  "@fastify/rate-limit": "^10.3.0",
30
30
  "@fastify/static": "^9.1.1",
31
31
  "@fastify/websocket": "^11.0.0",
32
- "@lamalibre/lamaste": "^4.5.0",
32
+ "@lamalibre/lamaste": "^4.6.0",
33
33
  "bcryptjs": "^3.0.3",
34
34
  "execa": "^9.6.1",
35
35
  "fastify": "^5.8.4",
@@ -45,6 +45,7 @@ import { startIdentityServices, stopIdentityServices } from './lib/identity-sync
45
45
  import { syncOidcClients } from './lib/plugin-oidc.js';
46
46
  import { ensureRelaySigningKey } from './lib/relay-signing.js';
47
47
  import { reconcileSiteVhosts, reconcileTunnelVhosts } from './lib/vhost-reconcile.js';
48
+ import { reconcileWebSurfaces } from './lib/web-surface.js';
48
49
  import {
49
50
  loadTicketScopeCapabilities,
50
51
  checkInstanceLiveness,
@@ -217,7 +218,7 @@ async function start() {
217
218
 
218
219
  // The auth portal and static site vhosts an older version wrote name the
219
220
  // client's Host header, not their own hostname, to Authelia. Before listen:
220
- // site changes take no lock, and none can arrive until the panel listens.
221
+ // no site change can arrive until the panel listens.
221
222
  if (config.onboarding.status === 'COMPLETED' && config.domain) {
222
223
  await reconcileSiteVhosts(server.log, config.domain).catch((err) => {
223
224
  server.log.error({ err: err.message }, 'Startup site vhost reconcile failed');
@@ -239,9 +240,13 @@ async function start() {
239
240
  // relay, and Gatekeeper vhosts name the client's Host header to Gatekeeper.
240
241
  // After listen: a rewritten agent-panel vhost asks this server at once.
241
242
  if (config.onboarding.status === 'COMPLETED') {
242
- reconcileTunnelVhosts(server.log).catch((err) => {
243
- server.log.error({ err: err.message }, 'Startup tunnel vhost reconcile failed');
244
- });
243
+ reconcileTunnelVhosts(server.log)
244
+ .catch((err) => {
245
+ server.log.error({ err: err.message }, 'Startup tunnel vhost reconcile failed');
246
+ })
247
+ // Plugins' web surfaces: each approved tree served (a redeploy may
248
+ // have reset the web root), each site's vhost in line.
249
+ .then(() => reconcileWebSurfaces(server.log));
245
250
  }
246
251
 
247
252
  // --- Health of hosted agent-hosted plugins (every 10s; idle without hostings) ---
@@ -30,17 +30,29 @@ export function chiselReloadsAuthfile() {
30
30
  }
31
31
 
32
32
  /**
33
- * Restart chisel if the change requires it. `try-restart` leaves a stopped
34
- * chisel stopped (see chisel-reconcile.js: a server that failed to reconcile
35
- * keeps chisel down until it succeeds).
33
+ * Whether an authfile change must restart chisel: a grant withdrawn (so
34
+ * live sessions drop the binding) — unless `keepLiveSessions` — or any
35
+ * change a chisel too old to reload its authfile would not see.
36
+ *
37
+ * @param {{ changed: boolean, revoked: boolean }} change
38
+ * @param {{ keepLiveSessions?: boolean }} [opts]
39
+ */
40
+ export function authfileChangeNeedsRestart(change, { keepLiveSessions = false } = {}) {
41
+ return (change.revoked && !keepLiveSessions) || (change.changed && !runningPinnedRelease);
42
+ }
43
+
44
+ /**
45
+ * Restart chisel if the change requires it ({@link authfileChangeNeedsRestart}).
46
+ * `try-restart` leaves a stopped chisel stopped (see chisel-reconcile.js: a
47
+ * server that failed to reconcile keeps chisel down until it succeeds).
36
48
  *
37
49
  * @param {{ changed: boolean, revoked: boolean }} change
38
50
  * @param {import('pino').Logger} [logger]
51
+ * @param {{ keepLiveSessions?: boolean }} [opts]
39
52
  * @returns {Promise<{ restartOk: boolean, restartError?: string }>}
40
53
  */
41
- export async function applyAuthfileChange(change, logger) {
42
- const needsRestart = change.revoked || (change.changed && !runningPinnedRelease);
43
- if (!needsRestart) return { restartOk: true };
54
+ export async function applyAuthfileChange(change, logger, opts = {}) {
55
+ if (!authfileChangeNeedsRestart(change, opts)) return { restartOk: true };
44
56
  const result = await reloadChiselAuth(execa, logger);
45
57
  return result.ok ? { restartOk: true } : { restartOk: false, restartError: result.error };
46
58
  }
@@ -71,10 +71,18 @@ export function getChiselStatus() {
71
71
  * grant, chisel is restarted so live sessions drop the binding. Call only
72
72
  * after the tunnel state has been written. Throws if the restart fails,
73
73
  * since a withdrawn grant would otherwise stay live.
74
+ *
75
+ * `keepLiveSessions`: withdraw the grant for new sessions only, without the
76
+ * restart — which drops every session, the hosting agents' internal plugin
77
+ * tunnels with them. Only for a port nothing routes to any more, whose
78
+ * agent drops the binding at its next sync (a tunnel moved to a plugin's
79
+ * web surface).
80
+ *
81
+ * @param {{ keepLiveSessions?: boolean }} [opts]
74
82
  */
75
- export async function syncChisel() {
83
+ export async function syncChisel(opts = {}) {
76
84
  const change = await syncChiselAuthfile();
77
- const { restartOk, restartError } = await applyAuthfileChange(change);
85
+ const { restartOk, restartError } = await applyAuthfileChange(change, undefined, opts);
78
86
  if (!restartOk) {
79
87
  throw new Error(`Chisel restart failed after withdrawing a port grant: ${restartError}`);
80
88
  }
@@ -225,26 +225,37 @@ function groupOf(raw) {
225
225
  }
226
226
 
227
227
  /**
228
- * Every grant on plugin `route` (`resourceType: 'plugin'`, `resourceId:
229
- * <route>`) — with roles or without.
228
+ * Every grant on one resource (`resourceType`, `resourceId`).
230
229
  *
231
- * @param {string} route
230
+ * @param {string} resourceType
231
+ * @param {string} resourceId
232
232
  * @returns {Promise<{ ok: true, grants: GatekeeperGrant[] } | Extract<GatekeeperResult, { ok: false }>>}
233
233
  */
234
- export async function listPluginGrants(route) {
235
- const qs = new URLSearchParams({ resourceType: 'plugin', resourceId: route }).toString();
234
+ export async function listResourceGrants(resourceType, resourceId) {
235
+ const qs = new URLSearchParams({ resourceType, resourceId }).toString();
236
236
  const result = await gatekeeperRequest('GET', `/api/grants?${qs}`);
237
237
  if (!result.ok) return result;
238
238
  if (!isRecord(result.data) || !Array.isArray(result.data.grants)) return malformed('its grants');
239
239
  const grants = result.data.grants.map(grantOf);
240
240
  if (grants.some((g) => g === null)) return malformed('its grants');
241
- // Gatekeeper filters by the query; a grant on anything else is not this plugin's.
241
+ // Gatekeeper filters by the query; a grant on anything else is not this resource's.
242
242
  return {
243
243
  ok: true,
244
- grants: grants.filter((g) => g.resourceType === 'plugin' && g.resourceId === route),
244
+ grants: grants.filter((g) => g.resourceType === resourceType && g.resourceId === resourceId),
245
245
  };
246
246
  }
247
247
 
248
+ /**
249
+ * Every grant on plugin `route` (`resourceType: 'plugin'`, `resourceId:
250
+ * <route>`) — with roles or without.
251
+ *
252
+ * @param {string} route
253
+ * @returns {Promise<{ ok: true, grants: GatekeeperGrant[] } | Extract<GatekeeperResult, { ok: false }>>}
254
+ */
255
+ export function listPluginGrants(route) {
256
+ return listResourceGrants('plugin', route);
257
+ }
258
+
248
259
  /**
249
260
  * Every Lamaste group with its members.
250
261
  *
@@ -871,8 +871,10 @@ export async function runUninstallPipeline(op, ctx, { force = false } = {}) {
871
871
  /**
872
872
  * The install pipeline around an upgrade of the relay package (the agent
873
873
  * half is already running; its stages go through the tunnel). `apply` is
874
- * Lamaste's step — install the new package and its hook module; `revert`
875
- * restores the previous ones. Agent and relay stages see `phase: "upgrade"`.
874
+ * Lamaste's step — install the new package and its hook module; `serve`,
875
+ * when given, is the last step (the new version's web tree served, once
876
+ * every stage passed);
877
+ * `revert` restores the previous ones. Agent and relay stages see `phase: "upgrade"`.
876
878
  *
877
879
  * A hosted plugin whose agent half Lamaste installs gets the new version of
878
880
  * it first (after the agent's pre stage): the agent's helper installs it
@@ -880,7 +882,7 @@ export async function runUninstallPipeline(op, ctx, { force = false } = {}) {
880
882
  * tunnel. A failure after that switch activates the version that ran
881
883
  * before (`ctx.previousAgentHalfVersion`).
882
884
  */
883
- export async function runUpgradePipeline(op, ctx, { apply, revert, hosted }) {
885
+ export async function runUpgradePipeline(op, ctx, { apply, serve, revert, hosted }) {
884
886
  let applied = false;
885
887
  try {
886
888
  if (hosted) {
@@ -906,6 +908,9 @@ export async function runUpgradePipeline(op, ctx, { apply, revert, hosted }) {
906
908
  await agentStageOrSkip(op, ctx, 'install:post-tunnel', 'upgrade', 'tunnel');
907
909
  await relayStageOrSkip(op, ctx, 'install:post-tunnel', 'upgrade');
908
910
  }
911
+ // What the new version serves its users from the relay (its web tree):
912
+ // the last step, once every stage passed — a failure still reverts.
913
+ if (serve) await op.step('serve the new web tree', serve);
909
914
  } catch (err) {
910
915
  op.warn(`Reverting: ${String(err.message ?? err).slice(0, 300)}`);
911
916
  try {
@@ -0,0 +1,14 @@
1
+ -- A plugin's web surface (4.6, slice 7).
2
+ --
3
+ -- user_surface is the manifest's `userSurface` block as the installed
4
+ -- version declares it (JSON; NULL when it declares none): its `web` names
5
+ -- the default subdomain and access mode Lamaste offers for the plugin's own
6
+ -- site and the directory of the server package that holds the shell.
7
+ --
8
+ -- web_tree_sha256 is the digest of that directory's web tree (core
9
+ -- `webTreeManifest`), read from the installed package: it enters the
10
+ -- approval digest, and the relay serves a copy of the tree only when the
11
+ -- copy's own digest equals it. NULL when the version declares no web
12
+ -- surface.
13
+ ALTER TABLE plugins ADD COLUMN user_surface TEXT;
14
+ ALTER TABLE plugins ADD COLUMN web_tree_sha256 TEXT;
@@ -7,6 +7,7 @@ import {
7
7
  USER_SURFACE_MAX_RESPONSE_SECONDS,
8
8
  isUserSurfaceRoute,
9
9
  userSurfaceApiPath,
10
+ WEB_TREE_CONTENT_TYPES,
10
11
  } from '@lamalibre/lamaste';
11
12
 
12
13
  const SITES_AVAILABLE = '/etc/nginx/sites-available';
@@ -590,12 +591,14 @@ const USER_SURFACE_PROXY_HEADERS = `
590
591
  * `X-Original-URL` and in `Host` — never by the client's `Host` header (see
591
592
  * {@link originalUrl}) — and the session cookie is passed on.
592
593
  *
593
- * @param {'tunnel'|'user-surface'} check
594
+ * @param {'tunnel'|'user-surface'|'user-surface-shell'} check
594
595
  * @param {string} fqdn
595
596
  * @returns {string}
596
597
  */
597
598
  function gatekeeperSubrequest(check, fqdn) {
598
599
  return `internal;
600
+ # A subrequest is never authorized itself (a host may authorize at server level)
601
+ auth_request off;
599
602
 
600
603
  proxy_pass http://127.0.0.1:9294/authz/${check}/${fqdn};
601
604
  proxy_pass_request_body off;
@@ -673,7 +676,7 @@ ${USER_SURFACE_PROXY_HEADERS}
673
676
  # The user surface of plugin "${route}"
674
677
  location = ${apiPath} ${block}
675
678
 
676
- location ${apiPath}/ ${block}
679
+ location ^~ ${apiPath}/ ${block}
677
680
  `;
678
681
  });
679
682
  return `
@@ -685,6 +688,7 @@ ${USER_SURFACE_PROXY_HEADERS}
685
688
 
686
689
  location = /internal/lamaste/user-surface-401 {
687
690
  internal;
691
+ auth_request off;
688
692
 
689
693
  proxy_pass http://127.0.0.1:9294/authz/user-surface-unauthenticated;
690
694
  proxy_pass_request_body off;
@@ -1163,6 +1167,269 @@ ${aliasBlock}`;
1163
1167
  return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
1164
1168
  }
1165
1169
 
1170
+ /**
1171
+ * The Content-Security-Policy of a plugin's web surface: its shell loads
1172
+ * scripts, styles, fonts and images from its own host only and talks to its
1173
+ * own host only (its user surface's API is there). Inline styles are
1174
+ * allowed — frameworks set `style` attributes and insert component styles
1175
+ * at run time — inline scripts never; no framing, no plugins, no foreign
1176
+ * form targets.
1177
+ */
1178
+ const WEB_SURFACE_CSP =
1179
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
1180
+
1181
+ /**
1182
+ * Response headers of the shell of a plugin's web surface, set in its
1183
+ * locations — not at server level, where the API's answers (headed by the
1184
+ * panel) would get a second copy. `no-cache`: a browser revalidates each file —
1185
+ * a 304 while the tree is the same, the new file once an update switched
1186
+ * it — so no page mixes two versions of the shell from its cache.
1187
+ */
1188
+ const WEB_SURFACE_HEADERS = `
1189
+ add_header Content-Security-Policy "${WEB_SURFACE_CSP}" always;
1190
+ add_header X-Content-Type-Options nosniff always;
1191
+ add_header X-Frame-Options DENY always;
1192
+ add_header Referrer-Policy same-origin always;
1193
+ add_header Cross-Origin-Opener-Policy same-origin always;
1194
+ add_header Cross-Origin-Resource-Policy same-origin always;
1195
+ add_header Cache-Control no-cache always;`;
1196
+
1197
+ /**
1198
+ * The content types of a web tree's files, by extension
1199
+ * (`WEB_TREE_CONTENT_TYPES` — the only extensions a tree may hold).
1200
+ */
1201
+ const WEB_SURFACE_TYPES = (() => {
1202
+ const byType = new Map();
1203
+ for (const [ext, type] of Object.entries(WEB_TREE_CONTENT_TYPES)) {
1204
+ byType.set(type, [...(byType.get(type) ?? []), ext.slice(1)]);
1205
+ }
1206
+ const entries = [...byType].map(([type, exts]) => ` ${type} ${exts.join(' ')};`);
1207
+ return `
1208
+ types {
1209
+ ${entries.join('\n')}
1210
+ }`;
1211
+ })();
1212
+
1213
+ /**
1214
+ * The files a page still running the previous shell may ask for after an
1215
+ * update — its scripts, styles, fonts and images — and nothing else: a
1216
+ * document, an SVG or a manifest is never served from the previous tree.
1217
+ */
1218
+ const WEB_SURFACE_SUBRESOURCE_RE = '[.](?:js|mjs|css|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|ico)$';
1219
+
1220
+ /**
1221
+ * Service workers: never registered from a plugin's site. A worker outlives
1222
+ * the tree that installed it — a revert, the site's deletion, the
1223
+ * hostname's next owner — and answers in its place; asked for its script
1224
+ * (`Service-Worker: script`), the site answers 404, which also
1225
+ * unregisters a worker already installed at its next update check.
1226
+ */
1227
+ const NO_SERVICE_WORKER = `
1228
+ if ($http_service_worker) {
1229
+ return 404;
1230
+ }`;
1231
+
1232
+ /**
1233
+ * Render the vhost of a plugin's web surface — a site the plugin owns
1234
+ * (`site.owner`). Pure; the startup reconcile renders it again to compare.
1235
+ *
1236
+ * While the plugin's surface is served (`opts.served`: its installed
1237
+ * version approved, declaring identity and a web surface, hosted), the
1238
+ * host serves two things under one Authelia session:
1239
+ *
1240
+ * - **the shell**: every path but the API, from the plugin's current tree
1241
+ * (`<treesDir>/current`), GET and HEAD only, each file under its own
1242
+ * content type (`WEB_SURFACE_TYPES`), never a service worker's script. Every request asks
1243
+ * Gatekeeper first (`/authz/user-surface-shell/<fqdn>`: a session in the
1244
+ * plugin's audience, and the site's grant when it is restricted) — a page
1245
+ * without a session goes to the login, a user without access gets the
1246
+ * access-request page. A script, style, font or image the current tree
1247
+ * lacks is looked for in the previous tree (a page still running the old
1248
+ * shell loads its lazy files after an update), else 404; any other
1249
+ * unknown path gets the current shell's `index.html`.
1250
+ * - **the API**: `/api/<route>/app/*`, exactly as on a bound tunnel
1251
+ * ({@link userSurfaceLocations}).
1252
+ *
1253
+ * While it is not served the host answers every request with 503 — and,
1254
+ * once its plugin is no longer installed (`opts.clearSiteData`), tells
1255
+ * browsers to clear the site's storage.
1256
+ *
1257
+ * @param {{ id: string, fqdn: string }} site
1258
+ * @param {string} certDir
1259
+ * @param {string} domain
1260
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string, clearSiteData?: boolean }} opts
1261
+ * @returns {{ name: string, fqdn: string, config: string }}
1262
+ */
1263
+ export function renderWebSurfaceVhost(site, certDir, domain, opts) {
1264
+ const { route, served, maxBodySizeMb, treesDir, clearSiteData = false } = opts;
1265
+ assertLiteralHostname(site.fqdn);
1266
+ if (!isUserSurfaceRoute(route)) throw new Error(`Invalid web surface route: ${route}`);
1267
+ if (!path.isAbsolute(treesDir) || !/^[A-Za-z0-9/._-]+$/.test(treesDir)) {
1268
+ throw new Error(`Invalid web tree directory for nginx vhost: ${treesDir}`);
1269
+ }
1270
+ let certDirClean = certDir;
1271
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
1272
+
1273
+ const head = `server {
1274
+ listen 443 ssl;
1275
+ server_name ${site.fqdn};
1276
+
1277
+ ssl_certificate ${certDirClean}/fullchain.pem;
1278
+ ssl_certificate_key ${certDirClean}/privkey.pem;
1279
+
1280
+ ssl_protocols TLSv1.2 TLSv1.3;
1281
+ ssl_ciphers HIGH:!aNULL:!MD5;
1282
+ ssl_prefer_server_ciphers on;
1283
+ `;
1284
+
1285
+ if (!served) {
1286
+ // A plugin no longer installed: whatever comes next on this hostname is
1287
+ // not its shell, so browsers drop what it left — its storage, caches
1288
+ // and service workers. Never while the plugin is only unhosted or
1289
+ // awaiting approval: its users' data stays.
1290
+ const clear = clearSiteData
1291
+ ? `
1292
+ add_header Clear-Site-Data '"storage"' always;`
1293
+ : '';
1294
+ return {
1295
+ name: `lamalibre-lamaste-site-${site.id}`,
1296
+ fqdn: site.fqdn,
1297
+ config: `${head}
1298
+ # The web surface of plugin "${route}" is not served now (not installed,
1299
+ # not approved, not hosted, or its version declares no web surface)
1300
+ location / {${NO_SERVICE_WORKER}
1301
+ add_header Cache-Control no-store always;${clear}
1302
+ return 503;
1303
+ }
1304
+ }
1305
+ `,
1306
+ };
1307
+ }
1308
+
1309
+ const url = originalUrl(site.fqdn);
1310
+ const config = `${head}
1311
+ root ${treesDir}/current;
1312
+ index index.html;
1313
+
1314
+ # Every path of the shell: Gatekeeper first (the API's locations ask on their own)
1315
+ auth_request /internal/lamaste/shell-authz;
1316
+ error_page 401 =302 https://auth.${domain}/?rd=${url};
1317
+ error_page 403 = /internal/lamaste/shell-authz;
1318
+
1319
+ location = /internal/lamaste/shell-authz {
1320
+ ${gatekeeperSubrequest('user-surface-shell', site.fqdn)}
1321
+ }
1322
+ ${userSurfaceLocations(site.fqdn, [{ route, maxBodySizeMb }])}
1323
+ # A script, style, font or image the current tree lacks: the previous
1324
+ # tree's (a page still running the old shell), else 404
1325
+ location ~* ${WEB_SURFACE_SUBRESOURCE_RE} {
1326
+ if ($request_method !~ ^(GET|HEAD)$) {
1327
+ return 405;
1328
+ }${NO_SERVICE_WORKER}
1329
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1330
+ try_files $uri @previous;
1331
+ }
1332
+
1333
+ # Everything else: the current tree's file, else the shell
1334
+ location / {
1335
+ if ($request_method !~ ^(GET|HEAD)$) {
1336
+ return 405;
1337
+ }${NO_SERVICE_WORKER}
1338
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1339
+ try_files $uri /index.html;
1340
+ }
1341
+
1342
+ location @previous {
1343
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1344
+ root ${treesDir}/previous;
1345
+ try_files $uri =404;
1346
+ }
1347
+ }
1348
+ `;
1349
+ return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
1350
+ }
1351
+
1352
+ /**
1353
+ * Write the vhost of a plugin's web surface (see {@link renderWebSurfaceVhost}).
1354
+ *
1355
+ * @param {{ id: string, fqdn: string }} site
1356
+ * @param {string} certDir
1357
+ * @param {string} domain
1358
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
1359
+ */
1360
+ export async function writeWebSurfaceVhost(site, certDir, domain, opts) {
1361
+ const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
1362
+ return safeWriteVhost(name, config, fqdn, { enabled: true });
1363
+ }
1364
+
1365
+ /**
1366
+ * Hand a hostname from one vhost to another in one nginx reload: vhost
1367
+ * `fromName` is unlinked, `name` written with `config` and linked, the
1368
+ * configuration tested and nginx reloaded once; `fromName` stays installed,
1369
+ * unlinked. Until the reload nginx serves the old vhost, after it the new
1370
+ * one; there is no moment in between. On any failure before the reload
1371
+ * both are put back as they were.
1372
+ *
1373
+ * @param {string} fromName the vhost giving the hostname up (linked)
1374
+ * @param {string} name a vhost that does not exist yet
1375
+ * @param {string} config
1376
+ * @param {string} fqdn
1377
+ */
1378
+ async function handOverVhost(fromName, name, config, fqdn) {
1379
+ if (await siteExists(name)) throw new Error(`Vhost ${name} exists already`);
1380
+ const fromEnabled = await isSiteEnabled(fromName);
1381
+ const restore = async () => {
1382
+ await siteAction('remove', name).catch(() => {});
1383
+ if (fromEnabled) await enableSite(fromName).catch(() => {});
1384
+ };
1385
+ try {
1386
+ await disableSite(fromName);
1387
+ await writeVhostFile(name, config);
1388
+ await enableSite(name);
1389
+ const result = await testConfig();
1390
+ if (!result.valid) {
1391
+ throw new Error(`Nginx config test failed handing ${fqdn} to ${name}: ${result.error}`);
1392
+ }
1393
+ await reload();
1394
+ } catch (err) {
1395
+ await restore();
1396
+ throw err;
1397
+ }
1398
+ // `fromName` stays installed, only unlinked: the tunnel it served can be
1399
+ // enabled again by hand once the new vhost is gone.
1400
+ }
1401
+
1402
+ /**
1403
+ * Unlink tunnel vhost `subdomain` when it is still enabled although a
1404
+ * plugin's site holds its hostname — a move the panel did not finish (it
1405
+ * wrote the state, then stopped before the handover). Does not reload:
1406
+ * the caller's next vhost install does, or it reloads itself. Returns
1407
+ * whether it unlinked.
1408
+ *
1409
+ * @param {string} subdomain the moved tunnel's
1410
+ */
1411
+ export async function unlinkMovedAppVhost(subdomain) {
1412
+ const name = `lamalibre-lamaste-app-${subdomain}`;
1413
+ if (!(await isSiteEnabled(name))) return false;
1414
+ await disableSite(name);
1415
+ return true;
1416
+ }
1417
+
1418
+ /**
1419
+ * Serve a plugin's web surface on the hostname of tunnel vhost `subdomain`
1420
+ * in one reload (see {@link handOverVhost}).
1421
+ *
1422
+ * @param {string} subdomain the tunnel's
1423
+ * @param {{ id: string, fqdn: string }} site
1424
+ * @param {string} certDir
1425
+ * @param {string} domain
1426
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
1427
+ */
1428
+ export async function moveAppVhostToWebSurface(subdomain, site, certDir, domain, opts) {
1429
+ const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
1430
+ await handOverVhost(`lamalibre-lamaste-app-${subdomain}`, name, config, fqdn);
1431
+ }
1432
+
1166
1433
  /**
1167
1434
  * Server block answering a site's aliases with a permanent redirect to the
1168
1435
  * primary hostname, on the same certificate (its lineage covers every alias).
@@ -218,28 +218,45 @@ export function installedAgentHalf(plugin) {
218
218
  return { ...declaration, version: pin.version, integrity: pin.integrity };
219
219
  }
220
220
 
221
+ /**
222
+ * The web tree a plugin version's approval covers: `{ treeSha256 }` when it
223
+ * declares a web surface (`userSurface.web`), null otherwise. A declared
224
+ * surface without the tree's digest cannot be approved.
225
+ */
226
+ function installedWebTree(plugin) {
227
+ if (!plugin.userSurface?.web) return null;
228
+ if (typeof plugin.webTreeSha256 !== 'string') {
229
+ throw new Error(`Plugin "${plugin.name}" declares a web surface but its web tree is unknown`);
230
+ }
231
+ return { treeSha256: plugin.webTreeSha256 };
232
+ }
233
+
221
234
  /**
222
235
  * What approving a plugin version covers — its relay hook set, the agent
223
- * half Lamaste installs and its identity block (normalized, as the registry
224
- * stores it) — and the digest the approval names. `digest` is null when
225
- * there is nothing to approve. A plugin without an identity block has the
226
- * digest it had before identity existed.
236
+ * half Lamaste installs, its identity block (normalized, as the registry
237
+ * stores it) and its web tree — and the digest the approval names.
238
+ * `digest` is null when there is nothing to approve. A plugin without an
239
+ * identity block or a web surface has the digest it had before they
240
+ * existed.
227
241
  */
228
242
  export function installedApproval(plugin) {
229
243
  const relay = installedRelayHooks(plugin);
230
244
  const agentHalf = installedAgentHalf(plugin);
231
245
  const identity = plugin.identity ?? null;
232
- const required = relay !== null || agentHalf !== null || identity !== null;
246
+ const web = installedWebTree(plugin);
247
+ const required = relay !== null || agentHalf !== null || identity !== null || web !== null;
233
248
  return {
234
249
  relay,
235
250
  agentHalf,
236
251
  identity,
252
+ web,
237
253
  required,
238
254
  digest: required
239
255
  ? approvalDigest({
240
256
  relay: relay ? { set: relay.set, entrySha256: relay.entrySha256 } : null,
241
257
  agentHalf,
242
258
  identity,
259
+ web,
243
260
  })
244
261
  : null,
245
262
  };
@@ -269,7 +286,7 @@ export async function installedVersionApproved(plugin) {
269
286
  */
270
287
  export async function hookStatus(plugin) {
271
288
  const approval = installedApproval(plugin);
272
- const { relay, agentHalf, identity } = approval;
289
+ const { relay, agentHalf, identity, web } = approval;
273
290
  const agent = agentHookSetOf({ hooks: plugin.hooks });
274
291
  const state = await readHookState(plugin.name);
275
292
  const approved = isApproved(approval, state);
@@ -290,6 +307,12 @@ export async function hookStatus(plugin) {
290
307
  approved: identity !== null && approved,
291
308
  block: identity,
292
309
  },
310
+ web: {
311
+ declared: web !== null,
312
+ approved: web !== null && approved,
313
+ treeSha256: web?.treeSha256 ?? null,
314
+ surface: plugin.userSurface?.web ?? null,
315
+ },
293
316
  approval: {
294
317
  required: approval.required,
295
318
  state: !approval.required ? 'not-required' : approved ? 'approved' : 'pending',
@@ -319,16 +342,17 @@ export const IDENTITY_CHANGED = 'identity.changed';
319
342
  /**
320
343
  * The reason a staged version waits because what it brings — its relay
321
344
  * hooks (`relay`), the agent half Lamaste installs (`agentHalf`), its
322
- * identity block (`identity`); each truthy when it brings one — is not what
323
- * was approved.
345
+ * identity block (`identity`), its web tree (`web`); each truthy when it
346
+ * brings one — is not what was approved.
324
347
  *
325
348
  * @returns {{ code: string, reason: string }}
326
349
  */
327
- export function digestUnapprovedReason({ relay, agentHalf, identity = null }) {
350
+ export function digestUnapprovedReason({ relay, agentHalf, identity = null, web = null }) {
328
351
  const parts = [
329
- ...(relay || (!agentHalf && !identity) ? ['relay hooks'] : []),
352
+ ...(relay || (!agentHalf && !identity && !web) ? ['relay hooks'] : []),
330
353
  ...(agentHalf ? ['agent half'] : []),
331
354
  ...(identity ? ['identity'] : []),
355
+ ...(web ? ['web surface'] : []),
332
356
  ];
333
357
  const named =
334
358
  parts.length === 1
@@ -351,6 +375,7 @@ function pendingApprovalRequired(pending) {
351
375
  relay: pending.preview,
352
376
  agentHalf: pending.agentHalf,
353
377
  identity: pending.identity?.after ?? null,
378
+ web: pending.web?.after ?? null,
354
379
  }),
355
380
  ]
356
381
  );
@@ -369,7 +394,10 @@ function pendingApprovalRequired(pending) {
369
394
  * how it changes the identity block (`identity`: `{ before, after,
370
395
  * rolesAdded, rolesRemoved, attributesAdded, attributesRemoved,
371
396
  * labelsChanged }` with the normalized blocks, null when neither version
372
- * declares one — part of the digest), why it waits for an administrator
397
+ * declares one — part of the digest), its web tree (`web`: `{ before,
398
+ * after }`, the installed version's tree digest and the staged one's
399
+ * `{ sha256, files, totalBytes }`, null when neither declares a web
400
+ * surface — part of the digest), why it waits for an administrator
373
401
  * (`approvalRequired`: `[{ code, reason }]`, `digest.unapproved`,
374
402
  * `settings.secret-to-plain` and `identity.changed`; never empty), and —
375
403
  * when the plugin has auto-approve on — why auto-approve did not cover it
@@ -385,6 +413,7 @@ export function pendingUpdateView(pending) {
385
413
  agentHalf: pending.agentHalf ?? null,
386
414
  settings: pending.settings ?? null,
387
415
  identity: pending.identity ?? null,
416
+ web: pending.web ?? null,
388
417
  approvalRequired: pendingApprovalRequired(pending),
389
418
  autoApproveWithheld: pending.autoApproveWithheld ?? null,
390
419
  };