@lamalibre/create-lamaste 4.5.0 → 4.7.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 (178) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +56 -5
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  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 +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/02-guides/static-sites.md +4 -0
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +80 -12
  34. package/vendor/docs/03-architecture/overview.md +5 -5
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +3 -3
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
  40. package/vendor/docs/04-api-reference/app-host.md +3 -3
  41. package/vendor/docs/04-api-reference/domains.md +192 -0
  42. package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
  43. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  44. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  45. package/vendor/docs/04-api-reference/overview.md +9 -0
  46. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  47. package/vendor/docs/04-api-reference/sites.md +9 -0
  48. package/vendor/docs/04-api-reference/tunnels.md +35 -28
  49. package/vendor/docs/04-api-reference/users.md +41 -29
  50. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  51. package/vendor/docs/05-operations/uninstalling.md +1 -1
  52. package/vendor/docs/05-operations/upgrades.md +15 -6
  53. package/vendor/docs/06-reference/config-files.md +19 -15
  54. package/vendor/docs/06-reference/glossary.md +3 -3
  55. package/vendor/docs/06-reference/installer-flags.md +13 -13
  56. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  57. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  58. package/vendor/docs/_index.json +105 -19
  59. package/vendor/docs/external-members-design.md +816 -0
  60. package/vendor/docs/gatekeeper-design.md +8 -8
  61. package/vendor/docs/identity-directory-design.md +3 -3
  62. package/vendor/docs/multi-domain-relay-design.md +836 -0
  63. package/vendor/docs/package.json +1 -1
  64. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  65. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  66. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/index.js +2 -2
  68. package/vendor/gatekeeper/dist/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  70. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  72. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  73. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  74. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  76. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  77. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  79. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  81. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  82. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  83. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  84. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  85. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  86. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  88. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  89. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  90. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  91. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  92. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  93. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  94. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  95. package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
  96. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/index.js +56 -11
  98. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  100. package/vendor/gatekeeper/dist/server/routes/authz.js +325 -55
  101. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  102. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  103. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  104. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  105. package/vendor/gatekeeper/package.json +2 -2
  106. package/vendor/server/bin/lamaste-server.js +7 -0
  107. package/vendor/server/package.json +2 -2
  108. package/vendor/server/src/commands/domains.js +214 -0
  109. package/vendor/server/src/commands/plugin-identity.js +2 -16
  110. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  111. package/vendor/server/src/commands/plugins.js +175 -14
  112. package/vendor/server/src/commands/registry-credential.js +10 -3
  113. package/vendor/server/src/commands/sites.js +18 -1
  114. package/vendor/server/src/commands/status.js +19 -0
  115. package/vendor/server/src/commands/tunnels.js +4 -1
  116. package/vendor/server/src/commands/users.js +8 -19
  117. package/vendor/server/src/domains.js +92 -0
  118. package/vendor/server/src/fail.js +21 -0
  119. package/vendor/server/src/oidc-client-grants.js +63 -0
  120. package/vendor/server/src/panel-api.js +15 -0
  121. package/vendor/server/src/plugin-approval.js +80 -1
  122. package/vendor/server/src/plugin-web-surface.js +307 -0
  123. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  124. package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
  125. package/vendor/server-ui/dist/index.html +2 -2
  126. package/vendor/serverd/package.json +2 -2
  127. package/vendor/serverd/src/index.js +50 -19
  128. package/vendor/serverd/src/lib/app-error.js +24 -0
  129. package/vendor/serverd/src/lib/audience.js +12 -3
  130. package/vendor/serverd/src/lib/authelia.js +341 -35
  131. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  132. package/vendor/serverd/src/lib/chisel.js +10 -2
  133. package/vendor/serverd/src/lib/directory.js +67 -52
  134. package/vendor/serverd/src/lib/domains.js +1792 -0
  135. package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
  136. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  137. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  138. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  139. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  140. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  141. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  142. package/vendor/serverd/src/lib/nginx.js +576 -131
  143. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  144. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  145. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  146. package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
  147. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  148. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  149. package/vendor/serverd/src/lib/plugins.js +82 -9
  150. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  151. package/vendor/serverd/src/lib/services.js +9 -1
  152. package/vendor/serverd/src/lib/state.js +29 -0
  153. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  154. package/vendor/serverd/src/lib/user-surface.js +77 -31
  155. package/vendor/serverd/src/lib/users-lock.js +23 -0
  156. package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
  157. package/vendor/serverd/src/lib/web-surface.js +783 -0
  158. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  159. package/vendor/serverd/src/routes/invite.js +15 -5
  160. package/vendor/serverd/src/routes/management/domains.js +154 -0
  161. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  162. package/vendor/serverd/src/routes/management/identity.js +21 -7
  163. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  164. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  165. package/vendor/serverd/src/routes/management/plugins.js +6 -2
  166. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  167. package/vendor/serverd/src/routes/management/sites.js +188 -71
  168. package/vendor/serverd/src/routes/management/tunnels.js +37 -22
  169. package/vendor/serverd/src/routes/management/users.js +184 -64
  170. package/vendor/serverd/src/routes/management.js +4 -0
  171. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  172. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  173. package/vendor/serverd/src/routes/user-access.js +13 -1
  174. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  175. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  176. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  177. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  178. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -22,6 +22,7 @@ import {
22
22
  normalizeIdentityBlock,
23
23
  pluginHostCapability,
24
24
  validateManifest as coreValidateManifest,
25
+ validateInstalledManifest,
25
26
  } from '@lamalibre/lamaste';
