@lamalibre/create-lamaste 4.2.0 → 4.3.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 (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Startup: bring installed vhosts in line with what the generators in
3
+ * nginx.js produce today.
4
+ *
5
+ * A vhost is written when its tunnel or site is created or reconfigured, so a
6
+ * generator change never reached the ones that already existed. Two changes
7
+ * must:
8
+ *
9
+ * - from 4.3 lamaste-agentd accepts a request from the relay only with a
10
+ * relay assertion, which only the current agent-panel vhost (signed by the
11
+ * panel) and plugin vhosts (signed by Gatekeeper) forward;
12
+ * - the authorization subrequest of a Gatekeeper tunnel, of an
13
+ * Authelia-protected static site and of the auth portal named the client's
14
+ * `Host` header rather than the vhost's own hostname, so a request line for
15
+ * one hostname sent with the `Host` of another was authorized for the other
16
+ * (see `originalUrl` in nginx.js).
17
+ *
18
+ * Each installed vhost is rendered again from state, on the certificate
19
+ * directory the installed file names (a wildcard certificate stays a wildcard
20
+ * certificate), and rewritten only when the text differs — through the same
21
+ * backup / test / rollback path as any vhost write. A rewrite never links a
22
+ * vhost that is not linked now.
23
+ * A rewrite never adds or removes authentication: a vhost that disagrees with
24
+ * its state on that is reported and left alone. The one exception is the
25
+ * agent-panel vhost, whose mTLS check gains the panel's `auth_request`.
26
+ */
27
+
28
+ import { withTunnelLock } from '@lamalibre/lamaste/server';
29
+ import {
30
+ installVhost,
31
+ isSiteEnabled,
32
+ readInstalledVhost,
33
+ renderAgentPanelVhost,
34
+ renderAuthVhost,
35
+ renderStaticSiteVhost,
36
+ renderTunnelVhost,
37
+ vhostCertDir,
38
+ } from './nginx.js';
39
+ import { readSites, readTunnels } from './state.js';
40
+
41
+ /** A subdomain or site id as it may appear in a site name. */
42
+ const SITE_NAME_PART_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/;
43
+
44
+ const AUTH_VHOST = 'lamalibre-lamaste-auth';
45
+
46
+ /**
47
+ * The vhost a tunnel should have, rendered on `certDir`; null for tunnels
48
+ * without one (internal plugin tunnels) or whose state cannot be rendered.
49
+ *
50
+ * @param {object} tunnel
51
+ * @param {string} certDir
52
+ * @returns {{ name: string, fqdn: string, config: string } | null}
53
+ */
54
+ export function expectedTunnelVhost(tunnel, certDir) {
55
+ if (!managedTunnelVhostName(tunnel) || typeof tunnel.fqdn !== 'string') return null;
56
+ if (!tunnel.fqdn.startsWith(`${tunnel.subdomain}.`)) return null;
57
+ const domain = tunnel.fqdn.slice(tunnel.subdomain.length + 1);
58
+ if (tunnel.type === 'panel') {
59
+ return renderAgentPanelVhost(tunnel.subdomain, domain, tunnel.port, certDir);
60
+ }
61
+ // As createTunnel and updateTunnel write it: the stored access mode
62
+ // (restricted when absent, as Gatekeeper reads it), the plugin route of a
63
+ // plugin tunnel, and a body limit only when the tunnel has one.
64
+ return renderTunnelVhost(
65
+ tunnel.accessMode ?? 'restricted',
66
+ tunnel.subdomain,
67
+ domain,
68
+ tunnel.port,
69
+ certDir,
70
+ {
71
+ ...(tunnel.type === 'plugin' && tunnel.pluginRoute ? { pathPrefix: tunnel.pluginRoute } : {}),
72
+ ...(tunnel.maxBodySizeMb !== undefined ? { maxBodySizeMb: tunnel.maxBodySizeMb } : {}),
73
+ },
74
+ );
75
+ }
76
+
77
+ /** Site name of a tunnel's vhost, or null for a tunnel without one. */
78
+ function managedTunnelVhostName(tunnel) {
79
+ if (tunnel.exposure === 'internal' || typeof tunnel.subdomain !== 'string') return null;
80
+ if (!SITE_NAME_PART_RE.test(tunnel.subdomain)) return null;
81
+ if (tunnel.type === 'panel') return `lamalibre-lamaste-agent-panel-${tunnel.subdomain}`;
82
+ // Tunnels persisted before `type` existed are app tunnels.
83
+ if (tunnel.type === 'app' || tunnel.type === 'plugin' || tunnel.type === undefined) {
84
+ return `lamalibre-lamaste-app-${tunnel.subdomain}`;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** True when a vhost asks for authorization before proxying or serving. */
90
+ function authenticates(config) {
91
+ return /^\s*auth_request\s/m.test(config);
92
+ }
93
+
94
+ function newResult() {
95
+ return { rewritten: [], unchanged: 0, skipped: [], failed: [] };
96
+ }
97
+
98
+ /**
99
+ * Compare one installed vhost with what it should be and rewrite it when it
100
+ * differs; record the outcome in `result` under `label`.
101
+ *
102
+ * @param {object} p
103
+ * @param {string} p.name - site name in sites-available
104
+ * @param {string} p.label - how the result names it: the vhost's hostname
105
+ * @param {(certDir: string) => ({ name: string, fqdn: string, config: string } | null)} p.render
106
+ * @param {() => Promise<boolean>} p.enabled - whether the rewritten site is linked
107
+ * @param {boolean} [p.keepAuthentication] - refuse a rewrite that adds or removes `auth_request` (default true)
108
+ * @param {boolean} [p.reportMissing] - report a vhost that is not installed (default true)
109
+ * @param {ReturnType<typeof newResult>} result
110
+ * @param {{ readInstalledVhost: typeof readInstalledVhost, installVhost: typeof installVhost }} deps
111
+ */
112
+ async function reconcileOne(
113
+ { name, label, render, enabled, keepAuthentication = true, reportMissing = true },
114
+ result,
115
+ deps,
116
+ ) {
117
+ try {
118
+ const installed = await deps.readInstalledVhost(name);
119
+ if (installed === null) {
120
+ if (reportMissing) result.skipped.push({ vhost: label, reason: 'no vhost installed' });
121
+ return;
122
+ }
123
+ const certDir = vhostCertDir(installed);
124
+ if (!certDir) {
125
+ result.skipped.push({ vhost: label, reason: 'installed vhost names no certificate' });
126
+ return;
127
+ }
128
+ const expected = render(certDir);
129
+ if (!expected) {
130
+ result.skipped.push({ vhost: label, reason: 'state cannot be rendered' });
131
+ return;
132
+ }
133
+ if (expected.config === installed) {
134
+ result.unchanged += 1;
135
+ return;
136
+ }
137
+ if (keepAuthentication && authenticates(expected.config) !== authenticates(installed)) {
138
+ result.skipped.push({
139
+ vhost: label,
140
+ reason:
141
+ 'installed vhost and state disagree on whether it authenticates; reconfigure it to rewrite the vhost',
142
+ });
143
+ return;
144
+ }
145
+ await deps.installVhost(expected.name, expected.config, expected.fqdn, {
146
+ enabled: await enabled(),
147
+ });
148
+ result.rewritten.push(label);
149
+ } catch (err) {
150
+ result.failed.push({ vhost: label, error: err instanceof Error ? err.message : String(err) });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * @param {{ info(o: object, m?: string): void, warn(o: object, m?: string): void, error(o: object, m?: string): void }} log
156
+ * @param {'tunnel' | 'site'} what - the vhosts the result is about, for the log messages
157
+ * @param {ReturnType<typeof newResult>} result
158
+ */
159
+ function logResult(log, what, result) {
160
+ if (result.rewritten.length > 0) {
161
+ log.info({ rewritten: result.rewritten }, `Rewrote ${what} vhosts`);
162
+ }
163
+ if (result.skipped.length > 0) {
164
+ log.warn({ skipped: result.skipped }, `Left ${what} vhosts as they are`);
165
+ }
166
+ for (const failure of result.failed) {
167
+ log.error(failure, `Failed to rewrite ${what} vhost; the previous vhost was restored`);
168
+ }
169
+ }
170
+
171
+ const defaultTunnelDeps = { readTunnels, readInstalledVhost, installVhost, isSiteEnabled };
172
+
173
+ /**
174
+ * Reconcile the vhost of every tunnel that has one: agent-panel, app and
175
+ * plugin tunnels. Runs under the tunnel lock, so no tunnel change
176
+ * interleaves. Call it once the panel listens: a rewritten agent-panel vhost
177
+ * asks the panel about every request.
178
+ *
179
+ * @param {Parameters<typeof logResult>[0]} log
180
+ * @param {typeof defaultTunnelDeps} [deps]
181
+ * @returns {Promise<ReturnType<typeof newResult>>}
182
+ */
183
+ export async function reconcileTunnelVhosts(log, deps = defaultTunnelDeps) {
184
+ return withTunnelLock(async () => {
185
+ const result = newResult();
186
+ for (const tunnel of await deps.readTunnels()) {
187
+ const name = managedTunnelVhostName(tunnel);
188
+ if (!name) continue;
189
+ await reconcileOne(
190
+ {
191
+ name,
192
+ label: tunnel.fqdn ?? tunnel.subdomain,
193
+ render: (certDir) => expectedTunnelVhost(tunnel, certDir),
194
+ // Linked only when it is now and the tunnel is enabled: a rewrite of
195
+ // the text never publishes a vhost someone unlinked, and unlinks a
196
+ // disabled tunnel's.
197
+ enabled: async () => tunnel.enabled !== false && (await deps.isSiteEnabled(name)),
198
+ keepAuthentication: tunnel.type !== 'panel',
199
+ },
200
+ result,
201
+ deps,
202
+ );
203
+ }
204
+ logResult(log, 'tunnel', result);
205
+ return result;
206
+ });
207
+ }
208
+
209
+ const defaultSiteDeps = { readSites, readInstalledVhost, installVhost, isSiteEnabled };
210
+
211
+ /**
212
+ * Reconcile the vhosts that ask Authelia directly: the auth portal
213
+ * (`auth.<domain>`) and every static site. Site changes take no lock, so call
214
+ * it before the panel listens — then nothing can change a site meanwhile.
215
+ *
216
+ * @param {Parameters<typeof logResult>[0]} log
217
+ * @param {string} domain
218
+ * @param {typeof defaultSiteDeps} [deps]
219
+ * @returns {Promise<ReturnType<typeof newResult>>}
220
+ */
221
+ export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
222
+ const result = newResult();
223
+
224
+ await reconcileOne(
225
+ {
226
+ name: AUTH_VHOST,
227
+ label: `auth.${domain}`,
228
+ render: (certDir) => renderAuthVhost(domain, certDir),
229
+ enabled: () => deps.isSiteEnabled(AUTH_VHOST),
230
+ },
231
+ result,
232
+ deps,
233
+ );
234
+
235
+ let sites = [];
236
+ try {
237
+ sites = await deps.readSites();
238
+ } catch (err) {
239
+ result.failed.push({ vhost: 'sites', error: err instanceof Error ? err.message : String(err) });
240
+ }
241
+ for (const site of sites) {
242
+ if (typeof site.id !== 'string' || !SITE_NAME_PART_RE.test(site.id)) continue;
243
+ const name = `lamalibre-lamaste-site-${site.id}`;
244
+ await reconcileOne(
245
+ {
246
+ name,
247
+ label: site.fqdn ?? site.id,
248
+ render: (certDir) => renderStaticSiteVhost(site, certDir, domain),
249
+ enabled: () => deps.isSiteEnabled(name),
250
+ // A site still waiting for its DNS or certificate has no vhost yet.
251
+ reportMissing: site.certIssued === true,
252
+ },
253
+ result,
254
+ deps,
255
+ );
256
+ }
257
+
258
+ logResult(log, 'site', result);
259
+ return result;
260
+ }
@@ -14,6 +14,15 @@ import {
14
14
  */
15
15
  const EXEMPT_PATHS = new Set(['/api/health', '/api/settings/2fa', '/api/settings/2fa/verify']);
16
16
 
17
+ /**
18
+ * The agent-panel vhost's authorization subrequest
19
+ * (`/api/agent-panel/authz/<label>`). It is answered for a browser on
20
+ * `agent-<label>.<domain>`, which never carries the panel's host-only 2FA
21
+ * cookie; agent panels are guarded by the client certificate alone, as they
22
+ * were before the panel took part in their authorization.
23
+ */
24
+ const AGENT_PANEL_AUTHZ_RE = /^\/api\/agent-panel\/authz\/[a-z0-9-]{1,63}$/;
25
+
17
26
  async function twofaSessionPlugin(fastify, _opts) {
18
27
  fastify.addHook('onRequest', async (request, reply) => {
19
28
  const config = getConfig();
@@ -30,7 +39,8 @@ async function twofaSessionPlugin(fastify, _opts) {
30
39
  }
31
40
 
32
41
  // Exempt paths
33
- if (EXEMPT_PATHS.has(request.url) || EXEMPT_PATHS.has(request.url.split('?')[0])) {
42
+ const urlPath = request.url.split('?')[0];
43
+ if (EXEMPT_PATHS.has(urlPath) || AGENT_PANEL_AUTHZ_RE.test(urlPath)) {
34
44
  return;
35
45
  }
36
46
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `/api/<route>/*` for agent-hosted plugins with an agent half — forwarded
3
+ * by the panel to the plugin's agent half over its internal tunnel, through
4
+ * the plugin's approved request hooks (`request:pre-forward` /
5
+ * `request:post-forward`) and, while it is down, its `tunnel:down` fallback.
6
+ * See `createHookedForwarder` in `@lamalibre/lamaste/server`.
7
+ *
8
+ * Registered after every other `/api` route: static routes win over its
9
+ * `/:route/*`, so only what nothing else answers arrives here, and a route
10
+ * that is no hosted plugin gets the panel's usual 404.
11
+ */
12
+ import { createHookedForwarder } from '@lamalibre/lamaste/server';
13
+ import { getConfig } from '../lib/config.js';
14
+ import { resolveForwardTarget, authorizeForwardedCaller } from '../lib/plugin-hosting.js';
15
+ import { requestHookRuntime } from '../lib/request-hooks.js';
16
+
17
+ export default async function agentForwarderRoutes(fastify) {
18
+ const forwarder = createHookedForwarder({
19
+ resolveTarget: resolveForwardTarget,
20
+ authorize: authorizeForwardedCaller,
21
+ // Management routes answer 503 before onboarding completes; so does a
22
+ // hosted plugin's route (but a non-route stays a 404, as before).
23
+ guard: () => {
24
+ const config = getConfig();
25
+ if (config.onboarding.status !== 'COMPLETED') {
26
+ return {
27
+ status: 503,
28
+ body: { error: 'Onboarding not complete', onboardingStatus: config.onboarding.status },
29
+ };
30
+ }
31
+ return null;
32
+ },
33
+ logger: fastify.log,
34
+ hooks: requestHookRuntime,
35
+ });
36
+ await fastify.register(forwarder);
37
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * GET /api/agent-panel/authz/:label — the `auth_request` target of an
3
+ * agent-panel vhost (`agent-<label>.<domain>`).
4
+ *
5
+ * nginx calls it for every request on that vhost, with the client
6
+ * certificate's verification result in the `X-SSL-Client-*` headers (so the
7
+ * mTLS middleware refuses a revoked or unknown certificate, as for any panel
8
+ * route) and the request's method and target in `X-Original-Method` /
9
+ * `X-Original-URI`. On success it answers 200 with a relay assertion in
10
+ * `X-Lamalibre-Relay-Assertion`, which nginx forwards to the agent's
11
+ * lamaste-agentd — the only proof of the caller the daemon accepts from the
12
+ * relay. A 401/403 from here is what nginx answers the browser.
13
+ *
14
+ * Through the panel's own vhosts this route is reachable too, with
15
+ * client-chosen `X-Original-*`. That gains nothing: the assertion only ever
16
+ * names the caller's own, nginx-verified certificate, for a panel that
17
+ * certificate may use anyway.
18
+ */
19
+
20
+ import { z } from 'zod';
21
+ import { signRelayAssertion, RELAY_ASSERTION_HEADER } from '@lamalibre/lamaste';
22
+ import { authorizeAgentPanelRequest } from '@lamalibre/lamaste/server';
23
+ import { readTunnels } from '../../lib/state.js';
24
+ import { relaySigner } from '../../lib/relay-signing.js';
25
+
26
+ const LabelParamSchema = z.object({
27
+ label: z
28
+ .string()
29
+ .min(1)
30
+ .max(63)
31
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Invalid agent label'),
32
+ });
33
+
34
+ /** @param {string | string[] | undefined} value */
35
+ function single(value) {
36
+ return typeof value === 'string' ? value : undefined;
37
+ }
38
+
39
+ export default async function agentPanelAuthzRoutes(fastify, _opts) {
40
+ fastify.get(
41
+ '/agent-panel/authz/:label',
42
+ {
43
+ preHandler: fastify.requireRole(['admin', 'agent']),
44
+ // One call per request on an agent panel — its SPA's assets and API
45
+ // polling included — keyed by the browser's address (nginx sets
46
+ // X-Forwarded-For to $remote_addr on the subrequest). Well above what
47
+ // the daemon behind it serves (100/min), so it never is the limit.
48
+ config: { rateLimit: { max: 1200, timeWindow: '1 minute' } },
49
+ },
50
+ async (request, reply) => {
51
+ const { label } = LabelParamSchema.parse(request.params);
52
+ reply.header('Cache-Control', 'no-store');
53
+
54
+ const decision = authorizeAgentPanelRequest(
55
+ {
56
+ label,
57
+ certRole: request.certRole,
58
+ certLabel: request.certLabel,
59
+ method: single(request.headers['x-original-method']),
60
+ uri: single(request.headers['x-original-uri']),
61
+ },
62
+ await readTunnels(),
63
+ );
64
+ if (!decision.ok) {
65
+ request.log.info(
66
+ { label, certRole: request.certRole, reason: decision.reason },
67
+ 'Agent panel request refused',
68
+ );
69
+ return reply.code(decision.status).send();
70
+ }
71
+
72
+ let key;
73
+ try {
74
+ key = await relaySigner.current();
75
+ } catch (err) {
76
+ request.log.error(
77
+ { err: err instanceof Error ? err.message : String(err) },
78
+ 'Relay signing key unusable',
79
+ );
80
+ key = null;
81
+ }
82
+ if (!key) return reply.code(503).send();
83
+
84
+ const assertion = signRelayAssertion(
85
+ {
86
+ aud: decision.aud,
87
+ port: decision.port,
88
+ caller: decision.caller,
89
+ method: decision.method,
90
+ target: { path: decision.path },
91
+ },
92
+ key,
93
+ );
94
+ return reply.header(RELAY_ASSERTION_HEADER, assertion).code(200).send();
95
+ },
96
+ );
97
+ }