@lamalibre/create-lamaste 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +34 -2
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +2 -2
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +5 -5
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/02-guides/static-sites.md +4 -0
  19. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  20. package/vendor/docs/03-architecture/installer.md +4 -4
  21. package/vendor/docs/03-architecture/management-flow.md +1 -1
  22. package/vendor/docs/03-architecture/nginx-configuration.md +65 -7
  23. package/vendor/docs/03-architecture/overview.md +5 -5
  24. package/vendor/docs/03-architecture/panel-client.md +6 -6
  25. package/vendor/docs/03-architecture/panel-server.md +2 -2
  26. package/vendor/docs/03-architecture/state-management.md +2 -2
  27. package/vendor/docs/03-architecture/system-overview.md +1 -1
  28. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +112 -37
  29. package/vendor/docs/04-api-reference/app-host.md +3 -3
  30. package/vendor/docs/04-api-reference/gatekeeper.md +19 -1
  31. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  32. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  33. package/vendor/docs/04-api-reference/sites.md +4 -0
  34. package/vendor/docs/04-api-reference/tunnels.md +21 -19
  35. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  36. package/vendor/docs/05-operations/uninstalling.md +1 -1
  37. package/vendor/docs/05-operations/upgrades.md +15 -6
  38. package/vendor/docs/06-reference/config-files.md +19 -15
  39. package/vendor/docs/06-reference/glossary.md +3 -3
  40. package/vendor/docs/06-reference/installer-flags.md +13 -13
  41. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  42. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  43. package/vendor/docs/gatekeeper-design.md +8 -8
  44. package/vendor/docs/identity-directory-design.md +3 -3
  45. package/vendor/docs/package.json +1 -1
  46. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  47. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  48. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  49. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  50. package/vendor/gatekeeper/dist/index.js +2 -2
  51. package/vendor/gatekeeper/dist/index.js.map +1 -1
  52. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  53. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  54. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  55. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  56. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  57. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  58. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  59. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  60. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  61. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  63. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  65. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  67. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  68. package/vendor/gatekeeper/dist/server/index.js +29 -10
  69. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  70. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/server/routes/authz.js +103 -22
  72. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  73. package/vendor/gatekeeper/package.json +2 -2
  74. package/vendor/server/package.json +2 -2
  75. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  76. package/vendor/server/src/commands/plugins.js +63 -6
  77. package/vendor/server/src/commands/sites.js +15 -1
  78. package/vendor/server/src/plugin-approval.js +80 -1
  79. package/vendor/server/src/plugin-web-surface.js +307 -0
  80. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  81. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  82. package/vendor/server-ui/dist/index.html +2 -2
  83. package/vendor/serverd/package.json +2 -2
  84. package/vendor/serverd/src/index.js +9 -4
  85. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  86. package/vendor/serverd/src/lib/chisel.js +10 -2
  87. package/vendor/serverd/src/lib/gatekeeper-client.js +18 -7
  88. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  89. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  90. package/vendor/serverd/src/lib/nginx.js +269 -2
  91. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  92. package/vendor/serverd/src/lib/plugin-lifecycle.js +128 -5
  93. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  94. package/vendor/serverd/src/lib/plugins.js +39 -6
  95. package/vendor/serverd/src/lib/state.js +18 -0
  96. package/vendor/serverd/src/lib/user-surface.js +48 -27
  97. package/vendor/serverd/src/lib/vhost-reconcile.js +5 -3
  98. package/vendor/serverd/src/lib/web-surface.js +766 -0
  99. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  100. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  101. package/vendor/serverd/src/routes/management/sites.js +143 -65
  102. package/vendor/serverd/src/routes/management/tunnels.js +11 -20
  103. package/vendor/serverd/src/routes/management.js +2 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -0,0 +1,151 @@