26
27
  import { listRegisteredSubScopeNames } from './tickets.js';
27
28
  import { getStateDb } from './state-db.js';
@@ -31,6 +32,7 @@ import { RegistryCredentialError } from '@lamalibre/lamaste/server';
31
32
  import {
32
33
  bareLamalibrePackageName,
33
34
  installedHookEntrySha256,
35
+ installedWebTreeSha256,
34
36
  pinAgentHalf,
35
37
  } from './plugin-packages.js';
36
38
 
@@ -72,8 +74,9 @@ async function getStmts() {
72
74
  INSERT INTO plugins
73
75
  (name, route, display_name, package_name, version, description, capabilities,
74
76
  packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
75
- agent_half_pin, settings, identity, status, installed_at, enabled_at)
76
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
+ agent_half_pin, settings, identity, user_surface, web_tree_sha256, status,
78
+ installed_at, enabled_at, domain)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
80
  `),
78
81
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
79
82
  // The plugin's audience and the state of its identity feed
@@ -141,7 +144,11 @@ function rowToPlugin(row) {
141
144
  if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
142
145
  if (row.settings != null) entry.settings = JSON.parse(row.settings);
143
146
  if (row.identity != null) entry.identity = JSON.parse(row.identity);
147
+ if (row.user_surface != null) entry.userSurface = JSON.parse(row.user_surface);
148
+ if (row.web_tree_sha256 != null) entry.webTreeSha256 = row.web_tree_sha256;
144
149
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
150
+ // The domain the plugin belongs to; null: the primary (lib/domains.js).
151
+ entry.domain = row.domain ?? null;
145
152
  return entry;
146
153
  }
147
154
 
@@ -224,9 +231,12 @@ export async function writePlugins(data) {
224
231
  nullableJson(p.agentHalfPin),
225
232
  nullableJson(p.settings),
226
233
  nullableJson(p.identity),
234
+ nullableJson(p.userSurface),
235
+ nullableString(p.webTreeSha256),
227
236
  p.status,
228
237
  p.installedAt,
229
238
  nullableString(p.enabledAt),
239
+ nullableString(p.domain),
230
240
  );
231
241
  }
232
242
  s.commit.run();
@@ -457,6 +467,17 @@ export function installPlugin(packageName, logger) {
457
467
  throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
458
468
  }
459
469
 
470
+ // A web surface: its tree must be in the package and keep every rule of
471
+ // a web tree; its digest goes into the approval digest, and the relay
472
+ // serves only a copy that matches it.
473
+ let webTreeSha256 = null;
474
+ try {
475
+ webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
476
+ } catch (err) {
477
+ await discardPackage();
478
+ throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
479
+ }
480
+
460
481
  // An agent half Lamaste installs: its tarball's integrity is pinned now,
461
482
  // from the registry this install used — the approval covers it and the
462
483
  // agent installs only a tarball that matches it.
@@ -508,6 +529,8 @@ export function installPlugin(packageName, logger) {
508
529
  agentHalfPin,
509
530
  settings: manifest.settings,
510
531
  identity: identityOf(manifest),
532
+ userSurface: manifest.userSurface,
533
+ webTreeSha256,
511
534
  status: 'disabled',
512
535
  installedAt: new Date().toISOString(),
513
536
  };
@@ -536,9 +559,13 @@ export function installPlugin(packageName, logger) {
536
559
  nullableJson(entry.agentHalfPin),
537
560
  nullableJson(entry.settings),
538
561
  nullableJson(entry.identity),
562
+ nullableJson(entry.userSurface),
563
+ nullableString(entry.webTreeSha256),
539
564
  entry.status,
540
565
  entry.installedAt,
541
566
  null,
567
+ // A plugin installed now belongs to the primary domain (lib/plugin-domain.js moves it).
568
+ null,
542
569
  );
543
570
  s.commit.run();
544
571
  } catch (err) {
@@ -601,6 +628,13 @@ export function uninstallPlugin(name, logger) {
601
628
  const { removeRelayHooks } = await import('./plugin-hooks.js');
602
629
  await removeRelayHooks(name, logger);
603
630
 
631
+ // The OIDC clients it declares, read while its manifest is installed:
632
+ // their grants go with it below.
633
+ const { collectPluginOidcClients } = await import('./plugin-oidc.js');
634
+ const clientIds = (await collectPluginOidcClients().catch(() => []))
635
+ .filter((c) => c.plugin === name)
636
+ .map((c) => c.id);
637
+
604
638
  // Uninstall the npm package
605
639
  logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
606
640
  await execa('npm', ['uninstall', plugin.packageName], {
@@ -674,6 +708,24 @@ export function uninstallPlugin(name, logger) {
674
708
 
675
709
  logger.info({ name }, 'Plugin uninstalled');
676
710
  await syncOidcClientsBestEffort(logger);
711
+
712
+ // Grants on its OIDC clients end with it: client ids are chosen, not
713
+ // random, so a later install of the plugin (or another declaring the
714
+ // same id) would otherwise admit whoever was granted before.
715
+ const { revokeOidcClientGrants } = await import('./owned-groups.js');
716
+ const revoked = await revokeOidcClientGrants(clientIds);
717
+ if (!revoked.ok) {
718
+ logger.warn(
719
+ { name, err: revoked.error },
720
+ "Grants on the uninstalled plugin's OIDC clients stayed — revoke them in Gatekeeper",
721
+ );
722
+ } else if (revoked.revoked > 0) {
723
+ logger.info({ name, revoked: revoked.revoked }, 'Grants on its OIDC clients revoked');
724
+ const { syncDomainAccess } = await import('./domains.js');
725
+ await syncDomainAccess({ logger }).catch((err) => {
726
+ logger.warn({ err: err.message }, 'Rendering domain access after an uninstall failed');
727
+ });
728
+ }
677
729
  return { ok: true, name };
678
730
  });
679
731
  }
@@ -684,7 +736,7 @@ const updateRowSql = `
684
736
  UPDATE plugins SET
