@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
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * `auth.kind` is `token` (a pasted long-lived Feria token) or `oidc` (the
11
11
  * panel's own `lamaste-server` client on Authelia: client-credentials grant,
12
- * Feria login). For `oidc` the client is written to Authelia before the
12
+ * Feria login; `issuerDomain` names the active domain whose issuer,
13
+ * `https://auth.<domain>`, the registry trusts — the primary when absent). For `oidc` the client is written to Authelia before the
13
14
  * chain is verified; if verification fails it is removed again, and the
14
15
  * error says what Feria's `auth.json` needs.
15
16
  *
@@ -28,9 +29,12 @@ import {
28
29
  clearRegistryCredentialConfig,
29
30
  readRegistryCredentialConfig,
30
31
  verifyRegistryCredential,
31
- writeRegistryCredentialConfig,
32
+ setRegistryCredential,
32
33
  } from '../../lib/registry-credential.js';
33
- import { syncOidcClients } from '../../lib/plugin-oidc.js';
34
+ import { DomainNameSchema } from '@lamalibre/lamaste';
35
+ import { sendFailure } from '../../lib/app-error.js';
36
+ import { resolveActiveDomain } from '../../lib/domains.js';
37
+ import { syncOidcClients, withRegistryCandidate } from '../../lib/plugin-oidc.js';
34
38
 
35
39
  const FeriaTokenSchema = z
36
40
  .string()
@@ -40,7 +44,7 @@ const FeriaTokenSchema = z
40
44
 
41
45
  const AuthSchema = z.discriminatedUnion('kind', [
42
46
  z.object({ kind: z.literal('token'), token: FeriaTokenSchema }),
43
- z.object({ kind: z.literal('oidc') }),
47
+ z.object({ kind: z.literal('oidc'), issuerDomain: DomainNameSchema.optional() }),
44
48
  ]);
45
49
 
46
50
  const PutSchema = z
@@ -64,9 +68,13 @@ const PutSchema = z
64
68
  }
65
69
  });
66
70
 