1
+ /**
2
+ * A plugin's web surface (slice 7): the site it owns, where the relay
3
+ * serves the shell its server package ships beside its user surface's API.
4
+ * Admin only. Business logic lives in `lib/web-surface.js`.
5
+ *
6
+ * GET /plugins/:name/web-surface what the installed version declares, the
7
+ * site, whether it is served, the trees, the offer
8
+ * POST /plugins/:name/web-surface { subdomain?, accessMode? } — create the site
9
+ * POST /plugins/:name/web-surface/move { tunnelId } — take over the bound tunnel's host
10
+ * DELETE /plugins/:name/web-surface delete the site and its trees
11
+ */
12
+
13
+ import { z } from 'zod';
14
+ import { WEB_SURFACE_ACCESS_MODES, derivePluginRoute } from '@lamalibre/lamaste';
15
+ import { actorFromRequest } from '../../lib/actor.js';
16
+ import { findPlugin } from '../../lib/plugins.js';
17
+ import {
18
+ createWebSurface,
19
+ deleteWebSurface,
20
+ moveWebSurfaceFromTunnel,
21
+ webSurfaceStatus,
22
+ } from '../../lib/web-surface.js';
23
+
24
+ const PluginNameParamSchema = z.object({
25
+ name: z
26
+ .string()
27
+ .min(1)
28
+ .max(100)
29
+ .regex(/^[a-z0-9-]+$/, 'Plugin name must contain only lowercase letters, numbers, and hyphens'),
30
+ });
31
+
32
+ const CreateBodySchema = z
33
+ .object({
34
+ subdomain: z
35
+ .string()
36
+ .min(1)
37
+ .max(63)
38
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'subdomain is a DNS label')
39
+ .optional(),
40
+ accessMode: z.enum(WEB_SURFACE_ACCESS_MODES).optional(),
41
+ })
42
+ .strict();
43
+
44
+ const MoveBodySchema = z.object({ tunnelId: z.string().min(1).max(64) }).strict();
45
+
46
+ /** The refusal a library threw (its `statusCode`, message and `code`), or a 500. */
47
+ function sendFailure(request, reply, err, fallback) {
48
+ if (Number.isInteger(err.statusCode) && err.statusCode < 500) {
49
+ return reply
50
+ .code(err.statusCode)
51
+ .send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
52
+ }
53
+ request.log.error(err, fallback);
54
+ return reply
55
+ .code(Number.isInteger(err.statusCode) ? err.statusCode : 500)
56
+ .send({ error: fallback, details: err.message });
57
+ }
58
+
59
+ /**
60
+ * The route plugin `:name` holds: the installed plugin's, else the one its
61
+ * name derives — a site outlives its plugin, and its status and deletion
62
+ * stay reachable (creating or moving refuses without an installed plugin).
63
+ */
64
+ async function routeOf(request) {
65
+ const { name } = PluginNameParamSchema.parse(request.params);
66
+ const plugin = await findPlugin(name);
67
+ return plugin?.route ?? derivePluginRoute(name);
68
+ }
69
+
70
+ export default async function pluginWebSurfaceRoutes(fastify, _opts) {
71
+ fastify.get(
72
+ '/plugins/:name/web-surface',
73
+ { preHandler: fastify.requireRole(['admin']) },
74
+ async (request, reply) => {
75
+ const route = await routeOf(request);
76
+ try {
77
+ return { webSurface: await webSurfaceStatus(route) };
78
+ } catch (err) {
79
+ return sendFailure(request, reply, err, 'Failed to read the web surface');
80
+ }
81
+ },
82
+ );
83
+
84
+ fastify.post(
85
+ '/plugins/:name/web-surface',
86
+ {
87
+ preHandler: fastify.requireRole(['admin']),
88
+ // Issues a certificate and reloads nginx.
89
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
90
+ },
91
+ async (request, reply) => {
92
+ const route = await routeOf(request);
93
+ const body = CreateBodySchema.parse(request.body ?? {});
94
+ try {
95
+ const webSurface = await createWebSurface(route, body, {
96
+ actor: actorFromRequest(request),
97
+ logger: request.log,
98
+ });
99
+ return reply.code(201).send({ webSurface });
100
+ } catch (err) {
101
+ return sendFailure(request, reply, err, 'Failed to create the web surface');
102
+ }
103
+ },
104
+ );
105
+
106
+ fastify.post(
107
+ '/plugins/:name/web-surface/move',
108
+ {
109
+ preHandler: fastify.requireRole(['admin']),
110
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
111
+ },
112
+ async (request, reply) => {
113
+ const route = await routeOf(request);
114
+ const { tunnelId } = MoveBodySchema.parse(request.body);
115
+ try {
116
+ return {
117
+ webSurface: await moveWebSurfaceFromTunnel(route, tunnelId, {
118
+ actor: actorFromRequest(request),
119
+ logger: request.log,
120
+ }),
121
+ };
122
+ } catch (err) {
123
+ return sendFailure(
124
+ request,
125
+ reply,
126
+ err,
127
+ 'Failed to move the user surface to the web surface',
128
+ );
129
+ }
130
+ },
131
+ );
132
+
133
+ fastify.delete(
134
+ '/plugins/:name/web-surface',
135
+ {
136
+ preHandler: fastify.requireRole(['admin']),
137
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
138
+ },
139
+ async (request, reply) => {
140
+ const route = await routeOf(request);
141
+ try {
142
+ return await deleteWebSurface(route, {
143
+ actor: actorFromRequest(request),
144
+ logger: request.log,
145
+ });
146
+ } catch (err) {
147
+ return sendFailure(request, reply, err, 'Failed to delete the web surface');
148
+ }
149
+ },
150
+ );
151
+ }
@@ -301,6 +301,8 @@ async function withHooks(plugin) {
301
301
  agent: status.agent !== null,
302
302
  // Lamaste installs the agent half (agent.package); false: none, or installed by hand.
303
303
  agentHalf: status.agentHalf !== null,
304
+ // The installed version declares a web surface (its tree is in the approval).
305
+ web: status.web.declared,
304
306
  approval: status.approval.state,
305
307
  autoApprove: status.autoApprove,
306
308
  failOpenCount: status.relay?.failOpenCount ?? 0,
@@ -11,9 +11,12 @@ import {
11
11
  updateSite,
12
12
  verifyDns,
13
13
  SiteError,
14
+ withTunnelLock,
14
15
  } from '@lamalibre/lamaste/server';
16
+ import { AppError } from '../../lib/app-error.js';
17
+ import { webSurfaceServing } from '../../lib/web-surface.js';
15
18
  import { getConfig } from '../../lib/config.js';
16
- import { readSites, writeSites, readTunnels } from '../../lib/state.js';
19
+ import { readSites, writeSites, readTunnels, withSitesLock } from '../../lib/state.js';
17
20
  import { writeStaticSiteVhost, removeStaticSiteVhost } from '../../lib/nginx.js';
18
21
  import { updateAccessControl } from '../../lib/authelia.js';
19
22
  import { issueTunnelCert, getCertPath, issueSiteCert } from '../../lib/certbot.js';
@@ -134,6 +137,79 @@ function siteErrorStatus(code) {
134
137
  }
135
138
  }