685
737
  display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
686
738
  config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
687
- agent_half_pin = ?, settings = ?, identity = ?
739
+ agent_half_pin = ?, settings = ?, identity = ?, user_surface = ?, web_tree_sha256 = ?
688
740
  WHERE name = ?`;
689
741
 
690
742
  /**
@@ -698,8 +750,16 @@ const updateRowSql = `
698
750
  * beside it, and a secret whose field became a plain value is no longer
699
751
  * reported set. The identity block is stored normalized; the update's
700
752
  * caller recomputes the audiences once the version is in place.
753
+ * `webTreeSha256` is the digest of the version's web tree, read from the
754
+ * installed package (null when it declares no web surface).
701
755
  */
702
- export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
756
+ export async function writePluginRow(
757
+ name,
758
+ manifest,
759
+ hooksEntrySha256,
760
+ agentHalfPin,
761
+ webTreeSha256 = null,
762
+ ) {
703
763
  const s = await getStmts();
704
764
  const { prunedSettingsCache } = await import('./plugin-settings.js');
705
765
  s.begin.run();
@@ -723,6 +783,8 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
723
783
  nullableJson(agentHalfPin),
724
784
  nullableJson(manifest.settings),
725
785
  nullableJson(identityOf(manifest)),
786
+ nullableJson(manifest.userSurface),
787
+ nullableString(webTreeSha256),
726
788
  name,
727
789
  );