67
- /** The stored `auth` for a request body. */
68
- function authOf(body) {
69
- if (body.auth?.kind === 'oidc') return { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID };
71
+ /** The stored `auth` for a request body (`issuerDomain` resolved against the active domains). */
72
+ async function authOf(body) {
73
+ if (body.auth?.kind === 'oidc') {
74
+ const auth = { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID };
75
+ if (body.auth.issuerDomain === undefined) return auth;
76
+ return { ...auth, issuerDomain: await resolveActiveDomain(body.auth.issuerDomain) };
77
+ }
70
78
  if (body.auth?.kind === 'token') return { kind: 'token', token: body.auth.token };
71
79
  return { kind: 'token', token: body.token };
72
80
  }
@@ -90,62 +98,83 @@ export default async function registryCredentialRoutes(fastify) {
90
98
  },
91
99
  async (request, reply) => {
92
100
  const body = PutSchema.parse(request.body);
93
- const auth = authOf(body);
101
+ let auth;
102
+ try {
103
+ auth = await authOf(body);
104
+ } catch (err) {
105
+ return sendFailure(request, reply, err, 'Failed to read the domains');
106
+ }
94
107
  const candidate = { registryUrl: body.registryUrl, auth };
95
108
 
96
- // The panel's own client must exist in Authelia (with a secret in the
97
- // store) before the chain can be exercised.
98
- if (auth.kind === 'oidc') {
99
- try {
100
- const synced = await syncOidcClients(request.log, {
101
- registryAuth: { registryUrl: body.registryUrl },
102
- });
103
- if (!synced) {
104
- return reply.code(503).send({ error: 'Complete onboarding (set a domain) first' });
109
+ // An oidc candidate is what every sync serves until it is stored or
110
+ // dropped — the identity tick's too, which would otherwise remove the
111
+ // panel's client from Authelia under the verification.
112
+ const registryAuth =
113
+ auth.kind === 'oidc'
114
+ ? {
115
+ registryUrl: body.registryUrl,
116
+ ...(auth.issuerDomain ? { issuerDomain: auth.issuerDomain } : {}),
117
+ }
118
+ : undefined;
119
+ try {
120
+ return await withRegistryCandidate(registryAuth, async () => {
121
+ // The panel's own client must exist in Authelia (with a secret in
122
+ // the store) before the chain can be exercised.
123
+ if (registryAuth) {
124
+ try {
125
+ if (!(await syncOidcClients(request.log))) {
126
+ return reply.code(503).send({ error: 'Complete onboarding (set a domain) first' });
127
+ }
128
+ } catch (err) {
129
+ return reply.code(500).send({
130
+ error: `Could not register the ${LAMASTE_SERVER_OIDC_CLIENT_ID} client in Authelia: ${err.message}`,
131
+ });
132
+ }
105
133
  }
106
- } catch (err) {
107
- return reply.code(500).send({
108
- error: `Could not register the ${LAMASTE_SERVER_OIDC_CLIENT_ID} client in Authelia: ${err.message}`,
109
- });
110
- }
111
- }
112
134
 
113
- let verified;
114
- try {
115
- verified = await verifyRegistryCredential(candidate);
116
- } catch (err) {
117
- if (auth.kind === 'oidc') {
118
- // Leave Authelia as the stored credential says — the candidate is
119
- // not kept.
120
- await syncOidcClients(request.log).catch((syncErr) =>
121
- request.log.warn({ err: syncErr.message }, 'Failed to roll back the OIDC client'),
135
+ let verified;
136
+ try {
137
+ verified = await verifyRegistryCredential(candidate);
138
+ } catch (err) {
139
+ const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
140
+ return reply.code(status).send({ error: err.message });
141
+ }
142
+
143
+ const config = {
144
+ provider: 'feria',
145
+ registryUrl: body.registryUrl,
146
+ scope: body.scope ?? '@lamalibre',
147
+ auth,
148
+ owner: verified.owner,
149
+ configuredAt: new Date().toISOString(),
150
+ };
151
+ let warnings;
152
+ try {
153
+ warnings = await setRegistryCredential(config, { logger: request.log });
154
+ } catch (err) {
155
+ return sendFailure(request, reply, err, 'Failed to store the registry credential');
156
+ }
157
+ request.log.info(
158
+ { registryUrl: body.registryUrl, owner: verified.owner, auth: auth.kind },
159
+ 'Registry credential configured',
122
160
  );
123
- }
124
- const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
125
- return reply.code(status).send({ error: err.message });
161
+ return {
162
+ configured: true,
163
+ ...summariseRegistryCredential(config),
164
+ ...(warnings.length > 0 ? { warnings } : {}),
165
+ };
166
+ });
167
+ } finally {
168
+ // With the candidate dropped, the stored credential decides whether
169
+ // Authelia carries the panel's client: the new one once stored, the
170
+ // previous one after a refusal (the candidate is not kept).
171
+ await syncOidcClients(request.log).catch((err) =>
172
+ request.log.warn(
173
+ { err: err.message },
174
+ 'Failed to sync OIDC clients after the registry credential request',
175
+ ),
176
+ );
126
177
  }
127
-
128
- const config = {
129
- provider: 'feria',
130
- registryUrl: body.registryUrl,
131
- scope: body.scope ?? '@lamalibre',
132
- auth,
133
- owner: verified.owner,
134
- configuredAt: new Date().toISOString(),
135
- };
136
- await writeRegistryCredentialConfig(config);
137
- // The stored credential now decides whether Authelia carries the client.
138
- await syncOidcClients(request.log).catch((err) =>
139
- request.log.warn(
140
- { err: err.message },
141
- 'Failed to sync OIDC clients after credential change',
142
- ),
143
- );
144
- request.log.info(
145
- { registryUrl: body.registryUrl, owner: verified.owner, auth: auth.kind },
146
- 'Registry credential configured',
147
- );
148
- return { configured: true, ...summariseRegistryCredential(config) };
149
178
  },
150
179
  );
151
180
 
@@ -11,11 +11,20 @@ import {
11
11
  updateSite,
12
12
  verifyDns,
13
13
  SiteError,
14
+ withTunnelLock,
14
15
  } from '@lamalibre/lamaste/server';
16
+ import { DomainNameSchema } from '@lamalibre/lamaste';
17
+ import { AppError } from '../../lib/app-error.js';
18
+ import { webSurfaceServing } from '../../lib/web-surface.js';
15
19
  import { getConfig } from '../../lib/config.js';
16
- import { readSites, writeSites, readTunnels } from '../../lib/state.js';
20
+ import { readSites, writeSites, readTunnels, withSitesLock } from '../../lib/state.js';
17
21
  import { writeStaticSiteVhost, removeStaticSiteVhost } from '../../lib/nginx.js';
18
- import { updateAccessControl } from '../../lib/authelia.js';
22
+ import {
23
+ activeDomainNames,
24
+ domainNames,
25
+ resolveActiveDomain,
26
+ syncDomainAccess,
27
+ } from '../../lib/domains.js';
19
28
  import { issueTunnelCert, getCertPath, issueSiteCert } from '../../lib/certbot.js';
20
29
  import {
21
30
  createSiteDirectory,
@@ -54,6 +63,8 @@ const CreateSiteSchema = z.object({
54
63
  aliases: z.array(AliasSchema).max(10, 'At most 10 aliases').optional(),
55
64
  spaMode: z.boolean().optional().default(false),
56
65
  autheliaProtected: z.boolean().optional().default(false),
66
+ /** A managed site's domain (an active one; the primary when absent). */
67
+ domain: DomainNameSchema.optional(),
57
68
  });
58
69
 
59
70
  const UpdateSiteSchema = z.object({
@@ -104,8 +115,31 @@ function buildTunnelReadDeps() {
104
115
  return { readTunnels };
105
116
  }
106
117
 
107
- function buildAutheliaDeps() {
108
- return { updateAccessControl };
118
+ /**
119
+ * Authelia renders every site's protection together with the domains'
120
+ * members (lib/domains.js) — given the sites being saved.
121
+ */
122
+ function buildAutheliaDeps(logger) {
123
+ return {
124
+ renderAccess: async (sites) => {
125
+ await syncDomainAccess({ logger, sites });
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The active domain a managed site is put on (the primary unless named), as
132
+ * a SiteError the create route answers like its other refusals.
133
+ *
134
+ * @param {string | undefined} requested
135
+ */
136
+ async function siteDomain(requested) {
137
+ try {
138
+ return await resolveActiveDomain(requested);
139
+ } catch (err) {
140
+ if (Number.isInteger(err.statusCode)) throw new SiteError(err.message, 'DOMAIN_NOT_CONFIGURED');
141
+ throw err;
142
+ }
109
143
  }
110
144
 
111
145
  /**
@@ -116,7 +150,6 @@ function siteErrorStatus(code) {
116
150
  case 'CUSTOM_DOMAIN_REQUIRED':
117
151
  case 'NAME_IN_USE':
118
152
  case 'RESERVED_NAME':
119
- case 'NAME_TUNNEL_COLLISION':
120
153
  case 'FQDN_IN_USE':
121
154
  case 'FQDN_TUNNEL_COLLISION':
122
155
  case 'NOT_CUSTOM':
@@ -124,6 +157,7 @@ function siteErrorStatus(code) {
124
157
  case 'INVALID_ALIAS':
125
158
  case 'ALIAS_IN_USE':
126
159
  case 'DNS_MISMATCH':
160
+ case 'NOT_UNDER_DOMAIN':
127
161
  return 400;
128
162
  case 'NOT_FOUND':
129
163
  return 404;
@@ -134,6 +168,79 @@ function siteErrorStatus(code) {
134
168
  }
135
169
  }
136
170
 
171
+ /**
172
+ * Run a site workflow that may give a site a hostname: under core's tunnel
173
+ * lock, then the sites lock — the order every such workflow takes them in
174
+ * (`withSitesLock`) — so no tunnel takes the name meanwhile.
175
+ *
176
+ * @template T
177
+ * @param {() => Promise<T>} fn
178
+ * @returns {Promise<T>}
179
+ */
180
+ function withHostnameLocks(fn) {
181
+ return withTunnelLock(() => withSitesLock(fn));
182
+ }
183
+
184
+ /**
185
+ * Refuse (409) a site a plugin owns: its files are the plugin's approved
186
+ * web tree and its settings follow the plugin — it is managed through the
187
+ * plugin's web surface, never here (and never by `lamaste-agent deploy`).
188
+ */
189
+ function assertNotPluginOwned(site) {
190
+ if (site?.owner) {
191
+ throw new AppError(
192
+ `Site '${site.name}' is the web surface of plugin '${site.owner.plugin}' — manage it through the plugin`,
193
+ 409,
194
+ { code: 'plugin_owned_site' },
195
+ );
196
+ }
197
+ }
198
+
199
+ /** The site `id`, refused when a plugin owns it; null when there is none. */
200
+ async function siteForManagement(id) {
201
+ const site = (await readSites()).find((s) => s.id === id) ?? null;
202
+ assertNotPluginOwned(site);
203
+ return site;
204
+ }
205
+
206
+ /**
207
+ * Record the size of site `id` on disk in sites.json, re-reading the file
208
+ * under the sites lock — never a copy read before an upload, which would
209
+ * undo every change made meanwhile.
210
+ *
211
+ * @returns {Promise<number>} the size
212
+ */
213
+ async function recordSiteSize(id) {
214
+ const totalSize = await getSiteSize(id);
215
+ await withSitesLock(async () => {
216
+ const sites = await readSites();
217
+ const site = sites.find((s) => s.id === id);
218
+ if (!site) return;
219
+ site.totalSize = totalSize;
220
+ await writeSites(sites);
221
+ });
222
+ return totalSize;
223
+ }
224
+
225
+ /**
226
+ * Sites as listed: a plugin-owned one with `webSurface: { served, reason }`
227
+ * — whether it serves its plugin's shell and API now, or answers 503 and why.
228
+ */
229
+ async function withWebSurfaceState(sites) {
230
+ if (!sites.some((site) => site.owner)) return sites;
231
+ const tunnels = await readTunnels();
232
+ return Promise.all(
233
+ sites.map(async (site) => {
234
+ if (!site.owner) return site;
235
+ const serving = await webSurfaceServing(site.owner.plugin, tunnels);
236
+ return {
237
+ ...site,
238
+ webSurface: { served: serving.served, reason: serving.served ? null : serving.reason },
239
+ };
240
+ }),
241
+ );
242
+ }
243
+
137
244
  function assertAgentSiteAccess(request, site) {
138
245
  if (request.certRole === 'agent') {
139
246
  const allowed = request.certAllowedSites || [];
@@ -151,7 +258,6 @@ export default async function sitesRoutes(fastify, _opts) {
151
258
  const filesDeps = buildFilesDeps();
152
259
  const siteStateDeps = buildSiteStateDeps();
153
260
  const tunnelReadDeps = buildTunnelReadDeps();
154
- const autheliaDeps = buildAutheliaDeps();
155
261
 
156
262
  // GET /api/sites
157
263
  //
@@ -191,7 +297,7 @@ export default async function sitesRoutes(fastify, _opts) {
191
297
  }
192
298
 
193
299
  const total = sites.length;
194
- const windowed = sites.slice(offset, offset + limit);
300
+ const windowed = await withWebSurfaceState(sites.slice(offset, offset + limit));
195
301
 
196
302
  return { sites: windowed, total, limit, offset };
197
303
  },
@@ -212,24 +318,34 @@ export default async function sitesRoutes(fastify, _opts) {
212
318
  error: 'Domain and email must be configured before creating sites',
213
319
  });
214
320
  }
215
-
216
- try {
217
- const result = await createSite({
218
- name: body.name,
219
- type: body.type,
220
- customDomain: body.customDomain,
221
- aliases: body.aliases,
222
- spaMode: body.spaMode,
223
- autheliaProtected: body.autheliaProtected,
224
- domain: config.domain,
225
- email: config.email,
226
- nginx: nginxDeps,
227
- certbot: certbotDeps,
228
- files: filesDeps,
229
- siteState: siteStateDeps,
230
- tunnelState: tunnelReadDeps,
231
- logger: request.log,
321
+ if (body.domain !== undefined && body.type !== 'managed') {
322
+ return reply.code(400).send({
323
+ error: "A custom site's hostname names its domain — `domain` is for managed sites",
232
324
  });
325
+ }
326
+ try {
327
+ // The domain is resolved under the hostname locks — those a domain's
328
+ // removal takes — so the site never lands on a removed domain.
329
+ const result = await withHostnameLocks(async () =>
330
+ createSite({
331
+ name: body.name,
332
+ type: body.type,
333
+ customDomain: body.customDomain,
334
+ aliases: body.aliases,
335
+ spaMode: body.spaMode,
336
+ autheliaProtected: body.autheliaProtected,
337
+ domain: await siteDomain(body.domain),
338
+ domains: await activeDomainNames(),
339
+ relayDomains: await domainNames(),
340
+ email: config.email,
341
+ nginx: nginxDeps,
342
+ certbot: certbotDeps,
343
+ files: filesDeps,
344
+ siteState: siteStateDeps,
345
+ tunnelState: tunnelReadDeps,
346
+ logger: request.log,
347
+ }),
348
+ );
233
349
 
234
350
  const response = { ok: true, site: result.site };
235
351
  if (result.message) response.message = result.message;
@@ -261,12 +377,15 @@ export default async function sitesRoutes(fastify, _opts) {
261
377
  const { id } = IdParamSchema.parse(request.params);
262
378
 
263
379
  try {
264
- await deleteSite({
265
- id,
266
- nginx: nginxDeps,
267
- files: filesDeps,
268
- siteState: siteStateDeps,
269
- logger: request.log,
380
+ await withSitesLock(async () => {
381
+ await siteForManagement(id);
382
+ await deleteSite({
383
+ id,
384
+ nginx: nginxDeps,
385
+ files: filesDeps,
386
+ siteState: siteStateDeps,
387
+ logger: request.log,
388
+ });
270
389
  });
271
390
  return { ok: true };
272
391
  } catch (err) {
@@ -277,6 +396,7 @@ export default async function sitesRoutes(fastify, _opts) {
277
396
  details: err.message,
278
397
  });
279
398
  }
399
+ if (err instanceof AppError) throw err;
280
400
  request.log.error(err, 'Failed to delete site');
281
401
  return reply.code(500).send({
282
402
  error: 'Failed to delete site',
@@ -298,21 +418,25 @@ export default async function sitesRoutes(fastify, _opts) {
298
418
  const config = getConfig();
299
419
 
300
420
  try {
301
- const result = await updateSite({
302
- id,
303
- spaMode: body.spaMode,
304
- autheliaProtected: body.autheliaProtected,
305
- allowedUsers: body.allowedUsers,
306
- aliases: body.aliases,
307
- domain: config.domain,
308
- email: config.email,
309
- serverIp: config.ip,
310
- nginx: nginxDeps,
311
- certbot: certbotDeps,
312
- siteState: siteStateDeps,
313
- tunnelState: tunnelReadDeps,
314
- authelia: autheliaDeps,
315
- logger: request.log,
421
+ const result = await withHostnameLocks(async () => {
422
+ await siteForManagement(id);
423
+ return updateSite({
424
+ id,
425
+ spaMode: body.spaMode,
426
+ autheliaProtected: body.autheliaProtected,
427
+ allowedUsers: body.allowedUsers,
428
+ aliases: body.aliases,
429
+ domains: await activeDomainNames(),
430
+ relayDomains: await domainNames(),
431
+ email: config.email,
432
+ serverIp: config.ip,
433
+ nginx: nginxDeps,
434
+ certbot: certbotDeps,
435
+ siteState: siteStateDeps,
436
+ tunnelState: tunnelReadDeps,
437
+ authelia: buildAutheliaDeps(request.log),
438
+ logger: request.log,
439
+ });
316
440
  });
317
441
  return result;
318
442
  } catch (err) {
@@ -330,6 +454,7 @@ export default async function sitesRoutes(fastify, _opts) {
330
454
  details: err.message,
331
455
  });
332
456
  }
457
+ if (err instanceof AppError) throw err;
333
458
  request.log.error(err, 'Failed to update site');
334
459
  return reply.code(500).send({
335
460
  error: 'Failed to update site configuration',
@@ -353,15 +478,18 @@ export default async function sitesRoutes(fastify, _opts) {
353
478
  const config = getConfig();
354
479
 
355
480
  try {
356
- const result = await verifyDns({
357
- id,
358
- serverIp: config.ip,
359
- domain: config.domain,
360
- email: config.email,
361
- nginx: nginxDeps,
362
- certbot: certbotDeps,
363
- siteState: siteStateDeps,
364
- logger: request.log,
481
+ const result = await withHostnameLocks(async () => {
482
+ await siteForManagement(id);
483
+ return verifyDns({
484
+ id,
485
+ serverIp: config.ip,
486
+ domains: await activeDomainNames(),
487
+ email: config.email,
488
+ nginx: nginxDeps,
489
+ certbot: certbotDeps,
490
+ siteState: siteStateDeps,
491
+ logger: request.log,
492
+ });
365
493
  });
366
494
  return result;
367
495
  } catch (err) {
@@ -371,6 +499,7 @@ export default async function sitesRoutes(fastify, _opts) {
371
499
  error: err.message,
372
500
  });
373
501
  }
502
+ if (err instanceof AppError) throw err;
374
503
  request.log.error(err, 'Failed to verify DNS');
375
504
  return reply.code(500).send({
376
505
  error: 'DNS verification failed',
@@ -390,8 +519,7 @@ export default async function sitesRoutes(fastify, _opts) {
390
519
  const { id } = IdParamSchema.parse(request.params);
391
520
  const { path: relativePath } = PathQuerySchema.parse(request.query);
392
521
 
393
- const sites = await readSites();
394
- const site = sites.find((s) => s.id === id);
522
+ const site = await siteForManagement(id);
395
523
  if (!site) {
396
524
  return reply.code(404).send({ error: 'Site not found' });
397
525
  }
@@ -417,8 +545,7 @@ export default async function sitesRoutes(fastify, _opts) {
417
545
  const { id } = IdParamSchema.parse(request.params);
418
546
  const config = getConfig();
419
547
 
420
- const sites = await readSites();
421
- const site = sites.find((s) => s.id === id);
548
+ const site = await siteForManagement(id);
422
549
  if (!site) {
423
550
  return reply.code(404).send({ error: 'Site not found' });
424
551
  }
@@ -450,13 +577,9 @@ export default async function sitesRoutes(fastify, _opts) {
450
577
 
451
578
  // Update site size
452
579
  try {
453
- const totalSize = await getSiteSize(id);
454
- const siteIndex = sites.findIndex((s) => s.id === id);
455
- sites[siteIndex].totalSize = totalSize;
456
-
580
+ const totalSize = await recordSiteSize(id);
457
581
  if (totalSize > config.maxSiteSize) {
458
582
  // Don't block but warn
459
- await writeSites(sites);
460
583
  return reply.code(200).send({
461
584
  ok: true,
462
585
  files: uploadedFiles,
@@ -464,8 +587,6 @@ export default async function sitesRoutes(fastify, _opts) {
464
587
  totalSize,
465
588
  });
466
589
  }
467
-
468
- await writeSites(sites);
469
590
  } catch {
470
591
  // Non-critical — don't fail the upload
471
592
  }
@@ -483,8 +604,7 @@ export default async function sitesRoutes(fastify, _opts) {
483
604
  async (request, reply) => {
484
605
  const { id } = IdParamSchema.parse(request.params);
485
606
 
486
- const sites = await readSites();
487
- const site = sites.find((s) => s.id === id);
607
+ const site = await siteForManagement(id);
488
608
  if (!site) {
489
609
  return reply.code(404).send({ error: 'Site not found' });
490
610
  }
@@ -501,10 +621,7 @@ export default async function sitesRoutes(fastify, _opts) {
501
621
 
502
622
  // Update site size
503
623
  try {
504
- const totalSize = await getSiteSize(id);
505
- const siteIndex = sites.findIndex((s) => s.id === id);
506
- sites[siteIndex].totalSize = totalSize;
507
- await writeSites(sites);
624
+ await recordSiteSize(id);
508
625
  } catch {
509
626
  // Non-critical
510
627
  }