136
139
 
140
+ /**
141
+ * Run a site workflow that may give a site a hostname: under core's tunnel
142
+ * lock, then the sites lock — the order every such workflow takes them in
143
+ * (`withSitesLock`) — so no tunnel takes the name meanwhile.
144
+ *
145
+ * @template T
146
+ * @param {() => Promise<T>} fn
147
+ * @returns {Promise<T>}
148
+ */
149
+ function withHostnameLocks(fn) {
150
+ return withTunnelLock(() => withSitesLock(fn));
151
+ }
152
+
153
+ /**
154
+ * Refuse (409) a site a plugin owns: its files are the plugin's approved
155
+ * web tree and its settings follow the plugin — it is managed through the
156
+ * plugin's web surface, never here (and never by `lamaste-agent deploy`).
157
+ */
158
+ function assertNotPluginOwned(site) {
159
+ if (site?.owner) {
160
+ throw new AppError(
161
+ `Site '${site.name}' is the web surface of plugin '${site.owner.plugin}' — manage it through the plugin`,
162
+ 409,
163
+ { code: 'plugin_owned_site' },
164
+ );
165
+ }
166
+ }
167
+
168
+ /** The site `id`, refused when a plugin owns it; null when there is none. */
169
+ async function siteForManagement(id) {
170
+ const site = (await readSites()).find((s) => s.id === id) ?? null;
171
+ assertNotPluginOwned(site);
172
+ return site;
173
+ }
174
+
175
+ /**
176
+ * Record the size of site `id` on disk in sites.json, re-reading the file
177
+ * under the sites lock — never a copy read before an upload, which would
178
+ * undo every change made meanwhile.
179
+ *
180
+ * @returns {Promise<number>} the size
181
+ */
182
+ async function recordSiteSize(id) {
183
+ const totalSize = await getSiteSize(id);
184
+ await withSitesLock(async () => {
185
+ const sites = await readSites();
186
+ const site = sites.find((s) => s.id === id);
187
+ if (!site) return;
188
+ site.totalSize = totalSize;
189
+ await writeSites(sites);
190
+ });
191
+ return totalSize;
192
+ }
193
+
194
+ /**
195
+ * Sites as listed: a plugin-owned one with `webSurface: { served, reason }`
196
+ * — whether it serves its plugin's shell and API now, or answers 503 and why.
197
+ */
198
+ async function withWebSurfaceState(sites) {
199
+ if (!sites.some((site) => site.owner)) return sites;
200
+ const tunnels = await readTunnels();
201
+ return Promise.all(
202
+ sites.map(async (site) => {
203
+ if (!site.owner) return site;
204
+ const serving = await webSurfaceServing(site.owner.plugin, tunnels);
205
+ return {
206
+ ...site,
207
+ webSurface: { served: serving.served, reason: serving.served ? null : serving.reason },
208
+ };
209
+ }),
210
+ );
211
+ }
212
+
137
213
  function assertAgentSiteAccess(request, site) {
138
214
  if (request.certRole === 'agent') {
139
215
  const allowed = request.certAllowedSites || [];
@@ -191,7 +267,7 @@ export default async function sitesRoutes(fastify, _opts) {
191
267
  }
192
268
 
193
269
  const total = sites.length;
194
- const windowed = sites.slice(offset, offset + limit);
270
+ const windowed = await withWebSurfaceState(sites.slice(offset, offset + limit));
195
271
 
196
272
  return { sites: windowed, total, limit, offset };
197
273
  },
@@ -214,22 +290,24 @@ export default async function sitesRoutes(fastify, _opts) {
214
290
  }
215
291
 
216
292
  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,
232
- });
293
+ const result = await withHostnameLocks(() =>
294
+ createSite({
295
+ name: body.name,
296
+ type: body.type,
297
+ customDomain: body.customDomain,
298
+ aliases: body.aliases,
299
+ spaMode: body.spaMode,
300
+ autheliaProtected: body.autheliaProtected,
301
+ domain: config.domain,
302
+ email: config.email,
303
+ nginx: nginxDeps,
304
+ certbot: certbotDeps,
305
+ files: filesDeps,
306
+ siteState: siteStateDeps,
307
+ tunnelState: tunnelReadDeps,
308
+ logger: request.log,
309
+ }),
310
+ );
233
311
 