728
790
  const pruned = prunedSettingsCache(
@@ -750,12 +812,21 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
750
812
 
751
813
  /**
752
814
  * Install `<packageName>@<version>` over the installed copy and read it
753
- * back: the validated manifest and its relay entry module's SHA-256. The
815
+ * back: the validated manifest, its relay entry module's SHA-256 and its
816
+ * web tree's digest. The
754
817
  * row is not touched — the caller compares the result with what was
755
818
  * reviewed and then writes it. Refuses a package whose manifest names
756
- * another plugin.
819
+ * another plugin. `restoring` puts back the version that was installed (an
820
+ * update's revert): its manifest is read as an installed one, so a rule
821
+ * tightened since it was installed does not leave the revert half done.
757
822
  */
758
- export function replacePluginPackage(name, packageName, version, logger) {
823
+ export function replacePluginPackage(
824
+ name,
825
+ packageName,
826
+ version,
827
+ logger,
828
+ { restoring = false } = {},
829
+ ) {
759
830
  return withPluginLock(async () => {
760
831
  const bare = bareLamalibrePackageName(packageName);
761
832
  const spec = `${bare}@${version}`;
@@ -777,7 +848,8 @@ export function replacePluginPackage(name, packageName, version, logger) {
777
848
  }
778
849
  const require = createRequire(`${STATE_DIR}/`);
779
850
  const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
780
- const manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
851
+ const raw = JSON.parse(await readFile(manifestPath, 'utf-8'));
852
+ const manifest = restoring ? validateInstalledManifest(raw) : validateManifest(raw);
781
853
  if (manifest.name !== name) {
782
854
  throw Object.assign(new Error(`${spec} is plugin "${manifest.name}", not "${name}"`), {
783
855
  statusCode: 400,
@@ -790,7 +862,8 @@ export function replacePluginPackage(name, packageName, version, logger) {
790
862
  );
791
863
  }
792
864
  const hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
793
- return { manifest, hooksEntrySha256 };
865
+ const webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
866
+ return { manifest, hooksEntrySha256, webTreeSha256 };
794
867
  });
795
868
  }
796
869
 
@@ -57,6 +57,33 @@ export async function writeRegistryCredentialConfig(config) {
57
57
  resetRegistryTokenState();
58
58
  }
59
59
 
60
+ /**
61
+ * Store `config` as the registry credential: written while its issuer
62
+ * domain is still an active domain (a domain's removal checks the
63
+ * credential under the same lock — 400 `unknown_domain` otherwise). When the
64
+ * registry's domains change (its issuer domain, the domain its host is
65
+ * under), the tokens of every user who is a member of none of the new ones
66
+ * are revoked: who may hold a token follows them. Returns the warnings.
67
+ *
68
+ * @param {object} config
69
+ * @param {{ logger?: import('pino').Logger }} [opts]
70
+ * @returns {Promise<Array<{ step: string, message: string }>>}
71
+ */
72
+ export async function setRegistryCredential(config, { logger } = {}) {
73
+ const { registryDomainsOf, revokeRegistryTokensOutside, withActiveDomain } =
74
+ await import('./domains.js');
75
+ const issuerDomain = config.auth?.kind === 'oidc' ? config.auth.issuerDomain : undefined;
76
+ const change = await withActiveDomain(issuerDomain, async () => {
77
+ const before = await registryDomainsOf(await readRegistryCredentialConfig().catch(() => null));
78
+ await writeRegistryCredentialConfig(config);
79
+ const after = await registryDomainsOf(config);
80
+ const same = after.length === before.length && after.every((d) => before.includes(d));
81
+ return { changed: !same, after };
82
+ });
83
+ if (!change.changed) return [];
84
+ return revokeRegistryTokensOutside(change.after, config, logger);
85
+ }
86
+
60
87
  export async function clearRegistryCredentialConfig() {
61
88
  const removed = await clearCore(STATE_DIR);
62
89
  resetRegistryTokenState();
@@ -70,9 +97,9 @@ export function resetRegistryTokenState() {
70
97
  }
71
98
 
72
99
  /**
73
- * What the core needs to run the OIDC chain for a credential: the relay
74
- * domain and the `lamaste-server` client secret. Pasted-token credentials
75
- * need nothing.
100
+ * What the core needs to run the OIDC chain for a credential: the primary
101
+ * domain (the issuer when the credential names no `issuerDomain`) and the
102
+ * `lamaste-server` client secret. Pasted-token credentials need nothing.
76
103
  * @param {{ auth: { kind: string } }} config
77
104
  */
78
105
  async function oidcDeps(config) {
@@ -166,10 +193,16 @@ export async function acquireAgentToken(agentLabel) {
166
193
  * @param {string} username
167
194
  * @param {{ before: string[], after: string[] } | { deleted: true }} change
168
195
  * @param {import('pino').Logger | undefined} log
169
- * @param {{ userId?: string | null }} [who]
196
+ * @param {{ userId?: string | null, config?: object }} [who] `config`: the credential
197
+ * already read (a batch of revocations reads it once)
170
198
  */
171
- export async function revokeUserRegistryTokens(username, change, log, { userId = null } = {}) {
172
- const config = await readRegistryCredentialConfig();
199
+ export async function revokeUserRegistryTokens(
200
+ username,
201
+ change,
202
+ log,
203
+ { userId = null, config: given } = {},
204
+ ) {
205
+ const config = given ?? (await readRegistryCredentialConfig());
173
206
  if (!config) return null;
174
207
 
175
208
  const lost =
@@ -1,4 +1,5 @@
1
1
  import { execa } from 'execa';
2
+ import { autheliaReady } from '@lamalibre/lamaste/server';
2
3
 
3
4
  export const ALLOWED_SERVICES = ['nginx', 'chisel', 'authelia', 'lamalibre-lamaste-serverd'];
4
5
 
@@ -120,7 +121,6 @@ export async function executeServiceAction(name, action) {
120
121
 
121
122
  try {
122
123
  await execa('sudo', ['systemctl', action, name], { timeout: 30_000 });
123
- return { ok: true, name, action };
124
124
  } catch (err) {
125
125
  const details = err.stderr || err.message;
126
126
  const error = new Error(`Failed to ${action} ${name}`);
@@ -128,4 +128,12 @@ export async function executeServiceAction(name, action) {
128
128
  error.details = details;
129
129
  throw error;
130
130
  }
131
+ // systemctl returns when Authelia's process is spawned, before it serves
132
+ // requests: the action is done once it does.
133
+ if (name === 'authelia' && action !== 'stop' && !(await autheliaReady(execa))) {
134
+ const error = new Error(`Authelia is not serving requests after ${action}`);
135
+ error.statusCode = 500;
136
+ throw error;
137
+ }
138
+ return { ok: true, name, action };
131
139
  }
@@ -48,6 +48,16 @@ export async function writeTunnels(tunnels) {
48
48
  await fd.close();
49
49
 
50
50
  await rename(tmpPath, filePath);
51
+ await hostsChanged();
52
+ }
53
+
54
+ /**
55
+ * A tunnel or a site changed: the hosts that name external members are
56
+ * rendered again shortly (lib/domains.js `scheduleHostRender`).
57
+ */
58
+ async function hostsChanged() {
59
+ const { scheduleHostRender } = await import('./domains.js');
60
+ scheduleHostRender();
51
61
  }
52
62
 
53
63
  function sitesPath() {
@@ -93,6 +103,25 @@ export async function writeSites(sites) {
93
103
  await fd.close();
94
104
 
95
105
  await rename(tmpPath, filePath);
106
+ await hostsChanged();
107
+ }
108
+
109
+ /** Serializes every read-modify-write of sites.json in this process. */
110
+ const sitesMutex = new PromiseChainMutex();
111
+
112
+ /**
113
+ * Run `fn` holding the sites lock: a read of sites.json, the decisions made
114
+ * on it and the write that follows must not interleave with another. A
115
+ * workflow that gives a site a hostname (creating it, adding an alias,
116
+ * taking a tunnel's host) takes core's tunnel lock first, then this one —
117
+ * always in that order — so a site and a tunnel never take one name at once.
118
+ *
119
+ * @template T
120
+ * @param {() => Promise<T>} fn
121
+ * @returns {Promise<T>}
122
+ */
123
+ export function withSitesLock(fn) {
124
+ return sitesMutex.run(fn);
96
125
  }
97
126
 
98
127
  function invitationsPath() {
@@ -7,8 +7,7 @@
7
7
 
8
8
  import {
9
9
  writePublicVhost,
10
- writeAuthenticatedVhost,
11
- writeRestrictedVhost,
10
+ writeGatekeeperVhost,
12
11
  removeAppVhost,
13
12
  enableAppVhost,
14
13
  disableAppVhost,
@@ -23,8 +22,7 @@ import { readTunnels, writeTunnels } from './state.js';
23
22
 
24
23
  export const tunnelNginxDeps = Object.freeze({
25
24
  writePublicVhost,
26
- writeAuthenticatedVhost,
27
- writeRestrictedVhost,
25
+ writeGatekeeperVhost,
28
26
  writeAgentPanelVhost,
29
27
  removeAppVhost,
30
28
  removeAgentPanelVhost,
@@ -45,8 +45,11 @@ import {
45
45
  RELAY_PANEL_AUDIENCE,
46
46
  RELAY_PANEL_PORT,
47
47
  isAgentHostedPlugin,
48
+ siteUserSurfaceRoute,
48
49
  tunnelUserSurfaceRoutes,
50
+ userSurfaceHostServing,
49
51
  userSurfaceHostingAgent,
52
+ userSurfaceHosts,
50
53
  verifyRelayAssertion,
51
54
  } from '@lamalibre/lamaste';
52
55
  import {
@@ -54,15 +57,17 @@ import {
54
57
  TunnelError,
55
58
  bindUserSurface,
56
59
  releaseUserSurfaceRoute,
60
+ tunnelHost,
57
61
  unbindUserSurface,
58
62
  } from '@lamalibre/lamaste/server';
59
63
  import { httpError } from './app-error.js';
60
64
  import { recordAuditBestEffort } from './audit.js';
61
65
  import { getConfig } from './config.js';
66
+ import { pluginDomainName } from './domains.js';
62
67
  import { installedVersionApproved } from './plugin-hooks.js';
63
68
  import { findPluginByRoute } from './plugins.js';
64
69
  import { relaySigner } from './relay-signing.js';
65
- import { readTunnels } from './state.js';
70
+ import { readSites, readTunnels } from './state.js';
66
71
  import { tunnelCertbotDeps, tunnelNginxDeps, tunnelStateDeps } from './tunnel-deps.js';
67
72
 
68
73
  /**
@@ -102,7 +107,7 @@ export const USER_SURFACE_ROUTE_CONFIG = Object.freeze({
102
107
  * @param {readonly object[]} tunnels
103
108
  * @returns {Promise<string | null>}
104
109
  */
105
- async function hostingAgentOf(plugin, tunnels) {
110
+ export async function hostingAgentOf(plugin, tunnels) {
106
111
  if (!plugin.route) return null;
107
112
  const { ownHosting } = await import('./plugin-hosting.js');
108
113
  const record = await ownHosting(plugin);
@@ -160,7 +165,7 @@ async function routeServing(route, agentLabel, tunnels) {
160
165
  * @param {object} plugin
161
166
  * @param {readonly object[]} tunnels the tunnel state
162
167
  */
163
- async function internalTunnelBodyLimit(plugin, tunnels) {
168
+ export async function internalTunnelBodyLimit(plugin, tunnels) {
164
169
  const { ownHosting } = await import('./plugin-hosting.js');
165
170
  const record = await ownHosting(plugin);
166
171
  if (!record) return TUNNEL_MAX_BODY_MB.default;
@@ -222,8 +227,8 @@ export async function withUserSurfaceStatus(tunnel) {
222
227
  // ---------------------------------------------------------------------------
223
228
 
224
229
  /**
225
- * What core's binding workflows need from the panel — among them its domain
226
- * and Let's Encrypt email, which a vhost rewrite needs.
230
+ * What core's binding workflows need from the panel — among them its Let's
231
+ * Encrypt email, which a vhost rewrite needs.
227
232
  */
228
233
  function bindingOptions(id, route, logger) {
229
234
  const config = getConfig();
@@ -233,7 +238,6 @@ function bindingOptions(id, route, logger) {
233
238
  return {
234
239
  id,
235
240
  route,
236
- domain: config.domain,
237
241
  email: config.email,
238
242
  nginx: tunnelNginxDeps,
239
243
  certbot: tunnelCertbotDeps,
@@ -287,6 +291,34 @@ async function assertBindableRoute(route) {
287
291
  'INVALID_SETTING',
288
292
  );
289
293
  }
294
+ // A route is served on one host at most: the plugin's own site, when it
295
+ // has one, is that host.
296
+ if ((await readSites()).some((site) => siteUserSurfaceRoute(site) === route)) {
297
+ throw new TunnelError(
298
+ `Plugin "${plugin.name}" has a web surface — its user surface is served there`,
299
+ 'INVALID_SETTING',
300
+ );
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Refuse binding tunnel `id` as the user surface of the plugin at `route`
306
+ * when the tunnel's hostname is not on the plugin's domain (400
307
+ * `not_plugin_domain`): a plugin's users are its domain's members.
308
+ */
309
+ async function assertOnPluginDomain(id, route) {
310
+ const tunnel = (await readTunnels()).find((t) => t.id === id);
311
+ const host = tunnel ? tunnelHost(tunnel) : null;
312
+ if (!host) return; // core refuses a tunnel without a hostname
313
+ const plugin = await findPluginByRoute(route);
314
+ const domain = await pluginDomainName(plugin);
315
+ if (host.domain !== domain) {
316
+ throw httpError(
317
+ `The tunnel is on ${host.domain}; the plugin holding '${route}' belongs to ${domain}`,
318
+ 400,
319
+ { code: 'not_plugin_domain' },
320
+ );
321
+ }
290
322
  }
291
323
 
292
324
  /** Whether an installed agent-hosted plugin holds `route`. */
@@ -318,7 +350,11 @@ async function isBindableRoute(route) {
318
350
  export async function bindTunnelUserSurface(id, route, { actor, logger }) {
319
351
  const result = await bindUserSurface({
320
352
  ...bindingOptions(id, route, logger),
321
- assertRoute: assertBindableRoute,
353
+ // Under the tunnel lock, as the plugin's domain changes (lib/domains.js).
354
+ assertRoute: async (r) => {
355
+ await assertBindableRoute(r);
356
+ await assertOnPluginDomain(id, r);
357
+ },
322
358
  });
323
359
  if (result.changed && !(await isBindableRoute(route))) {
324
360
  await pruneUserSurfaceBindings(route, { plugin: null, logger });
@@ -413,7 +449,8 @@ export async function refreshTunnelUserSurfaceVhosts(ids, logger) {
413
449
  * Whether plugin `route`'s user surface is served may have changed — its
414
450
  * version was approved, updated or reverted, it was hosted or unhosted (the
415
451
  * body limit is its internal tunnel's): bring the vhosts of the tunnels it
416
- * is bound to in line. Best effort — logged, never thrown; the panel's
452
+ * is bound to in line, and its web surface — the approved tree served, the
453
+ * vhost of the site it owns (`lib/web-surface.js`). Best effort — logged, never thrown; the panel's
417
454
  * start reconciles every vhost again.
418
455
  *
419
456
  * @param {string | null | undefined} route
@@ -429,6 +466,9 @@ export async function refreshUserSurfaceVhosts(route, logger) {
429
466
  'Could not bring the user-surface vhosts of a plugin in line',
430
467
  );
431
468
  }
469
+ // Its web surface too: the approved tree served, the site's vhost in line.
470
+ const { refreshWebSurface } = await import('./web-surface.js');
471
+ await refreshWebSurface(route, logger);
432
472
  }
433
473
 
434
474
  /**
@@ -491,6 +531,10 @@ export async function releasePluginUserSurface(route, { plugin, logger }) {
491
531
  'Could not release the user surface of an uninstalled plugin',
492
532
  );
493
533
  }
534
+ // The site it owns stays, answering 503 until the plugin is back (or an
535
+ // administrator deletes it): its vhost goes in line.
536
+ const { refreshWebSurface } = await import('./web-surface.js');
537
+ await refreshWebSurface(route, logger);
494
538
  }
495
539
 
496
540
  // ---------------------------------------------------------------------------
@@ -560,33 +604,35 @@ export async function verifyUserSurfaceRelay(request) {
560
604
  }
561
605
 
562
606
  /**
563
- * The enabled tunnel whose host serves `route`'s user surface, or null:
564
- * bound to the route, and carried by the agent hosting its plugin — the
565
- * check of every request, whatever a vhost not yet re-rendered still
566
- * routes here.
607
+ * The enabled host that serves `route`'s user surface, or null — the check
608
+ * of every request, whatever a vhost not yet re-rendered still routes here:
609
+ * a tunnel bound to the route and carried by the agent hosting its plugin,
610
+ * or the site the plugin owns while its web surface is served
611
+ * (`lib/web-surface.js`). Read with core `userSurfaceHosts`, as Gatekeeper
612
+ * reads it, so a route two hosts would serve is served by neither.
567
613
  *
568
614
  * @param {string} route
615
+ * @returns {Promise<import('@lamalibre/lamaste').UserSurfaceHost | null>}
569
616
  */
570
- async function servedUserSurfaceTunnel(route) {
571
- const tunnels = await readTunnels();
572
- const tunnel =
573
- tunnels.find(
574
- (t) =>
575
- t.enabled !== false &&
576
- typeof t.fqdn === 'string' &&
577
- tunnelUserSurfaceRoutes(t).includes(route),
578
- ) ?? null;
579
- if (!tunnel) return null;
617
+ async function servedUserSurfaceHost(route) {
618
+ const [tunnels, sites] = await Promise.all([readTunnels(), readSites()]);
619
+ const host = userSurfaceHostServing(userSurfaceHosts(tunnels, sites), route);
620
+ if (!host) return null;
621
+ if (host.kind === 'site') {
622
+ const { webSurfaceServing } = await import('./web-surface.js');
623
+ return (await webSurfaceServing(route, tunnels)).served ? host : null;
624
+ }
580
625
  const plugin = await findPluginByRoute(route);
581
626
  if (!plugin) return null;
627
+ const tunnel = tunnels.find((t) => t.id === host.id);
582
628
  const hosting = await hostingAgentOf(plugin, tunnels);
583
- return hosting !== null && tunnel.agentLabel === hosting ? tunnel : null;
629
+ return hosting !== null && tunnel?.agentLabel === hosting ? host : null;
584
630
  }
585
631
 
586
632
  /**
587
633
  * The forward target of `route`'s user surface (core `userSurfacePlugin`'s
588
- * `resolveTarget`): null when no enabled tunnel its hosting agent carries
589
- * is bound to it, else what the admin forwarder resolves — the hosting's
634
+ * `resolveTarget`): null when no host serves it (see
635
+ * {@link servedUserSurfaceHost}), else what the admin forwarder resolves — the hosting's
590
636
  * internal tunnel, or why not (not found, disabled, not hosted) — and
591
637
  * `down` while the hosting's health is down: nothing of a user's request
592
638
  * reaches the relay-loopback port then (503).
@@ -594,7 +640,7 @@ async function servedUserSurfaceTunnel(route) {
594
640
  * @param {string} route
595
641
  */
596
642
  export async function resolveUserSurfaceTarget(route) {
597
- if (!(await servedUserSurfaceTunnel(route))) return null;
643
+ if (!(await servedUserSurfaceHost(route))) return null;
598
644
  const { resolveForwardTarget } = await import('./plugin-hosting.js');
599
645
  return resolveForwardTarget(route);
600
646
  }
@@ -602,15 +648,15 @@ export async function resolveUserSurfaceTarget(route) {
602
648
  /**
603
649
  * The origin a request that may change something on `route`'s user surface
604
650
  * must come from (core `userSurfacePlugin`'s `originFor`): the bound host,
605
- * as the tunnel state names it — never the request's `Host`. Null when no
606
- * enabled tunnel its hosting agent carries is bound to the route.
651
+ * as the state names it — never the request's `Host`. Null when no host
652
+ * serves the route.
607
653
  *
608
654
  * @param {string} route
609
655
  * @returns {Promise<string | null>}
610
656
  */
611
657
  export async function userSurfaceOrigin(route) {
612
- const tunnel = await servedUserSurfaceTunnel(route);
613
- return tunnel ? `https://${tunnel.fqdn.toLowerCase()}` : null;
658
+ const host = await servedUserSurfaceHost(route);
659
+ return host ? `https://${host.fqdn}` : null;
614
660
  }
615
661
 
616
662
  // ---------------------------------------------------------------------------
@@ -702,7 +748,7 @@ export async function sweepOpenUserSurfaceResponses(logger) {
702
748
  const { route, caller } = response;
703
749
  let verdict;
704
750
  try {
705
- if (!served.has(route)) served.set(route, (await servedUserSurfaceTunnel(route)) !== null);
751
+ if (!served.has(route)) served.set(route, (await servedUserSurfaceHost(route)) !== null);
706
752
  const key = `${route}\n${caller.username}`;
707
753
  if (!callers.has(key)) callers.set(key, await resolveUserCaller(route, caller.username));
708
754
  const resolution = callers.get(key);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The users lock — the one lock around every read-modify-write of users.yml
3
+ * and every change of the user directory in this process (core's users
4
+ * functions take none), and around the changes that must agree with
5
+ * users.yml's groups: a membership's kind (an external member holds no
6
+ * Authelia group — lib/domains.js). Taken before the domains lock, never
7
+ * after it.
8
+ */
9
+
10
+ import { PromiseChainMutex } from '@lamalibre/lamaste';
11
+
12
+ const usersMutex = new PromiseChainMutex();
13
+
14
+ /**
15
+ * Run `fn` holding the users lock.
16
+ *
17
+ * @template T
18
+ * @param {() => Promise<T>} fn
19
+ * @returns {Promise<T>}
20
+ */
21
+ export function withUsersLock(fn) {
22
+ return usersMutex.run(fn);
23
+ }