@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,16 +22,20 @@ import {
22
22
  updateTunnel,
23
23
  TUNNEL_MAX_BODY_MB,
24
24
  grantedTunnelsFor,
25
+ hostnameHolder,
25
26
  TunnelError,
26
27
  } from '@lamalibre/lamaste/server';
27
28
  import {
28
29
  AGENT_LABEL_REGEX,
30
+ DomainNameSchema,
29
31
  INTERNAL_PLUGIN_PORT_RANGE,
30
32
  PLUGIN_AGENT_CN_PREFIX,
31
33
  RESERVED_TUNNEL_PORTS,
32
34
  isInternalPluginPort,
33
35
  } from '@lamalibre/lamaste';
34
36
  import { getConfig } from '../../lib/config.js';
37
+ import { primaryDomainName, resolveActiveDomain } from '../../lib/domains.js';
38
+ import { sendFailure } from '../../lib/app-error.js';
35
39
  import { readTunnels, readSites } from '../../lib/state.js';
36
40
  import { getAgentHostFactsHash, loadAgentRegistry } from '../../lib/mtls.js';
37
41
  import { getChiselCredentialIssuedAt } from '../../lib/chisel-users.js';
@@ -147,6 +151,9 @@ const CreateTunnelSchema = z
147
151
  agentLabel: AgentLabelSchema.optional(),
148
152
  accessMode: AccessModeSchema.optional().default('restricted'),
149
153
  maxBodySizeMb: MaxBodySizeSchema.optional(),
154
+ // The base domain the hostname is under (an active one; the primary
155
+ // when absent). An agent panel is always on the primary.
156
+ domain: DomainNameSchema.optional(),
150
157
  })