234
312
  const response = { ok: true, site: result.site };
235
313
  if (result.message) response.message = result.message;
@@ -261,12 +339,15 @@ export default async function sitesRoutes(fastify, _opts) {
261
339
  const { id } = IdParamSchema.parse(request.params);
262
340
 
263
341
  try {
264
- await deleteSite({
265
- id,
266
- nginx: nginxDeps,
267
- files: filesDeps,
268
- siteState: siteStateDeps,
269
- logger: request.log,
342
+ await withSitesLock(async () => {
343
+ await siteForManagement(id);
344
+ await deleteSite({
345
+ id,
346
+ nginx: nginxDeps,
347
+ files: filesDeps,
348
+ siteState: siteStateDeps,
349
+ logger: request.log,
350
+ });
270
351
  });
271
352
  return { ok: true };
272
353
  } catch (err) {
@@ -277,6 +358,7 @@ export default async function sitesRoutes(fastify, _opts) {
277
358
  details: err.message,
278
359
  });
279
360
  }
361
+ if (err instanceof AppError) throw err;
280
362
  request.log.error(err, 'Failed to delete site');
281
363
  return reply.code(500).send({
282
364
  error: 'Failed to delete site',
@@ -298,21 +380,24 @@ export default async function sitesRoutes(fastify, _opts) {
298
380
  const config = getConfig();
299
381
 
300
382
  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,
383
+ const result = await withHostnameLocks(async () => {
384
+ await siteForManagement(id);
385
+ return updateSite({
386
+ id,
387
+ spaMode: body.spaMode,
388
+ autheliaProtected: body.autheliaProtected,
389
+ allowedUsers: body.allowedUsers,
390
+ aliases: body.aliases,
391
+ domain: config.domain,
392
+ email: config.email,
393
+ serverIp: config.ip,
394
+ nginx: nginxDeps,
395
+ certbot: certbotDeps,
396
+ siteState: siteStateDeps,
397
+ tunnelState: tunnelReadDeps,
398
+ authelia: autheliaDeps,
399
+ logger: request.log,
400
+ });
316
401
  });
317
402
  return result;
318
403
  } catch (err) {
@@ -330,6 +415,7 @@ export default async function sitesRoutes(fastify, _opts) {
330
415
  details: err.message,
331
416
  });
332
417
  }
418
+ if (err instanceof AppError) throw err;
333
419
  request.log.error(err, 'Failed to update site');
334
420
  return reply.code(500).send({
335
421
  error: 'Failed to update site configuration',
@@ -353,15 +439,18 @@ export default async function sitesRoutes(fastify, _opts) {
353
439
  const config = getConfig();
354
440
 
355
441
  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,
442
+ const result = await withHostnameLocks(async () => {
443
+ await siteForManagement(id);
444
+ return verifyDns({
445
+ id,
446
+ serverIp: config.ip,
447
+ domain: config.domain,
448
+ email: config.email,
449
+ nginx: nginxDeps,
450
+ certbot: certbotDeps,
451
+ siteState: siteStateDeps,
452
+ logger: request.log,
453
+ });
365
454
  });
366
455
  return result;
367
456
  } catch (err) {
@@ -371,6 +460,7 @@ export default async function sitesRoutes(fastify, _opts) {
371
460
  error: err.message,
372
461
  });
373
462
  }
463
+ if (err instanceof AppError) throw err;
374
464
  request.log.error(err, 'Failed to verify DNS');
375
465
  return reply.code(500).send({
376
466
  error: 'DNS verification failed',
@@ -390,8 +480,7 @@ export default async function sitesRoutes(fastify, _opts) {
390
480
  const { id } = IdParamSchema.parse(request.params);
391
481
  const { path: relativePath } = PathQuerySchema.parse(request.query);
392
482
 
393
- const sites = await readSites();
394
- const site = sites.find((s) => s.id === id);
483
+ const site = await siteForManagement(id);
395
484
  if (!site) {
396
485
  return reply.code(404).send({ error: 'Site not found' });
397
486
  }
@@ -417,8 +506,7 @@ export default async function sitesRoutes(fastify, _opts) {
417
506
  const { id } = IdParamSchema.parse(request.params);
418
507
  const config = getConfig();
419
508
 
420
- const sites = await readSites();
421
- const site = sites.find((s) => s.id === id);
509
+ const site = await siteForManagement(id);
422
510
  if (!site) {
423
511
  return reply.code(404).send({ error: 'Site not found' });
424
512
  }
@@ -450,13 +538,9 @@ export default async function sitesRoutes(fastify, _opts) {
450
538
 
451
539
  // Update site size
452
540
  try {
453
- const totalSize = await getSiteSize(id);
454
- const siteIndex = sites.findIndex((s) => s.id === id);
455
- sites[siteIndex].totalSize = totalSize;
456
-
541
+ const totalSize = await recordSiteSize(id);
457
542
  if (totalSize > config.maxSiteSize) {
458
543
  // Don't block but warn
459
- await writeSites(sites);
460
544
  return reply.code(200).send({
461
545
  ok: true,
462
546
  files: uploadedFiles,
@@ -464,8 +548,6 @@ export default async function sitesRoutes(fastify, _opts) {
464
548
  totalSize,
465
549
  });
466
550
  }
467
-
468
- await writeSites(sites);
469
551
  } catch {
470
552
  // Non-critical — don't fail the upload
471
553
  }
@@ -483,8 +565,7 @@ export default async function sitesRoutes(fastify, _opts) {
483
565
  async (request, reply) => {
484
566
  const { id } = IdParamSchema.parse(request.params);
485
567
 
486
- const sites = await readSites();
487
- const site = sites.find((s) => s.id === id);
568
+ const site = await siteForManagement(id);
488
569
  if (!site) {
489
570
  return reply.code(404).send({ error: 'Site not found' });
490
571
  }
@@ -501,10 +582,7 @@ export default async function sitesRoutes(fastify, _opts) {
501
582
 
502
583
  // Update site size
503
584
  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);
585
+ await recordSiteSize(id);
508
586
  } catch {
509
587
  // Non-critical
510
588
  }
@@ -220,10 +220,16 @@ function maxBodyCeiling(request) {
220
220
  return request.certRole === 'admin' ? TUNNEL_MAX_BODY_MB.max : TUNNEL_MAX_BODY_MB.agentMax;
221
221
  }
222
222
 
223
- /** A static site (or one of its aliases) that already answers on `fqdn`. */
223
+ /**
224
+ * Core `createTunnel`'s `hostTaken`: the static site (or one of its
225
+ * aliases) that already answers on `fqdn` — two vhosts with one
226
+ * server_name make nginx pick silently. Asked under the tunnel lock, which
227
+ * every workflow giving a site a hostname holds too.
228
+ */
224
229
  async function siteServing(fqdn) {
225
230
  const sites = await readSites();
226
- return sites.find((s) => s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn)) ?? null;
231
+ const site = sites.find((s) => s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn));
232
+ return site ? `by a static site ('${site.name}')` : null;
227
233
  }
228
234
 
229
235
  /**
@@ -496,16 +502,6 @@ export default async function tunnelRoutes(fastify, _opts) {
496
502
  });
497
503
  }
498
504
 
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
- });
507
- }
508
-
509
505
  try {
510
506
  const tunnel = await createTunnel({
511
507
  subdomain,
@@ -523,6 +519,7 @@ export default async function tunnelRoutes(fastify, _opts) {
523
519
  chisel: chiselDeps,
524
520
  state: stateDeps,
525
521
  agents: agentDeps,
522
+ hostTaken: siteServing,
526
523
  logger: request.log,
527
524
  });
528
525
  return reply.code(201).send({ ok: true, tunnel });
@@ -600,6 +597,7 @@ export default async function tunnelRoutes(fastify, _opts) {
600
597
  state: stateDeps,
601
598
  agents: agentDeps,
602
599
  userSurface: userSurfaceDeps,
600
+ hostTaken: siteServing,
603
601
  logger: request.log,
604
602
  authorize: authorizeFor(request, 'change'),
605
603
  });
@@ -748,14 +746,6 @@ export default async function tunnelRoutes(fastify, _opts) {
748
746
  });
749
747
  }
750
748
 
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
749
  // Check if a panel tunnel already exists for this agent
760
750
  const existing = await readTunnels();
761
751
  const existingPanel = existing.find((t) => t.type === 'panel' && t.subdomain === subdomain);
@@ -780,6 +770,7 @@ export default async function tunnelRoutes(fastify, _opts) {
780
770
  chisel: chiselDeps,
781
771
  state: stateDeps,
782
772
  agents: agentDeps,
773
+ hostTaken: siteServing,
783
774
  logger: request.log,
784
775
  });
785
776
  return reply.code(201).send({ ok: true, tunnel });
@@ -13,6 +13,7 @@ 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';
@@ -37,6 +38,7 @@ export default async function managementRoutes(fastify, _opts) {
37
38
  await fastify.register(identityRoutes);
38
39
  await fastify.register(identityDirectoryRoutes);
39
40
  await fastify.register(pluginIdentityRoutes);
41
+ await fastify.register(pluginWebSurfaceRoutes);
40
42
  await fastify.register(storageRoutes);
41
43
  await fastify.register(gatekeeperProxyRoutes);
42
44
  await fastify.register(agentsRoutes);