@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
@@ -5,18 +5,38 @@
5
5
  *
6
6
  * Users go through here alone: every write of users.yml is a locked
7
7
  * read-modify-write in `lib/directory.js` (`changeUsers`), which brings the
8
- * user directory in line before Authelia restarts. Tests point the users
9
- * file, the restart and the TOTP writer elsewhere
10
- * ({@link setAutheliaForTests}) — there is no Authelia to restart there.
8
+ * user directory in line and renders the domains afterwards. Authelia
9
+ * watches the file (`authentication_backend.file.watch`) and applies a
10
+ * change at each user's next request — nothing restarts it.
11
+ *
12
+ * users.yml also carries the groups Lamaste owns (core owned-groups.ts:
13
+ * domain memberships with their kind, static sites' listed users, host
14
+ * groups, OIDC client grants), which the domains' rendering writes
15
+ * ({@link writeOwnedGroups}). Every write here passes through one file lock
16
+ * and keeps them as that rendering last decided — a read-modify-write that
17
+ * read the file before a rendering never writes an older set back; a user
18
+ * the rendering did not know (created since, perhaps under a deleted
19
+ * user's username) gets none until the next one; after a rendering failed,
20
+ * writes only narrow. A write never drops a user it was not told to delete.
21
+ * After every write the file is touched once Authelia's reload cooldown has
22
+ * passed ({@link USERS_FILE_TOUCH_DELAY_MS}) — read again, the owned groups
23
+ * applied again, written: Authelia skips a change that lands within half a
24
+ * second of its previous reload, and this guarantees one reload after the
25
+ * last write, with the groups Lamaste decided even if something else wrote
26
+ * the file meanwhile. The identity tick and the start touch it too.
27
+ *
28
+ * Tests point the users file, the TOTP writer and the renderers elsewhere
29
+ * ({@link setAutheliaForTests}).
11
30
  */
12
31
 
13
32
  import { execa } from 'execa';
14
33
  import bcrypt from 'bcryptjs';
34
+ import { readFile } from 'node:fs/promises';
35
+ import { PromiseChainMutex, isLamasteOwnedGroup, mergeOwnedGroups } from '@lamalibre/lamaste';
15
36
  import {
16
37
  AUTHELIA_USERS,
17
38
  isAutheliaInstalled as isAutheliaInstalledCore,
18
39
  writeAutheliaConfig as writeAutheliaConfigCore,
19
- createAutheliaUser,
20
40
  readAutheliaUsers,
21
41
  writeAutheliaUsers,
22
42
  readAutheliaUsersRaw,
@@ -24,9 +44,9 @@ import {
24
44
  startAuthelia as startAutheliaCore,
25
45
  reloadAuthelia as reloadAutheliaCore,
26
46
  isAutheliaRunning as isAutheliaRunningCore,
27
- updateAutheliaAccessControl,
47
+ updateAutheliaDomainAccess,
48
+ autheliaWatchesUsersFile,
28
49
  updateAutheliaOidcProvider,
29
- createUserFromInvitation as createUserFromInvitationCore,
30
50
  base32Encode,
31
51
  base32Decode,
32
52
  generateTotpSecret,
@@ -35,6 +55,13 @@ import {
35
55
  autheliaHoldsOpaqueIdentifier,
36
56
  } from '@lamalibre/lamaste/server';
37
57
 
58
+ /**
59
+ * How long after a write of users.yml it is written again with the same
60
+ * content: past Authelia's half-second reload cooldown (its watcher skips a
61
+ * change within 0.5 s of its previous reload, it does not queue it).
62
+ */
63
+ export const USERS_FILE_TOUCH_DELAY_MS = 600;
64
+
38
65
  // bcryptjs hasher — signature matches the core lib's BcryptHashFn.
39
66
  const bcryptHash = (password, cost) => bcrypt.hash(password, cost);
40
67
 
@@ -50,24 +77,54 @@ const realUserStorage = Object.freeze({
50
77
 
51
78
  /** Authelia's users file — another one in tests. */
52
79
  let usersFile = AUTHELIA_USERS;
53
- /** Restart Authelia (it reads users.yml when it starts) — replaced in tests. */
80
+ /** Restart Authelia (it reads its configuration when it starts) — replaced in tests. */
54
81
  let restartAuthelia = restartRealAuthelia;
82
+ const realWatches = () => autheliaWatchesUsersFile();
83
+ /** Whether Authelia's configuration has it watch users.yml — replaced in tests (true). */
84
+ let watchesUsersFile = realWatches;
85
+ /** Whether users.yml changed since Authelia last (re)read it for certain — see {@link takeUsersFileDirty}. */
86
+ let usersFileDirty = false;
87
+ /** The reload touch's delay; null: none (tests unless they ask). */
88
+ let touchDelayMs = USERS_FILE_TOUCH_DELAY_MS;
55
89
  /** Store a TOTP secret in Authelia's database — replaced in tests. */
56
90
  let totpWriter = writeRealTotp;
57
91
  /** Authelia's per-user storage — replaced in tests. */
58
92
  let userStorage = realUserStorage;
93
+ const updateRealDomainAccess = (access) => updateAutheliaDomainAccess(access, execa);
94
+ /** Render the domains into Authelia's configuration — replaced in tests. */
95
+ let domainAccessWriter = updateRealDomainAccess;
96
+ const updateRealOidcProvider = (domains, clients, options) =>
97
+ updateAutheliaOidcProvider(domains, clients, execa, options);
98
+ /** Write Authelia's OIDC provider — replaced in tests. */
99
+ let oidcProviderWriter = updateRealOidcProvider;
59
100
 
60
101
  /**
61
- * Point the users file, the Authelia restart, the TOTP writer and the
62
- * per-user storage elsewhere (tests); `null` restores Authelia's.
102
+ * Point the users file, the Authelia restart, the TOTP writer, the
103
+ * per-user storage and the domain rendering elsewhere (tests); `null`
104
+ * restores Authelia's. The reload touch is off in tests unless
105
+ * `touchDelayMs` asks for it. The owned groups the last rendering decided
106
+ * are forgotten either way.
63
107
  *
64
- * @param {{ usersFile?: string, restart?: () => Promise<unknown>, writeTotp?: (username: string, secret: string) => Promise<void>, userStorage?: { purgeSecondFactors: (username: string) => Promise<void>, holdsOidcIdentifier: (username: string) => Promise<boolean> } } | null} overrides
108
+ * @param {{ usersFile?: string, restart?: () => Promise<unknown>, watchesUsersFile?: () => Promise<boolean>, writeTotp?: (username: string, secret: string) => Promise<void>, userStorage?: { purgeSecondFactors: (username: string) => Promise<void>, holdsOidcIdentifier: (username: string) => Promise<boolean> }, domainAccess?: (access: object) => Promise<{ changed: boolean }>, oidcProvider?: (domains: object[], clients: object[], options: object) => Promise<{ changed: boolean, clients: number }>, touchDelayMs?: number | null } | null} overrides
65
109
  */
66
110
  export function setAutheliaForTests(overrides) {
67
111
  usersFile = overrides?.usersFile ?? AUTHELIA_USERS;
68
112
  restartAuthelia = overrides?.restart ?? restartRealAuthelia;
113
+ watchesUsersFile = overrides?.watchesUsersFile ?? (overrides ? async () => true : realWatches);
114
+ usersFileDirty = false;
115
+ touchDelayMs =
116
+ overrides?.touchDelayMs !== undefined
117
+ ? overrides.touchDelayMs
118
+ : overrides
119
+ ? null
120
+ : USERS_FILE_TOUCH_DELAY_MS;
121
+ ownedGroups = null;
122
+ if (touchTimer) clearTimeout(touchTimer);
123
+ touchTimer = null;
69
124
  totpWriter = overrides?.writeTotp ?? writeRealTotp;
70
125
  userStorage = overrides?.userStorage ?? realUserStorage;
126
+ domainAccessWriter = overrides?.domainAccess ?? updateRealDomainAccess;
127
+ oidcProviderWriter = overrides?.oidcProvider ?? updateRealOidcProvider;
71
128
  }
72
129
 
73
130
  /**
@@ -95,21 +152,255 @@ export function isAutheliaInstalled() {
95
152
  return isAutheliaInstalledCore();
96
153
  }
97
154
 
98
- export function writeAutheliaConfig(domain, secrets) {
99
- return writeAutheliaConfigCore(domain, secrets);
155
+ /**
156
+ * Write Authelia's first configuration (onboarding): its rules name the
157
+ * primary domain's groups by the key of its record, which onboarding has
158
+ * made just before.
159
+ */
160
+ export async function writeAutheliaConfig(domain, secrets) {
161
+ const { domainGroupKey } = await import('./domains.js');
162
+ const groupKey = (await domainGroupKey(domain)) ?? undefined;
163
+ return writeAutheliaConfigCore(domain, secrets, groupKey ? { groupKey } : {});
100
164
  }
101
165
 
102
- /** Add the default administrator to users.yml (onboarding) — inside `changeUsers`. */
103
- export function createUser(username, password) {
104
- return createAutheliaUser(username, password, bcryptHash, usersFile);
166
+ // ---------------------------------------------------------------------------
167
+ // users.yml: one writer, the owned groups kept, a reload guaranteed
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /** One write of users.yml at a time (a rendering's and a change's alike). */
171
+ const usersFileMutex = new PromiseChainMutex();
172
+
173
+ /**
174
+ * The owned groups the domains' rendering last decided — `desired` by
175
+ * username, the domain records they were decided for (every domain record
176
+ * and every retired one), the usernames users.yml held then (`known`), and
177
+ * whether a rendering failed since (`narrowOnly`) — or null before the
178
+ * first rendering (a write then keeps the owned groups it was given).
179
+ *
180
+ * @type {{ desired: Map<string, readonly string[]>, domains: readonly string[], known: Set<string>, narrowOnly: boolean } | null}
181
+ */
182
+ let ownedGroups = null;
183
+
184
+ /** The pending reload touch. */
185
+ let touchTimer = null;
186
+
187
+ /** `usersData` with the owned groups the last rendering decided, when there was one. */
188
+ function withOwnedGroups(usersData) {
189
+ if (!ownedGroups) return usersData;
190
+ const merged = mergeOwnedGroups(usersData.users ?? {}, ownedGroups.desired, ownedGroups.domains, {
191
+ known: ownedGroups.known,
192
+ narrowOnly: ownedGroups.narrowOnly,
193
+ });
194
+ return merged.changed ? { ...usersData, users: merged.users } : usersData;
105
195
  }
106
196
 
107
- export function readUsers() {
108
- return readAutheliaUsers(usersFile);
197
+ /**
198
+ * users.yml for a write (holding the file lock): a missing file is empty;
199
+ * an empty file — read while another writer truncated it — is read again a
200
+ * few times before it is believed.
201
+ */
202
+ async function readCurrentLocked() {
203
+ for (let attempt = 0; ; attempt++) {
204
+ let content;
205
+ try {
206
+ content = await readFile(usersFile, 'utf-8');
207
+ } catch (err) {
208
+ if (err.code === 'ENOENT') return { users: {} };
209
+ throw err;
210
+ }
211
+ if (content.trim().length > 0 || attempt >= EMPTY_READ_RETRIES) {
212
+ return readAutheliaUsersRaw(usersFile);
213
+ }
214
+ await new Promise((resolve) => setTimeout(resolve, EMPTY_READ_RETRY_MS));
215
+ }
216
+ }
217
+
218
+ /** How often an empty users.yml is read again before a write believes it, and how far apart. */
219
+ const EMPTY_READ_RETRIES = 3;
220
+ const EMPTY_READ_RETRY_MS = 50;
221
+
222
+ /**
223
+ * Write users.yml (holding the file lock) and schedule the reload touch
224
+ * ({@link touchUsersFileLocked}) for {@link USERS_FILE_TOUCH_DELAY_MS}
225
+ * after the last write. A write that changes the file marks it dirty
226
+ * ({@link takeUsersFileDirty}).
227
+ */
228
+ async function writeUsersLocked(usersData, current) {
229
+ if (JSON.stringify(current) !== JSON.stringify(usersData)) usersFileDirty = true;
230
+ await writeAutheliaUsers(usersData, usersFile);
231
+ scheduleTouch();
232
+ }
233
+
234
+ function scheduleTouch() {
235
+ if (touchDelayMs === null) return;
236
+ if (touchTimer) clearTimeout(touchTimer);
237
+ touchTimer = setTimeout(() => {
238
+ touchTimer = null;
239
+ void usersFileMutex.run(touchUsersFileLocked).catch(() => {
240
+ // The file went or cannot be read (tests): the next write, the tick or
241
+ // the next start touches again.
242
+ });
243
+ }, touchDelayMs);
244
+ touchTimer.unref();
245
+ }
246
+
247
+ /**
248
+ * Read users.yml, apply the owned groups the last rendering decided, and
249
+ * write it — whether or not anything changed: a write Authelia's watcher
250
+ * skipped in its cooldown is reloaded now, and a change something else
251
+ * made meanwhile (a hand edit) gets Lamaste's groups back.
252
+ */
253
+ async function touchUsersFileLocked() {
254
+ const current = await readCurrentLocked();
255
+ const next = withOwnedGroups(current);
256
+ if (next !== current) usersFileDirty = true;
257
+ await writeAutheliaUsers(next, usersFile);
258
+ }
259
+
260
+ /**
261
+ * Touch users.yml now ({@link touchUsersFileLocked}) — at start and on the
262
+ * identity tick, so a reload a restart of the panel cut short (its touch
263
+ * pending) or skipped for any reason is never missing for long.
264
+ */
265
+ export function touchUsersFile() {
266
+ return usersFileMutex.run(touchUsersFileLocked);
267
+ }
268
+
269
+ /**
270
+ * Whether users.yml changed since this was last asked — and forget it.
271
+ * When Authelia is not known to watch the file (its configuration could not
272
+ * be brought in line), the domains' rendering restarts it after such a
273
+ * change, so it reads the file as 4.7 did.
274
+ */
275
+ export function takeUsersFileDirty() {
276
+ const dirty = usersFileDirty;
277
+ usersFileDirty = false;
278
+ return dirty;
109
279
  }
110
280
 
111
- export function writeUsers(usersData) {
112
- return writeAutheliaUsers(usersData, usersFile);
281
+ /** Whether Authelia's configuration has it watch users.yml (core `autheliaWatchesUsersFile`). */
282
+ export function autheliaWatchesUsers() {
283
+ return watchesUsersFile();
284
+ }
285
+
286
+ /**
287
+ * Write users.yml — every write of the file the panel makes. The owned
288
+ * groups are those the last rendering decided (see {@link withOwnedGroups}).
289
+ * Refused when it would drop a user `deleting` does not name — a write
290
+ * built from a read that missed users (a partial file) never removes them;
291
+ * a user it deletes is forgotten by the owned groups as well, so whoever
292
+ * takes the username next inherits none of them.
293
+ *
294
+ * @param {import('@lamalibre/lamaste/server').UsersYamlFile} usersData
295
+ * @param {{ deleting?: readonly string[] }} [opts]
296
+ */
297
+ export function writeUsers(usersData, { deleting = [] } = {}) {
298
+ return usersFileMutex.run(async () => {
299
+ const current = await readCurrentLocked();
300
+ const next = usersData.users ?? {};
301
+ const dropped = Object.keys(current.users ?? {}).filter((u) => !Object.hasOwn(next, u));
302
+ if (dropped.some((u) => !deleting.includes(u))) {
303
+ throw new Error(
304
+ 'users.yml was not written: it would remove users this change does not delete (read the file again and retry)',
305
+ );
306
+ }
307
+ if (ownedGroups) {
308
+ for (const u of dropped) {
309
+ ownedGroups.desired.delete(u);
310
+ ownedGroups.known.delete(u);
311
+ }
312
+ }
313
+ await writeUsersLocked(withOwnedGroups(usersData), current);
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Give every user exactly the owned groups of `desired` (core
319
+ * `renderOwnedGroups`) — the domains' rendering; `domains` are every
320
+ * domain record and every retired one. Compares first: an unchanged file
321
+ * is not written. From now on every write keeps these groups (and adds
322
+ * them only to the users the file holds now). Returns whether the file
323
+ * changed.
324
+ *
325
+ * @param {ReadonlyMap<string, readonly string[]>} desired
326
+ * @param {readonly string[]} domains
327
+ * @returns {Promise<{ changed: boolean }>}
328
+ */
329
+ export function writeOwnedGroups(desired, domains) {
330
+ return usersFileMutex.run(async () => {
331
+ const current = await readCurrentLocked();
332
+ ownedGroups = {
333
+ desired: new Map(desired),
334
+ domains,
335
+ known: new Set(Object.keys(current.users ?? {})),
336
+ narrowOnly: false,
337
+ };
338
+ const merged = mergeOwnedGroups(current.users ?? {}, desired, domains);
339
+ if (!merged.changed) return { changed: false };
340
+ await writeUsersLocked({ ...current, users: merged.users }, current);
341
+ return { changed: true };
342
+ });
343
+ }
344
+
345
+ /**
346
+ * After a rendering failed to compute or write the owned groups: until the
347
+ * next one succeeds, writes only narrow (keep the owned groups a user holds
348
+ * that the last rendering decided too, add none) — what it decided may be
349
+ * older than the relay's state.
350
+ */
351
+ export function narrowOwnedGroupsOnly() {
352
+ if (ownedGroups) ownedGroups.narrowOnly = true;
353
+ }
354
+
355
+ /**
356
+ * The owned groups each user of users.yml holds now, and whether each is
357
+ * enabled — what the domains' rendering weighs a narrowing against.
358
+ *
359
+ * @param {readonly string[]} domains every domain record and every retired one
360
+ * @returns {Promise<Map<string, { groups: string[], active: boolean }>>}
361
+ */
362
+ export async function heldOwnedGroups(domains) {
363
+ const raw = await readAutheliaUsersRaw(usersFile);
364
+ const out = new Map();
365
+ for (const [username, entry] of Object.entries(raw.users ?? {})) {
366
+ const groups = Array.isArray(entry?.groups)
367
+ ? entry.groups.filter((g) => typeof g === 'string' && isLamasteOwnedGroup(g, domains))
368
+ : [];
369
+ out.set(username, { groups, active: entry?.disabled !== true });
370
+ }
371
+ return out;
372
+ }
373
+
374
+ /**
375
+ * Add the default administrator (group `admins`) to users.yml at
376
+ * onboarding — inside `changeUsers`.
377
+ */
378
+ export async function createUser(username, password) {
379
+ const hash = await hashPassword(password);
380
+ if (!hash || !hash.startsWith('$2')) throw new Error('Bcrypt hashing produced invalid output');
381
+ const usersData = await readUsersForWrite();
382
+ usersData.users[username] = {
383
+ displayname: username,
384
+ password: hash,
385
+ email: `${username}@lamaste.local`,
386
+ groups: ['admins'],
387
+ };
388
+ await writeUsers(usersData);
389
+ return { username, created: true };
390
+ }
391
+
392
+ /** users.yml for a read-modify-write: a missing file starts empty, an unreadable one is refused. */
393
+ async function readUsersForWrite() {
394
+ try {
395
+ return await readAutheliaUsersRaw(usersFile);
396
+ } catch (err) {
397
+ if (err.code === 'ENOENT') return { users: {} };
398
+ throw err;
399
+ }
400
+ }
401
+
402
+ export function readUsers() {
403
+ return readAutheliaUsers(usersFile);
113
404
  }
114
405
 
115
406
  /** users.yml as written, password hashes included; throws when it cannot be read or parsed. */
@@ -125,38 +416,53 @@ export function startAuthelia() {
125
416
  return startAutheliaCore(execa);
126
417
  }
127
418
 
128
- /** Restart Authelia: it reads users.yml when it starts, and every session ends. */
129
- export function reloadAuthelia() {
130
- return restartAuthelia();
419
+ /**
420
+ * Restart Authelia — every session ends. A change of users.yml needs none
421
+ * (Authelia watches it); only configuration rewrites restart it, in core.
422
+ */
423
+ export async function reloadAuthelia() {
424
+ const restarted = await restartAuthelia();
425
+ usersFileDirty = false;
426
+ return restarted;
131
427
  }
132
428
 
133
429
  export function isAutheliaRunning() {
134
430
  return isAutheliaRunningCore(execa);
135
431
  }
136
432
 
137
- export function updateAccessControl(sites) {
138
- return updateAutheliaAccessControl(sites, execa);
433
+ /**
434
+ * Bring Authelia's session cookies and access control in line with the
435
+ * relay's domains and protected sites (core `updateDomainAccess`) —
436
+ * lib/domains.js `syncDomainAccess` alone calls it.
437
+ *
438
+ * @param {import('@lamalibre/lamaste/server').DomainAccess} access
439
+ * @returns {Promise<{ changed: boolean }>}
440
+ */
441
+ export function updateDomainAccess(access) {
442
+ return domainAccessWriter(access);
139
443
  }
140
444
 
141
445
  /**
142
- * Make Authelia's OIDC provider serve exactly these clients.
143
- * @param {string} domain
446
+ * Make Authelia's OIDC provider serve exactly these clients, each of one of
447
+ * `domains` (the active domains, each with its group key).
448
+ * @param {import('@lamalibre/lamaste/server').AutheliaDomain[]} domains
144
449
  * @param {import('@lamalibre/lamaste/server').AutheliaOidcClient[]} clients
145
450
  * @param {import('@lamalibre/lamaste/server').OidcProviderOptions} [options]
146
451
  */
147
- export function updateOidcProvider(domain, clients, options = {}) {
148
- return updateAutheliaOidcProvider(domain, clients, execa, options);
452
+ export function updateOidcProvider(domains, clients, options = {}) {
453
+ return oidcProviderWriter(domains, clients, options);
149
454
  }
150
455
 
151
456
  /**
152
- * Add an invited user to users.yml — inside `changeUsers`, which restarts
153
- * Authelia once the directory knows the user.
457
+ * Add an invited user to users.yml — inside `changeUsers`, which brings the
458
+ * directory and the domains' rendering in line with it.
154
459
  */
155
- export function createUserFromInvitation(username, email, groups, hashedPassword) {
156
- return createUserFromInvitationCore(username, email, groups, hashedPassword, execa, {
157
- restart: false,
158
- usersFile,
159
- });
460
+ export async function createUserFromInvitation(username, email, groups, hashedPassword) {
461
+ const usersData = await readUsersForWrite();
462
+ if (usersData.users[username]) throw new Error(`User '${username}' already exists`);
463
+ usersData.users[username] = { displayname: username, email, password: hashedPassword, groups };
464
+ await writeUsers(usersData);
465
+ return { username, created: true };
160
466
  }
161
467
 
162
468
  export function writeTotpToDatabase(username, base32Secret) {
@@ -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
  }