151
158
  .refine((d) => d.type !== 'plugin' || (d.pluginName && d.agentLabel), {
152
159
  message: 'pluginName and agentLabel are required for plugin tunnels',
@@ -220,10 +227,15 @@ function maxBodyCeiling(request) {
220
227
  return request.certRole === 'admin' ? TUNNEL_MAX_BODY_MB.max : TUNNEL_MAX_BODY_MB.agentMax;
221
228
  }
222
229
 
223
- /** A static site (or one of its aliases) that already answers on `fqdn`. */
230
+ /**
231
+ * Core `createTunnel`'s `hostTaken`: the static site (or one of its
232
+ * aliases) that already answers on `fqdn` — two vhosts with one
233
+ * server_name make nginx pick silently. Asked under the tunnel lock, which
234
+ * every workflow giving a site a hostname holds too.
235
+ */
224
236
  async function siteServing(fqdn) {
225
- const sites = await readSites();
226
- return sites.find((s) => s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn)) ?? null;
237
+ const holder = hostnameHolder(fqdn, { sites: await readSites() });
238
+ return holder ? `by a static site ('${holder.name}')` : null;
227
239
  }
228
240
 
229
241
  /**
@@ -336,6 +348,7 @@ export default async function tunnelRoutes(fastify, _opts) {
336
348
  tunnels: carried.map((t) => ({
337
349
  port: t.port,
338
350
  subdomain: t.subdomain,
351
+ fqdn: t.fqdn,
339
352
  })),
340
353
  // The agent-hosted plugins this agent hosts: the agent fetches a
341
354
  // plugin's config when it does not hold the secret issued at
@@ -495,15 +508,16 @@ export default async function tunnelRoutes(fastify, _opts) {
495
508
  error: 'Domain and email must be configured before creating tunnels',
496
509
  });
497
510
  }
498
-
499
- // A static site (or one of its aliases) may already answer on this
500
- // hostname; two vhosts with one server_name make nginx pick silently.
501
- const fqdn = `${subdomain}.${config.domain}`;
502
- if (await siteServing(fqdn)) {
503
- return reply.code(400).send({
504
- error: 'Failed to create tunnel',
505
- details: `Domain '${fqdn}' is already served by a static site`,
506
- });
511
+ let domain;
512
+ try {
513
+ domain = await resolveActiveDomain(body.domain);
514
+ } catch (err) {
515
+ return sendFailure(request, reply, err, 'Failed to read the domains');
516
+ }
517
+ if (type === 'panel' && domain !== (await primaryDomainName())) {
518
+ return reply
519
+ .code(400)
520
+ .send({ error: 'An agent panel is served on the primary domain only' });
507
521
  }
508
522
 
509
523
  try {
@@ -516,17 +530,25 @@ export default async function tunnelRoutes(fastify, _opts) {
516
530
  pluginName,
517
531
  agentLabel,
518
532
  maxBodySizeMb,
519
- domain: config.domain,
533
+ domain,
534
+ // Under the tunnel lock: the domain may have been removed since.
535
+ assertDomainActive: async (d) => {
536
+ await resolveActiveDomain(d);
537
+ },
520
538
  email: config.email,
521
539
  nginx: nginxDeps,
522
540
  certbot: certbotDeps,
523
541
  chisel: chiselDeps,
524
542
  state: stateDeps,
525
543
  agents: agentDeps,
544
+ hostTaken: siteServing,
526
545
  logger: request.log,
527
546
  });
528
547
  return reply.code(201).send({ ok: true, tunnel });
529
548
  } catch (err) {
549
+ if (Number.isInteger(err.statusCode)) {
550
+ return sendFailure(request, reply, err, 'Failed to create tunnel');
551
+ }
530
552
  if (err instanceof TunnelError) {
531
553
  const status = tunnelErrorStatus(err.code);
532
554
  return reply.code(status).send({
@@ -592,7 +614,6 @@ export default async function tunnelRoutes(fastify, _opts) {
592
614
  agentLabel: body.agentLabel,
593
615
  accessMode: body.accessMode,
594
616
  maxBodySizeMb: body.maxBodySizeMb,
595
- domain: config.domain,
596
617
  email: config.email,
597
618
  nginx: nginxDeps,
598
619
  certbot: certbotDeps,
@@ -600,6 +621,7 @@ export default async function tunnelRoutes(fastify, _opts) {
600
621
  state: stateDeps,
601
622
  agents: agentDeps,
602
623
  userSurface: userSurfaceDeps,
624
+ hostTaken: siteServing,
603
625
  logger: request.log,
604
626
  authorize: authorizeFor(request, 'change'),
605
627
  });
@@ -748,14 +770,6 @@ export default async function tunnelRoutes(fastify, _opts) {
748
770
  });
749
771
  }
750
772
 
751
- const fqdn = `${subdomain}.${config.domain}`;
752
- if (await siteServing(fqdn)) {
753
- return reply.code(400).send({
754
- error: 'Failed to expose agent panel',
755
- details: `Domain '${fqdn}' is already served by a static site`,
756
- });
757
- }
758
-
759
773
  // Check if a panel tunnel already exists for this agent
760
774
  const existing = await readTunnels();
761
775
  const existingPanel = existing.find((t) => t.type === 'panel' && t.subdomain === subdomain);
@@ -780,6 +794,7 @@ export default async function tunnelRoutes(fastify, _opts) {
780
794
  chisel: chiselDeps,
781
795
  state: stateDeps,
782
796
  agents: agentDeps,
797
+ hostTaken: siteServing,
783
798
  logger: request.log,
784
799
  });
785
800
  return reply.code(201).send({ ok: true, tunnel });
@@ -1,5 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
2
+ import {
3
+ USERNAME_MAX_LENGTH,
4
+ USERNAME_MIN_LENGTH,
5
+ USERNAME_REGEX,
6
+ isLamasteOwnedGroup,
7
+ } from '@lamalibre/lamaste';
3
8
  import {
4
9
  readUsers,
5
10
  readUsersRaw,
@@ -9,7 +14,7 @@ import {
9
14
  writeTotpToDatabase,
10
15
  } from '../../lib/authelia.js';
11
16
  import { revokeUserRegistryTokens } from '../../lib/registry-credential.js';
12
- import { httpError } from '../../lib/app-error.js';
17
+ import { httpError, sendFailure } from '../../lib/app-error.js';
13
18
  import { actorFromRequest } from '../../lib/actor.js';
14
19
  import { recordAuditBestEffort } from '../../lib/audit.js';
15
20
  import {
@@ -21,6 +26,19 @@ import {
21
26
  getDirectoryUser,
22
27
  usersFileError,
23
28
  } from '../../lib/directory.js';
29
+ import {
30
+ MembershipInputSchema,
31
+ adminGroups,
32
+ adminGroupsOf,
33
+ assertAssignableGroups,
34
+ domainNames,
35
+ domainsByUser,
36
+ domainsForNewUser,
37
+ domainsOfUser,
38
+ externalMemberGroupsError,
39
+ normalizeMemberships,
40
+ setUserDomains,
41
+ } from '../../lib/domains.js';
24
42
 
25
43
  const CreateUserSchema = z.object({
26
44
  username: z
@@ -34,7 +52,11 @@ const CreateUserSchema = z.object({
34
52
  displayname: z.string().min(1).max(100),
35
53
  email: z.string().email(),
36
54
  password: z.string().min(8).max(128),
37
- groups: z.array(z.string()).optional().default([]),
55
+ groups: z.array(z.string()).max(100).optional().default([]),
56
+ // The domains the user is a member of — a name (a member) or
57
+ // `{ name, kind }` — required while the relay has two or more; with one,
58
+ // the user becomes its member.
59
+ domains: z.array(MembershipInputSchema).max(50).optional(),
38
60
  });
39
61
 
40
62
  const UsernameParamSchema = z.object({
@@ -50,10 +72,14 @@ const UpdateUserSchema = z
50
72
  displayname: z.string().min(1).max(100).optional(),
51
73
  email: z.string().email().optional(),
52
74
  password: z.string().min(8).max(128).optional(),
53
- groups: z.array(z.string()).optional(),
75
+ groups: z.array(z.string()).max(100).optional(),
54
76
  // Authelia's `disabled: true`: the user cannot sign in, every plugin
55
77
  // audience shows them disabled, and the user surface refuses them.
56
78
  disabled: z.boolean().optional(),
79
+ // The domains the user is a member of afterwards — exactly these (the
80
+ // difference applied in one transaction, each membership audited): a
81
+ // name (a member) or `{ name, kind }`.
82
+ domains: z.array(MembershipInputSchema).max(50).optional(),
57
83
  })
58
84
  .refine((data) => Object.keys(data).length > 0, {
59
85
  message: 'At least one field must be provided',
@@ -76,17 +102,28 @@ async function readUsersOrFail() {
76
102
  }
77
103
  }
78
104
 
79
- async function writeUsersOrFail(usersData) {
105
+ async function writeUsersOrFail(usersData, opts) {
80
106
  try {
81
- await writeUsers(usersData);
107
+ await writeUsers(usersData, opts);
82
108
  } catch (err) {
83
109
  throw httpError('Failed to update user database', 500, { cause: err });
84
110
  }
85
111
  }
86
112
 
87
- /** The user's groups as a sorted list (users.yml may hold none). */
88
- function groupsOf(entry) {
89
- return Array.isArray(entry?.groups) ? [...entry.groups].sort() : [];
113
+ /**
114
+ * The groups an administrator gave a user as a sorted list — the groups
115
+ * Lamaste owns left out (users.yml may hold none). `domains`: every domain
116
+ * record.
117
+ */
118
+ function groupsOf(entry, domains) {
119
+ return adminGroups(entry?.groups, domains).sort();
120
+ }
121
+
122
+ /** The groups Lamaste owns that a users.yml entry holds — kept by every edit. */
123
+ function ownedGroupsOf(entry, domains) {
124
+ return Array.isArray(entry?.groups)
125
+ ? entry.groups.filter((g) => isLamasteOwnedGroup(g, domains))
126
+ : [];
90
127
  }
91
128
 
92
129
  /** Whether two lists hold the same strings. */
@@ -101,22 +138,6 @@ function isEnabledAdmin(entry) {
101
138
  );
102
139
  }
103
140
 
104
- /**
105
- * Answer a refusal a users change threw (an error with `statusCode`: its
106
- * message and `code`), or a 500 for anything else — logged either way when
107
- * it is the panel's own failure.
108
- */
109
- function sendFailure(request, reply, err, fallback) {
110
- if (Number.isInteger(err.statusCode)) {
111
- if (err.statusCode >= 500) request.log.error(err.cause ?? err, err.message);
112
- return reply
113
- .code(err.statusCode)
114
- .send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
115
- }
116
- request.log.error(err, fallback);
117
- return reply.code(500).send({ error: fallback });
118
- }
119
-
120
141
  export default async function usersRoutes(fastify, _opts) {
121
142
  // GET /api/users — list all users (no sensitive fields): users.yml's, with
122
143
  // each one's directory id (null until the directory has seen them, within
@@ -130,13 +151,17 @@ export default async function usersRoutes(fastify, _opts) {
130
151
  try {
131
152
  const users = await readUsers();
132
153
  const directory = await directoryUsersByUsername();
154
+ const memberships = await domainsByUser();
155
+ const domains = await domainNames();
133
156
  const listed = users
134
157
  .map((user) => {
135
158
  const known = directory.get(user.username);
136
159
  return {
137
160
  ...user,
161
+ groups: groupsOf(user, domains),
138
162
  userId: known?.userId ?? null,
139
163
  status: known?.status ?? (user.disabled ? 'disabled' : 'active'),
164
+ domains: (known && memberships.get(known.userId)) ?? [],
140
165
  };
141
166
  })
142
167
  .sort((a, b) => a.username.localeCompare(b.username));
@@ -160,6 +185,17 @@ export default async function usersRoutes(fastify, _opts) {
160
185
  async (request, reply) => {
161
186
  const body = CreateUserSchema.parse(request.body);
162
187
 
188
+ let domains;
189
+ try {
190
+ assertAssignableGroups(body.groups);
191
+ domains = await domainsForNewUser(body.domains, { required: true });
192
+ if (body.groups.length > 0 && domains.some((m) => m.kind === 'external')) {
193
+ throw externalMemberGroupsError(400);
194
+ }
195
+ } catch (err) {
196
+ return sendFailure(request, reply, err, 'Failed to read the domains');
197
+ }
198
+
163
199
  try {
164
200
  // Refused before a password is hashed; checked again under the lock.
165
201
  if ((await readUsersOrFail()).users[body.username]) {
@@ -198,6 +234,7 @@ export default async function usersRoutes(fastify, _opts) {
198
234
  // A removed user's grants, groups and second factors under the
199
235
  // username go first; a username with an OIDC identity is refused.
200
236
  creates: [body.username],
237
+ domains: { [body.username]: domains },
201
238
  expect: (users) => users[body.username]?.password === hash,
202
239
  },
203
240
  ));
@@ -217,6 +254,7 @@ export default async function usersRoutes(fastify, _opts) {
217
254
  disabled: false,
218
255
  userId: created?.userId ?? null,
219
256
  status: 'active',
257
+ domains: created ? await domainsOfUser(created.userId) : [],
220
258
  },
221
259
  ...(warnings.length > 0 ? { warnings } : {}),
222
260
  });
@@ -248,68 +286,149 @@ export default async function usersRoutes(fastify, _opts) {
248
286
  }
249
287
  }
250
288
 
289
+ const domains = await domainNames();
290
+
251
291
  /** Whether users.yml's entry holds every field this request set. */
252
292
  const intended = (entry) =>
253
293
  entry !== undefined &&
254
294
  entry !== null &&
255
295
  (body.displayname === undefined || entry.displayname === body.displayname) &&
256
296
  (body.email === undefined || entry.email === body.email) &&
257
- (body.groups === undefined || sameList(groupsOf(entry), [...body.groups].sort())) &&
297
+ (body.groups === undefined ||
298
+ sameList(groupsOf(entry, domains), [...body.groups].sort())) &&
258
299
  (passwordHash === undefined || entry.password === passwordHash) &&
259
300
  (body.disabled === undefined || (entry.disabled === true) === body.disabled);
260
301
 
302
+ const { domains: memberOf, ...fields } = body;
303
+ const memberships = memberOf === undefined ? undefined : normalizeMemberships(memberOf);
304
+ const known = await findDirectoryUser(username);
305
+ const userId = known?.userId ?? null;
306
+ if (memberships !== undefined && !userId) {
307
+ return reply.code(409).send({
308
+ error: 'The directory has not seen this user yet — try again within a minute',
309
+ code: 'user_not_in_directory',
310
+ });
311
+ }
312
+ const current = userId ? await domainsOfUser(userId) : [];
313
+ const external = (memberships ?? current).some((m) => m.kind === 'external');
314
+ // Refused before anything is written: groups kept or given to a user
315
+ // who is (or becomes) an external member. Checked again under the
316
+ // users lock, by each step.
317
+ if (external) {
318
+ let groups = body.groups;
319
+ if (groups === undefined) {
320
+ try {
321
+ groups = await adminGroupsOf(username);
322
+ } catch (err) {
323
+ return sendFailure(request, reply, err, 'Failed to read user database');
324
+ }
325
+ }
326
+ if (groups.length > 0) {
327
+ return sendFailure(request, reply, externalMemberGroupsError(409), 'Refused');
328
+ }
329
+ }
330
+ // A user leaving every external membership with groups given in the
331
+ // same request: the memberships first, then the groups (each step is
332
+ // checked against the other under the users lock).
333
+ const membershipsFirst =
334
+ memberships !== undefined && !external && current.some((m) => m.kind === 'external');
335
+
336
+ const warnings = [];
337
+ const changeMemberships = async () => {
338
+ if (memberships === undefined) return;
339
+ const membership = await setUserDomains(userId, memberships, {
340
+ actor: actorFromRequest(request),
341
+ logger: request.log,
342
+ });
343
+ warnings.push(...(membership.warnings ?? []));
344
+ };
345
+ if (membershipsFirst) {
346
+ try {
347
+ await changeMemberships();
348
+ } catch (err) {
349
+ return sendFailure(request, reply, err, 'Failed to change the memberships');
350
+ }
351
+ }
352
+
261
353
  let outcome;
262
354
  let before = null;
263
355
  try {
264
- outcome = await changeUsers(
265
- async () => {
266
- const usersData = await readUsersOrFail();
267
- const user = usersData.users[username];
268
- if (!user) throw httpError('User not found', 404);
269
- // As it was before this request (a second attempt finds it changed already).
270
- before ??= { groups: groupsOf(user), disabled: user.disabled === true };
271
- if (body.disabled === true && isEnabledAdmin(user)) {
272
- const enabledAdmins = Object.values(usersData.users).filter(isEnabledAdmin).length;
273
- if (enabledAdmins <= 1) {
274
- throw httpError('Cannot disable the last enabled administrator', 400, {
275
- code: 'last_admin',
276
- });
356
+ // Only memberships change: users.yml is not written, the user is read.
357
+ if (Object.keys(fields).length === 0) {
358
+ const usersData = await readUsersOrFail();
359
+ const user = usersData.users[username];
360
+ if (!user) throw httpError('User not found', 404);
361
+ before = { groups: groupsOf(user, domains), disabled: user.disabled === true };
362
+ outcome = { result: user, warnings: [], persisted: true };
363
+ } else
364
+ outcome = await changeUsers(
365
+ async () => {
366
+ const usersData = await readUsersOrFail();
367
+ const user = usersData.users[username];
368
+ if (!user) throw httpError('User not found', 404);
369
+ // As it was before this request (a second attempt finds it changed already).
370
+ before ??= { groups: groupsOf(user, domains), disabled: user.disabled === true };
371
+ if (body.groups !== undefined) {
372
+ assertAssignableGroups(body.groups, before.groups);
373
+ // Checked under the users lock, which a change of kind takes too.
374
+ if (
375
+ body.groups.length > 0 &&
376
+ userId &&
377
+ (await domainsOfUser(userId)).some((m) => m.kind === 'external')
378
+ ) {
379
+ throw externalMemberGroupsError(409);
380
+ }
381
+ }
382
+ if (body.disabled === true && isEnabledAdmin(user)) {
383
+ const enabledAdmins = Object.values(usersData.users).filter(isEnabledAdmin).length;
384
+ if (enabledAdmins <= 1) {
385
+ throw httpError('Cannot disable the last enabled administrator', 400, {
386
+ code: 'last_admin',
387
+ });
388
+ }
277
389
  }
278
- }
279
-
280
- if (body.displayname !== undefined) user.displayname = body.displayname;
281
- if (body.email !== undefined) user.email = body.email;
282
- if (body.groups !== undefined) user.groups = body.groups;
283
- if (passwordHash !== undefined) user.password = passwordHash;
284
- if (body.disabled === true) user.disabled = true;
285
- if (body.disabled === false) delete user.disabled;
286
390
 
287
- await writeUsersOrFail(usersData);
288
- return user;
289
- },
290
- {
291
- actor: actorFromRequest(request),
292
- logger: request.log,
293
- // Authelia's own password reset may rewrite users.yml under the
294
- // change: it is checked after the restart, and made once more.
295
- expect: (users) => intended(users[username]),
296
- },
297
- );
391
+ if (body.displayname !== undefined) user.displayname = body.displayname;
392
+ if (body.email !== undefined) user.email = body.email;
393
+ if (body.groups !== undefined) {
394
+ user.groups = [...body.groups, ...ownedGroupsOf(user, domains)];
395
+ }
396
+ if (passwordHash !== undefined) user.password = passwordHash;
397
+ if (body.disabled === true) user.disabled = true;
398
+ if (body.disabled === false) delete user.disabled;
399
+
400
+ await writeUsersOrFail(usersData);
401
+ return user;
402
+ },
403
+ {
404
+ actor: actorFromRequest(request),
405
+ logger: request.log,
406
+ // Authelia's own password reset may rewrite users.yml under the
407
+ // change: it is checked after the rendering, and made once more.
408
+ expect: (users) => intended(users[username]),
409
+ },
410
+ );
298
411
  } catch (err) {
299
412
  return sendFailure(request, reply, err, 'Failed to update user database');
300
413
  }
301
414
 
302
415
  const user = outcome.result;
303
416
  const disabled = user.disabled === true;
304
- const warnings = [...outcome.warnings];
305
- const known = await findDirectoryUser(username);
306
- const userId = known?.userId ?? null;
417
+ warnings.push(...outcome.warnings);
418
+
419
+ if (!membershipsFirst) {
420
+ try {
421
+ await changeMemberships();
422
+ } catch (err) {
423
+ return sendFailure(request, reply, err, 'Failed to change the memberships');
424
+ }
425
+ }
307
426
 
308
427
  // Disabling is offboarding, which the change did as the directory saw
309
428
  // the user become disabled (`changeUsers`): sessions ended, registry
310
429
  // tokens revoked. Registry scopes are granted at login from feria-*
311
430
  // groups too: losing one must not leave a valid token behind either.
312
- const groupsAfter = groupsOf(user);
431
+ const groupsAfter = groupsOf(user, domains);
313
432
  const groupsChanged = body.groups !== undefined && !sameList(before.groups, groupsAfter);
314
433
  if (groupsChanged && !(disabled && !before.disabled)) {
315
434
  try {
@@ -348,10 +467,11 @@ export default async function usersRoutes(fastify, _opts) {
348
467
  username,
349
468
  displayname: user.displayname,
350
469
  email: user.email,
351
- groups: user.groups || [],
470
+ groups: groupsAfter,
352
471
  disabled,
353
472
  userId,
354
473
  status: disabled ? 'disabled' : 'active',
474
+ domains: userId ? await domainsOfUser(userId) : [],
355
475
  },
356
476
  ...(outcome.persisted ? {} : { persisted: false }),
357
477
  ...(warnings.length > 0 ? { warnings } : {}),
@@ -386,7 +506,7 @@ export default async function usersRoutes(fastify, _opts) {
386
506
  throw httpError('Cannot delete the last user', 400);
387
507
  }
388
508
  delete usersData.users[username];
389
- await writeUsersOrFail(usersData);
509
+ await writeUsersOrFail(usersData, { deleting: [username] });
390
510
  },
391
511
  {
392
512
  actor,
@@ -13,12 +13,14 @@ import ticketRoutes from './management/tickets.js';
13
13
  import identityRoutes from './management/identity.js';
14
14
  import identityDirectoryRoutes from './management/identity-directory.js';
15
15
  import pluginIdentityRoutes from './management/plugin-identity.js';
16
+ import pluginWebSurfaceRoutes from './management/plugin-web-surface.js';
16
17
  import storageRoutes from './management/storage.js';
17
18
  import gatekeeperProxyRoutes from './management/gatekeeper-proxy.js';
18
19
  import agentsRoutes from './management/agents.js';
19
20
  import registryCredentialRoutes from './management/registry-credential.js';
20
21
  import auditRoutes from './management/audit.js';
21
22
  import agentPanelAuthzRoutes from './management/agent-panel-authz.js';
23
+ import domainRoutes from './management/domains.js';
22
24
 
23
25
  export default async function managementRoutes(fastify, _opts) {
24
26
  fastify.addHook('onRequest', managementOnly());
@@ -37,10 +39,12 @@ export default async function managementRoutes(fastify, _opts) {
37
39
  await fastify.register(identityRoutes);
38
40
  await fastify.register(identityDirectoryRoutes);
39
41
  await fastify.register(pluginIdentityRoutes);
42
+ await fastify.register(pluginWebSurfaceRoutes);
40
43
  await fastify.register(storageRoutes);
41
44
  await fastify.register(gatekeeperProxyRoutes);
42
45
  await fastify.register(agentsRoutes);
43
46
  await fastify.register(registryCredentialRoutes);
44
47
  await fastify.register(auditRoutes);
45
48
  await fastify.register(agentPanelAuthzRoutes);
49
+ await fastify.register(domainRoutes);
46
50
  }
@@ -1,21 +1,7 @@
1
- import dns from 'node:dns/promises';
2
- import { CORE_SUBDOMAINS } from '@lamalibre/lamaste';
1
+ import { CORE_SUBDOMAINS, DNS_WILDCARD_PROBE_LABEL } from '@lamalibre/lamaste';
2
+ import { resolveHostA as resolveA } from '@lamalibre/lamaste/server';
3
3
  import { getConfig, updateConfig } from '../../lib/config.js';
4
4
 
5
- /**
6
- * Resolve A records for a hostname, returning an empty array on expected DNS errors.
7
- */
8
- async function resolveA(hostname) {
9
- try {
10
- return await dns.resolve4(hostname);
11
- } catch (err) {
12
- if (err.code === 'ENOTFOUND' || err.code === 'ENODATA' || err.code === 'ETIMEOUT') {
13
- return [];
14
- }
15
- throw err;
16
- }
17
- }
18
-
19
5
  /**
20
6
  * Build a human-readable diagnostic message for the DNS verification result.
21
7
  */
@@ -69,7 +55,7 @@ export default async function dnsRoute(fastify, _opts) {
69
55
  const [coreResolved, apexResolvedIps, wildcardResolvedIps] = await Promise.all([
70
56
  Promise.all(coreNames.map((name) => resolveA(name))),
71
57
  resolveA(domain),
72
- resolveA(`test-lamaste-check.${domain}`),
58
+ resolveA(`${DNS_WILDCARD_PROBE_LABEL}.${domain}`),
73
59
  ]);
74
60
 
75
61
  const records = coreNames.map((name, i) => {
@@ -12,6 +12,7 @@ import { migrateChiselCredentialsIfNeeded } from '../../lib/chisel-users.js';
12
12
  import { loadAgentRegistry } from '../../lib/mtls.js';
13
13
  import { setChiselRunningPinnedRelease } from '../../lib/chisel-runtime.js';
14
14
  import { changeUsers } from '../../lib/directory.js';
15
+ import { ensurePrimaryDomain, syncDomainAccess } from '../../lib/domains.js';
15
16
 
16
17
  // Module-level state for provisioning
17
18
  const emitter = new EventEmitter();
@@ -115,24 +116,16 @@ async function runProvisioning(log) {
115
116
  'Authelia is not installed. Run `npx @lamalibre/create-lamaste` on the server to repair the installation.',
116
117
  );
117
118
  }
118
- emitProgress('install-authelia', 'running', 'Writing configuration...');
119
-
120
- const secrets = {
121
- jwtSecret: crypto.randomBytes(32).toString('hex'),
122
- sessionSecret: crypto.randomBytes(32).toString('hex'),
123
- storageEncryptionKey: crypto.randomBytes(32).toString('hex'),
124
- };
125
- await authelia.writeAutheliaConfig(domain, secrets);
126
-
127
119
  emitProgress('install-authelia', 'running', 'Creating admin user...');
128
120
  adminPassword = crypto.randomBytes(16).toString('base64url');
129
121
  // Through the users lock, and into the user directory — which reconciles
130
- // with users.yml from here on. Authelia starts next, so no restart.
122
+ // with users.yml from here on. The domain is rendered below, once it
123
+ // exists.
131
124
  const created = await changeUsers(() => authelia.createUser('admin', adminPassword), {
132
125
  actor: 'lamaste',
133
126
  logger: log,
134
127
  creates: ['admin'],
135
- restartAuthelia: false,
128
+ renderDomains: false,
136
129
  // Authelia's storage does not exist before its first start.
137
130
  autheliaStorage: false,
138
131
  });
@@ -140,6 +133,21 @@ async function runProvisioning(log) {
140
133
  log.warn({ step: warning.step }, warning.message);
141
134
  }
142
135
 
136
+ // The primary domain with the administrator as its member — rendered
137
+ // into users.yml's groups and Gatekeeper's projection before Authelia
138
+ // first starts, so its first configuration already admits them (the
139
+ // configuration it is rendered from is the one written here: nothing to
140
+ // restart). The panel renders every later change.
141
+ emitProgress('install-authelia', 'running', 'Writing configuration...');
142
+ await ensurePrimaryDomain({ logger: log });
143
+ const secrets = {
144
+ jwtSecret: crypto.randomBytes(32).toString('hex'),
145
+ sessionSecret: crypto.randomBytes(32).toString('hex'),
146
+ storageEncryptionKey: crypto.randomBytes(32).toString('hex'),
147
+ };
148
+ await authelia.writeAutheliaConfig(domain, secrets);
149
+ await syncDomainAccess({ logger: log });
150
+
143
151
  emitProgress('install-authelia', 'running', 'Starting Authelia service...');
144
152
  await authelia.startAuthelia();
145
153
  emitProgress('install-authelia', 'done', 'Authelia configured and running');
@@ -156,7 +164,7 @@ async function runProvisioning(log) {
156
164
  await nginx.writePanelVhost(domain);
157
165
 
158
166
  emitProgress('configure-nginx', 'running', 'Writing auth vhost...');
159
- await nginx.writeAuthVhost(domain);
167
+ await nginx.writeAuthVhost(domain, { primary: true });
160
168
 
161
169
  emitProgress('configure-nginx', 'running', 'Writing tunnel vhost...');
162
170
  await nginx.writeTunnelVhost(domain);
@@ -166,7 +174,6 @@ async function runProvisioning(log) {
166
174
 
167
175
  emitProgress('configure-nginx', 'running', 'Enabling sites...');
168
176
  await nginx.enableSite('lamalibre-lamaste-panel-domain');
169
- await nginx.enableSite('lamalibre-lamaste-auth');
170
177
  await nginx.enableSite('lamalibre-lamaste-tunnel');
171
178
 
172
179
  emitProgress('configure-nginx', 'running', 'Testing nginx configuration...');
@@ -217,6 +224,8 @@ async function runProvisioning(log) {
217
224
  // Step 6: Finalize
218
225
  emitProgress('finalize', 'running', 'Updating configuration...');
219
226
  await updateConfig({ onboarding: { status: 'COMPLETED' } });
227
+ // Gatekeeper's projection of the domain (Authelia holds it already).
228
+ await syncDomainAccess({ logger: log });
220
229
 
221
230
  const result = {
222
231
  adminUsername: 'admin',