@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.
- package/package.json +2 -2
- package/scripts/lamaste-priv +205 -2
- package/src/index.js +4 -4
- package/src/lib/node-version.js +114 -0
- package/src/lib/relay-services.js +15 -1
- package/src/lib/service-config.js +113 -0
- package/src/tasks/gatekeeper.js +8 -3
- package/src/tasks/node.js +34 -75
- package/src/tasks/redeploy.js +36 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +6 -3
- package/vendor/docs/01-concepts/authorization.md +29 -17
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
- package/vendor/docs/01-concepts/security-model.md +98 -34
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +34 -16
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
- package/vendor/docs/03-architecture/overview.md +8 -8
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
- package/vendor/docs/04-api-reference/app-host.md +360 -0
- package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
- package/vendor/docs/04-api-reference/certificates.md +95 -38
- package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -111
- package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
- package/vendor/docs/04-api-reference/tunnels.md +15 -13
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +25 -20
- package/vendor/docs/06-reference/config-files.md +190 -97
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +33 -8
- package/vendor/docs/_index.json +20 -0
- package/vendor/docs/gatekeeper-design.md +22 -19
- package/vendor/docs/identity-directory-design.md +57 -56
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +47 -8
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +4 -3
- package/vendor/server/bin/lamaste-server.js +18 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/audit.js +60 -0
- package/vendor/server/src/commands/plugins.js +963 -12
- package/vendor/server/src/commands/push-install.js +317 -0
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/uninstall.js +28 -1
- package/vendor/server/src/ndjson.js +4 -2
- package/vendor/server/src/panel-api.js +32 -4
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
- package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +56 -4
- package/vendor/serverd/src/lib/actor.js +24 -0
- package/vendor/serverd/src/lib/agent-jobs.js +330 -0
- package/vendor/serverd/src/lib/app-error.js +10 -0
- package/vendor/serverd/src/lib/audit.js +145 -0
- package/vendor/serverd/src/lib/hook-effects.js +123 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
- package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
- package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
- package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
- package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +89 -8
- package/vendor/serverd/src/lib/nginx.js +298 -44
- package/vendor/serverd/src/lib/notifications.js +107 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
- package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
- package/vendor/serverd/src/lib/plugin-operations.js +295 -0
- package/vendor/serverd/src/lib/plugin-packages.js +249 -0
- package/vendor/serverd/src/lib/plugins.js +298 -68
- package/vendor/serverd/src/lib/push-install.js +88 -2
- package/vendor/serverd/src/lib/relay-hooks.js +100 -0
- package/vendor/serverd/src/lib/relay-signing.js +43 -0
- package/vendor/serverd/src/lib/request-hooks.js +79 -0
- package/vendor/serverd/src/lib/retained-data.js +99 -0
- package/vendor/serverd/src/lib/trust-proxy.js +34 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
- package/vendor/serverd/src/middleware/twofa-session.js +11 -1
- package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
- package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
- package/vendor/serverd/src/routes/management/agents.js +262 -1
- package/vendor/serverd/src/routes/management/audit.js +81 -0
- package/vendor/serverd/src/routes/management/certs.js +1 -15
- package/vendor/serverd/src/routes/management/plugins.js +569 -54
- package/vendor/serverd/src/routes/management/tunnels.js +26 -0
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
|
-
import { access, lstat } from 'node:fs/promises';
|
|
2
|
+
import { access, lstat, readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
5
|
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
@@ -14,6 +14,14 @@ const SITES_ENABLED = '/etc/nginx/sites-enabled';
|
|
|
14
14
|
*/
|
|
15
15
|
const PRIV = '/usr/local/sbin/lamaste-priv';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Ceiling for one nginx-related command (a `lamaste-priv` action, `nginx -t`,
|
|
19
|
+
* a reload). Each takes well under a second; a hung one must fail — and roll
|
|
20
|
+
* the vhost back — rather than hold up the caller, which at startup is the
|
|
21
|
+
* panel before it listens.
|
|
22
|
+
*/
|
|
23
|
+
const NGINX_COMMAND_TIMEOUT_MS = 60_000;
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Run one `lamaste-priv nginx-site` action.
|
|
19
27
|
*
|
|
@@ -24,6 +32,7 @@ const PRIV = '/usr/local/sbin/lamaste-priv';
|
|
|
24
32
|
async function siteAction(action, name, content) {
|
|
25
33
|
try {
|
|
26
34
|
await execa('sudo', [PRIV, 'nginx-site', action, name], {
|
|
35
|
+
timeout: NGINX_COMMAND_TIMEOUT_MS,
|
|
27
36
|
...(content === undefined ? {} : { input: content }),
|
|
28
37
|
});
|
|
29
38
|
} catch (err) {
|
|
@@ -242,11 +251,75 @@ server {
|
|
|
242
251
|
return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-panel-domain');
|
|
243
252
|
}
|
|
244
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A hostname written into a vhost as a literal. Where it names the vhost to
|
|
256
|
+
* an authorization subrequest it must stay a literal — a `$` would make nginx
|
|
257
|
+
* expand a variable there — so it is checked here, not trusted to the
|
|
258
|
+
* callers' validation.
|
|
259
|
+
*/
|
|
260
|
+
const LITERAL_HOSTNAME_RE =
|
|
261
|
+
/^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
|
|
262
|
+
|
|
263
|
+
function assertLiteralHostname(name) {
|
|
264
|
+
if (typeof name !== 'string' || !LITERAL_HOSTNAME_RE.test(name)) {
|
|
265
|
+
throw new Error(`Invalid hostname for nginx vhost: ${name}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The URL an authorization subrequest asks about: the vhost's own hostname,
|
|
271
|
+
* written into the configuration, and the request's path and query.
|
|
272
|
+
*
|
|
273
|
+
* Never `$http_host`. nginx selects the vhost from the request line when the
|
|
274
|
+
* request is in absolute form (`GET https://a.example.com/x HTTP/1.1`) and
|
|
275
|
+
* from `Host` otherwise, so a client can send the request line of one
|
|
276
|
+
* hostname with the `Host` header of another: built from `$http_host`, the
|
|
277
|
+
* subrequest asked Authelia (and Gatekeeper) about the other hostname while
|
|
278
|
+
* nginx served this one. `$host` is no better — on the default server it is
|
|
279
|
+
* whatever `Host` the client sent. Every vhost here listens on 443 with TLS
|
|
280
|
+
* only, so the scheme is fixed too. `$request_uri` is what follows the host in
|
|
281
|
+
* the request target — it starts with `/`, or with `?` for an absolute-form
|
|
282
|
+
* target without a path — so it cannot change the URL's host.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} fqdn
|
|
285
|
+
*/
|
|
286
|
+
function originalUrl(fqdn) {
|
|
287
|
+
assertLiteralHostname(fqdn);
|
|
288
|
+
return `https://${fqdn}$request_uri`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Cleared in every authorization subrequest. Each names a host, a scheme or a
|
|
293
|
+
* path, and the client's values would otherwise reach Gatekeeper and
|
|
294
|
+
* Authelia; neither decides by them today, and neither may.
|
|
295
|
+
*/
|
|
296
|
+
const AUTHZ_CLEARED_HEADERS = `
|
|
297
|
+
proxy_set_header X-Forwarded-Host "";
|
|
298
|
+
proxy_set_header X-Forwarded-Proto "";
|
|
299
|
+
proxy_set_header X-Forwarded-Uri "";
|
|
300
|
+
proxy_set_header X-Forwarded-Method "";`;
|
|
301
|
+
|
|
245
302
|
/**
|
|
246
303
|
* Write the Authelia auth portal vhost.
|
|
247
304
|
*/
|
|
248
305
|
export async function writeAuthVhost(domain) {
|
|
306
|
+
const { name, config } = renderAuthVhost(domain);
|
|
307
|
+
await writeVhostFile(name, config);
|
|
308
|
+
return path.join(SITES_AVAILABLE, name);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Render the Authelia auth portal vhost. Pure; the startup reconcile renders
|
|
313
|
+
* it on the certificate directory the installed vhost names.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} domain
|
|
316
|
+
* @param {string} [certPath] - certificate directory (default: the portal's own lineage)
|
|
317
|
+
* @returns {{ name: string, fqdn: string, config: string }}
|
|
318
|
+
*/
|
|
319
|
+
export function renderAuthVhost(domain, certPath) {
|
|
249
320
|
const fqdn = `auth.${domain}`;
|
|
321
|
+
let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
322
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
250
323
|
const config = `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
|
|
251
324
|
limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
|
|
252
325
|
|
|
@@ -254,8 +327,8 @@ server {
|
|
|
254
327
|
listen 443 ssl;
|
|
255
328
|
server_name ${fqdn};
|
|
256
329
|
|
|
257
|
-
ssl_certificate
|
|
258
|
-
ssl_certificate_key
|
|
330
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
331
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
259
332
|
|
|
260
333
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
261
334
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
@@ -292,8 +365,9 @@ server {
|
|
|
292
365
|
proxy_set_header Content-Length "";
|
|
293
366
|
proxy_set_header Connection "";
|
|
294
367
|
proxy_set_header X-Original-Method $request_method;
|
|
295
|
-
|
|
296
|
-
proxy_set_header X-
|
|
368
|
+
# This vhost's hostname, never the client's Host header
|
|
369
|
+
proxy_set_header X-Original-URL ${originalUrl(fqdn)};
|
|
370
|
+
proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
|
|
297
371
|
|
|
298
372
|
proxy_http_version 1.1;
|
|
299
373
|
proxy_buffers 4 32k;
|
|
@@ -331,8 +405,7 @@ server {
|
|
|
331
405
|
}
|
|
332
406
|
`;
|
|
333
407
|
|
|
334
|
-
|
|
335
|
-
return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-auth');
|
|
408
|
+
return { name: 'lamalibre-lamaste-auth', fqdn, config };
|
|
336
409
|
}
|
|
337
410
|
|
|
338
411
|
/**
|
|
@@ -429,6 +502,14 @@ export async function writePublicVhost(
|
|
|
429
502
|
certPath,
|
|
430
503
|
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
431
504
|
) {
|
|
505
|
+
const { name, fqdn, config } = renderPublicVhost(subdomain, domain, port, certPath, {
|
|
506
|
+
pathPrefix,
|
|
507
|
+
maxBodySizeMb,
|
|
508
|
+
});
|
|
509
|
+
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb } = {}) {
|
|
432
513
|
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
433
514
|
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
434
515
|
}
|
|
@@ -455,7 +536,14 @@ ${bodySizeDirective(maxBodySizeMb)}
|
|
|
455
536
|
proxy_set_header Remote-User "";
|
|
456
537
|
proxy_set_header Remote-Groups "";
|
|
457
538
|
proxy_set_header Remote-Name "";
|
|
458
|
-
proxy_set_header Remote-Email ""
|
|
539
|
+
proxy_set_header Remote-Email "";${
|
|
540
|
+
pathPrefix
|
|
541
|
+
? `
|
|
542
|
+
# Nor a relay assertion: lamaste-agentd, which serves plugin routes,
|
|
543
|
+
# would otherwise be handed one a client sent.
|
|
544
|
+
proxy_set_header X-Lamalibre-Relay-Assertion "";`
|
|
545
|
+
: ''
|
|
546
|
+
}
|
|
459
547
|
${TUNNEL_PROXY_HEADERS}
|
|
460
548
|
|
|
461
549
|
${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
|
|
@@ -467,8 +555,7 @@ ${TUNNEL_PROXY_HEADERS}
|
|
|
467
555
|
}
|
|
468
556
|
`;
|
|
469
557
|
|
|
470
|
-
|
|
471
|
-
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
558
|
+
return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
|
|
472
559
|
}
|
|
473
560
|
|
|
474
561
|
/**
|
|
@@ -483,22 +570,11 @@ export async function writeAuthenticatedVhost(
|
|
|
483
570
|
certPath,
|
|
484
571
|
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
485
572
|
) {
|
|
486
|
-
|
|
487
|
-
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const fqdn = `${subdomain}.${domain}`;
|
|
491
|
-
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
492
|
-
let certDirClean = certDir;
|
|
493
|
-
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
494
|
-
|
|
495
|
-
const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
|
|
573
|
+
const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
496
574
|
pathPrefix,
|
|
497
575
|
maxBodySizeMb,
|
|
498
576
|
restricted: false,
|
|
499
577
|
});
|
|
500
|
-
|
|
501
|
-
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
502
578
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
503
579
|
}
|
|
504
580
|
|
|
@@ -513,6 +589,21 @@ export async function writeRestrictedVhost(
|
|
|
513
589
|
port,
|
|
514
590
|
certPath,
|
|
515
591
|
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
592
|
+
) {
|
|
593
|
+
const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
594
|
+
pathPrefix,
|
|
595
|
+
maxBodySizeMb,
|
|
596
|
+
restricted: true,
|
|
597
|
+
});
|
|
598
|
+
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function renderGatekeeperTunnelVhost(
|
|
602
|
+
subdomain,
|
|
603
|
+
domain,
|
|
604
|
+
port,
|
|
605
|
+
certPath,
|
|
606
|
+
{ pathPrefix, maxBodySizeMb, restricted },
|
|
516
607
|
) {
|
|
517
608
|
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
518
609
|
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
@@ -526,11 +617,30 @@ export async function writeRestrictedVhost(
|
|
|
526
617
|
const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
|
|
527
618
|
pathPrefix,
|
|
528
619
|
maxBodySizeMb,
|
|
529
|
-
restricted
|
|
620
|
+
restricted,
|
|
530
621
|
});
|
|
531
622
|
|
|
532
|
-
|
|
533
|
-
|
|
623
|
+
return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Render the vhost of an app or plugin tunnel — what the tunnel workflows
|
|
628
|
+
* write for `accessMode`. Pure; used to compare an installed vhost with what
|
|
629
|
+
* the generators produce today (see vhost-reconcile.js).
|
|
630
|
+
*
|
|
631
|
+
* @param {'public'|'authenticated'|'restricted'} accessMode
|
|
632
|
+
* @returns {{ name: string, fqdn: string, config: string }}
|
|
633
|
+
*/
|
|
634
|
+
export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath, opts = {}) {
|
|
635
|
+
const { pathPrefix, maxBodySizeMb } = opts;
|
|
636
|
+
if (accessMode === 'public') {
|
|
637
|
+
return renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb });
|
|
638
|
+
}
|
|
639
|
+
return renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
640
|
+
pathPrefix,
|
|
641
|
+
maxBodySizeMb,
|
|
642
|
+
restricted: accessMode !== 'authenticated',
|
|
643
|
+
});
|
|
534
644
|
}
|
|
535
645
|
|
|
536
646
|
/**
|
|
@@ -538,6 +648,16 @@ export async function writeRestrictedVhost(
|
|
|
538
648
|
* Both 'authenticated' and 'restricted' modes use this — the difference
|
|
539
649
|
* is handled server-side by Gatekeeper based on the tunnel's accessMode.
|
|
540
650
|
* For restricted mode, the 403 body from Gatekeeper is served inline.
|
|
651
|
+
*
|
|
652
|
+
* The subrequest names the tunnel in its path, `/authz/tunnel/<fqdn>`,
|
|
653
|
+
* written here as the agent-panel vhost writes its agent's label, and
|
|
654
|
+
* `X-Original-URL` and `Host` carry the same hostname (see
|
|
655
|
+
* {@link originalUrl}). Vhosts before 4.3 called `/authz/check` with both
|
|
656
|
+
* built from the client's `Host`, so an Authelia user allowed on one tunnel
|
|
657
|
+
* reached any other — the request line named the tunnel nginx served, `Host`
|
|
658
|
+
* the one Gatekeeper authorized. Gatekeeper now refuses `/authz/check`: a
|
|
659
|
+
* vhost the panel has not rewritten closes its tunnel instead of opening it.
|
|
660
|
+
* The login redirect names this vhost for the same reason.
|
|
541
661
|
*/
|
|
542
662
|
function buildGatekeeperVhost(
|
|
543
663
|
fqdn,
|
|
@@ -546,6 +666,7 @@ function buildGatekeeperVhost(
|
|
|
546
666
|
domain,
|
|
547
667
|
{ pathPrefix, maxBodySizeMb, restricted } = {},
|
|
548
668
|
) {
|
|
669
|
+
const url = originalUrl(fqdn);
|
|
549
670
|
return `server {
|
|
550
671
|
listen 443 ssl;
|
|
551
672
|
server_name ${fqdn};
|
|
@@ -557,19 +678,20 @@ function buildGatekeeperVhost(
|
|
|
557
678
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
558
679
|
ssl_prefer_server_ciphers on;
|
|
559
680
|
${bodySizeDirective(maxBodySizeMb)}
|
|
560
|
-
# Gatekeeper authorization subrequest (handles Authelia validation + grant check)
|
|
681
|
+
# Gatekeeper authorization subrequest (handles Authelia validation + grant check).
|
|
682
|
+
# The tunnel is named in the path — never by the client's Host header.
|
|
561
683
|
location /internal/lamaste/authz {
|
|
562
684
|
internal;
|
|
563
685
|
|
|
564
|
-
proxy_pass http://127.0.0.1:9294/authz/
|
|
686
|
+
proxy_pass http://127.0.0.1:9294/authz/tunnel/${fqdn};
|
|
565
687
|
proxy_pass_request_body off;
|
|
566
688
|
|
|
567
689
|
proxy_set_header Content-Length "";
|
|
568
690
|
proxy_set_header Connection "";
|
|
569
691
|
proxy_set_header X-Original-Method $request_method;
|
|
570
|
-
proxy_set_header X-Original-URL $
|
|
571
|
-
proxy_set_header X-Forwarded-For $remote_addr
|
|
572
|
-
proxy_set_header Host $
|
|
692
|
+
proxy_set_header X-Original-URL ${url};
|
|
693
|
+
proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
|
|
694
|
+
proxy_set_header Host ${fqdn};
|
|
573
695
|
proxy_set_header Cookie $http_cookie;
|
|
574
696
|
|
|
575
697
|
proxy_http_version 1.1;
|
|
@@ -582,12 +704,25 @@ ${bodySizeDirective(maxBodySizeMb)}
|
|
|
582
704
|
auth_request_set $user $upstream_http_remote_user;
|
|
583
705
|
auth_request_set $groups $upstream_http_remote_groups;
|
|
584
706
|
auth_request_set $name $upstream_http_remote_name;
|
|
585
|
-
auth_request_set $email $upstream_http_remote_email
|
|
707
|
+
auth_request_set $email $upstream_http_remote_email;${
|
|
708
|
+
pathPrefix
|
|
709
|
+
? `
|
|
710
|
+
auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;`
|
|
711
|
+
: ''
|
|
712
|
+
}
|
|
586
713
|
|
|
587
714
|
proxy_set_header Remote-User $user;
|
|
588
715
|
proxy_set_header Remote-Groups $groups;
|
|
589
716
|
proxy_set_header Remote-Name $name;
|
|
590
|
-
proxy_set_header Remote-Email $email
|
|
717
|
+
proxy_set_header Remote-Email $email;${
|
|
718
|
+
pathPrefix
|
|
719
|
+
? `
|
|
720
|
+
|
|
721
|
+
# The user, signed by Gatekeeper for this one request — lamaste-agentd,
|
|
722
|
+
# which serves plugin routes, trusts no Remote-* header without it
|
|
723
|
+
proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;`
|
|
724
|
+
: ''
|
|
725
|
+
}
|
|
591
726
|
${TUNNEL_PROXY_HEADERS}
|
|
592
727
|
|
|
593
728
|
${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
|
|
@@ -596,8 +731,8 @@ ${TUNNEL_PROXY_HEADERS}
|
|
|
596
731
|
proxy_read_timeout 86400s;
|
|
597
732
|
proxy_send_timeout 86400s;
|
|
598
733
|
|
|
599
|
-
# Authentication failure -> redirect to Authelia login
|
|
600
|
-
error_page 401 =302 https://auth.${domain}/?rd=$
|
|
734
|
+
# Authentication failure -> redirect to Authelia login, back to this vhost
|
|
735
|
+
error_page 401 =302 https://auth.${domain}/?rd=${url};
|
|
601
736
|
|
|
602
737
|
${
|
|
603
738
|
restricted
|
|
@@ -611,7 +746,7 @@ ${TUNNEL_PROXY_HEADERS}
|
|
|
611
746
|
}
|
|
612
747
|
|
|
613
748
|
/** True when `name` is linked into sites-enabled. The directory is world-readable. */
|
|
614
|
-
async function isSiteEnabled(name) {
|
|
749
|
+
export async function isSiteEnabled(name) {
|
|
615
750
|
try {
|
|
616
751
|
await lstat(path.join(SITES_ENABLED, name));
|
|
617
752
|
return true;
|
|
@@ -703,6 +838,24 @@ async function safeWriteVhost(name, config, fqdn, { enabled = true } = {}) {
|
|
|
703
838
|
* @param {string} [domain] - Base domain (needed for Authelia redirect URL)
|
|
704
839
|
*/
|
|
705
840
|
export async function writeStaticSiteVhost(site, certDir, domain) {
|
|
841
|
+
const { name, fqdn, config } = renderStaticSiteVhost(site, certDir, domain);
|
|
842
|
+
return safeWriteVhost(name, config, fqdn, { enabled: true });
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Render a static site vhost. Pure; the startup reconcile renders it on the
|
|
847
|
+
* certificate directory the installed vhost names.
|
|
848
|
+
*
|
|
849
|
+
* Authelia decides by the hostname of `X-Original-URL` — a site restricted to
|
|
850
|
+
* some users has rules for its own hostname only — so the subrequest names
|
|
851
|
+
* this site, never the client's `Host` (see {@link originalUrl}).
|
|
852
|
+
*
|
|
853
|
+
* @param {object} site - as for writeStaticSiteVhost
|
|
854
|
+
* @param {string} certDir
|
|
855
|
+
* @param {string} [domain]
|
|
856
|
+
* @returns {{ name: string, fqdn: string, config: string }}
|
|
857
|
+
*/
|
|
858
|
+
export function renderStaticSiteVhost(site, certDir, domain) {
|
|
706
859
|
let certDirClean = certDir;
|
|
707
860
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
708
861
|
const aliasBlock = buildAliasRedirectBlock(site, certDirClean);
|
|
@@ -723,8 +876,9 @@ export async function writeStaticSiteVhost(site, certDir, domain) {
|
|
|
723
876
|
proxy_set_header Content-Length "";
|
|
724
877
|
proxy_set_header Connection "";
|
|
725
878
|
proxy_set_header X-Original-Method $request_method;
|
|
726
|
-
|
|
727
|
-
proxy_set_header X-
|
|
879
|
+
# This site's hostname, never the client's Host header
|
|
880
|
+
proxy_set_header X-Original-URL ${originalUrl(site.fqdn)};
|
|
881
|
+
proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
|
|
728
882
|
|
|
729
883
|
proxy_http_version 1.1;
|
|
730
884
|
proxy_buffers 4 32k;
|
|
@@ -779,8 +933,7 @@ ${
|
|
|
779
933
|
}
|
|
780
934
|
${aliasBlock}`;
|
|
781
935
|
|
|
782
|
-
|
|
783
|
-
return safeWriteVhost(name, config, site.fqdn, { enabled: true });
|
|
936
|
+
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
|
|
784
937
|
}
|
|
785
938
|
|
|
786
939
|
/**
|
|
@@ -861,7 +1014,7 @@ export async function removeAppVhost(subdomain) {
|
|
|
861
1014
|
*/
|
|
862
1015
|
export async function testConfig() {
|
|
863
1016
|
try {
|
|
864
|
-
await execa('sudo', ['nginx', '-t']);
|
|
1017
|
+
await execa('sudo', ['nginx', '-t'], { timeout: NGINX_COMMAND_TIMEOUT_MS });
|
|
865
1018
|
return { valid: true };
|
|
866
1019
|
} catch (err) {
|
|
867
1020
|
return { valid: false, error: err.stderr || err.message };
|
|
@@ -873,7 +1026,7 @@ export async function testConfig() {
|
|
|
873
1026
|
*/
|
|
874
1027
|
export async function reload() {
|
|
875
1028
|
try {
|
|
876
|
-
await execa('sudo', ['systemctl', 'reload', 'nginx']);
|
|
1029
|
+
await execa('sudo', ['systemctl', 'reload', 'nginx'], { timeout: NGINX_COMMAND_TIMEOUT_MS });
|
|
877
1030
|
return { reloaded: true };
|
|
878
1031
|
} catch (err) {
|
|
879
1032
|
throw new Error(`Failed to reload nginx: ${err.stderr || err.message}`);
|
|
@@ -977,6 +1130,40 @@ export async function writeAgentPanelVhost(
|
|
|
977
1130
|
certPath,
|
|
978
1131
|
{ enabled = true } = {},
|
|
979
1132
|
) {
|
|
1133
|
+
const { name, fqdn, config } = renderAgentPanelVhost(subdomain, domain, port, certPath);
|
|
1134
|
+
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Render an agent panel vhost. Pure.
|
|
1139
|
+
*
|
|
1140
|
+
* nginx verifies the browser's client certificate, then asks the panel
|
|
1141
|
+
* whether the request may reach the agent (`auth_request` to
|
|
1142
|
+
* `/api/agent-panel/authz/<label>`, with the certificate's verification
|
|
1143
|
+
* result, the method and `$request_uri`). The panel refuses a revoked or
|
|
1144
|
+
* foreign certificate and otherwise signs a relay assertion for exactly this
|
|
1145
|
+
* request, which goes to the agent's lamaste-agentd in
|
|
1146
|
+
* `X-Lamalibre-Relay-Assertion`. The daemon accepts nothing else from the
|
|
1147
|
+
* relay: its port, and the tunnel's port on the relay's loopback, can be
|
|
1148
|
+
* reached without going through nginx, so a header proves nothing to it.
|
|
1149
|
+
*
|
|
1150
|
+
* `X-SSL-Client-*` still go upstream for agents older than 4.3, which read
|
|
1151
|
+
* them; lamaste-agentd from 4.3 removes them unread.
|
|
1152
|
+
*
|
|
1153
|
+
* @param {string} subdomain - `agent-<label>`
|
|
1154
|
+
* @param {string} domain
|
|
1155
|
+
* @param {number} port
|
|
1156
|
+
* @param {string} [certPath]
|
|
1157
|
+
* @returns {{ name: string, fqdn: string, config: string }}
|
|
1158
|
+
*/
|
|
1159
|
+
export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
|
|
1160
|
+
if (!/^agent-[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) {
|
|
1161
|
+
throw new Error(`Invalid agent panel subdomain for nginx vhost: ${subdomain}`);
|
|
1162
|
+
}
|
|
1163
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1164
|
+
throw new Error(`Invalid port for nginx vhost: ${port}`);
|
|
1165
|
+
}
|
|
1166
|
+
const label = subdomain.slice('agent-'.length);
|
|
980
1167
|
const fqdn = `${subdomain}.${domain}`;
|
|
981
1168
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
982
1169
|
let certDirClean = certDir;
|
|
@@ -996,16 +1183,50 @@ export async function writeAgentPanelVhost(
|
|
|
996
1183
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
997
1184
|
ssl_prefer_server_ciphers on;
|
|
998
1185
|
|
|
1186
|
+
# Authorization by the panel: refuses revoked and foreign certificates,
|
|
1187
|
+
# otherwise answers with the relay assertion for this one request
|
|
1188
|
+
location = /internal/lamaste/agent-panel-authz {
|
|
1189
|
+
internal;
|
|
1190
|
+
|
|
1191
|
+
proxy_pass http://127.0.0.1:3100/api/agent-panel/authz/${label};
|
|
1192
|
+
proxy_pass_request_body off;
|
|
1193
|
+
proxy_http_version 1.1;
|
|
1194
|
+
|
|
1195
|
+
proxy_set_header Content-Length "";
|
|
1196
|
+
proxy_set_header Connection "";
|
|
1197
|
+
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
|
1198
|
+
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
|
|
1199
|
+
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
|
|
1200
|
+
proxy_set_header X-Original-Method $request_method;
|
|
1201
|
+
proxy_set_header X-Original-URI $request_uri;
|
|
1202
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
1203
|
+
|
|
1204
|
+
# Nothing else of the browser's request is the panel's business
|
|
1205
|
+
proxy_set_header Cookie "";
|
|
1206
|
+
proxy_set_header Authorization "";
|
|
1207
|
+
proxy_set_header Remote-User "";
|
|
1208
|
+
proxy_set_header Remote-Groups "";
|
|
1209
|
+
proxy_set_header Remote-Name "";
|
|
1210
|
+
proxy_set_header Remote-Email "";
|
|
1211
|
+
}
|
|
1212
|
+
|
|
999
1213
|
# Reject requests without valid client certificate
|
|
1000
1214
|
location / {
|
|
1001
1215
|
if ($ssl_client_verify != SUCCESS) {
|
|
1002
1216
|
return 496;
|
|
1003
1217
|
}
|
|
1004
1218
|
|
|
1219
|
+
auth_request /internal/lamaste/agent-panel-authz;
|
|
1220
|
+
auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
|
|
1221
|
+
|
|
1005
1222
|
proxy_pass http://127.0.0.1:${port};
|
|
1006
1223
|
proxy_http_version 1.1;
|
|
1007
1224
|
|
|
1008
|
-
#
|
|
1225
|
+
# The caller, signed by the panel for this one request
|
|
1226
|
+
proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
|
|
1227
|
+
|
|
1228
|
+
# Client cert headers — set from nginx TLS variables, never passed through
|
|
1229
|
+
# from client; read only by agents older than 4.3
|
|
1009
1230
|
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
|
1010
1231
|
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
|
|
1011
1232
|
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
|
|
@@ -1031,10 +1252,43 @@ export async function writeAgentPanelVhost(
|
|
|
1031
1252
|
}
|
|
1032
1253
|
`;
|
|
1033
1254
|
|
|
1034
|
-
|
|
1035
|
-
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
1255
|
+
return { name: `lamalibre-lamaste-agent-panel-${subdomain}`, fqdn, config };
|
|
1036
1256
|
}
|
|
1037
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* The installed sites-available/<name>, or null when there is none. The
|
|
1260
|
+
* directory is world-readable.
|
|
1261
|
+
*
|
|
1262
|
+
* @param {string} name
|
|
1263
|
+
* @returns {Promise<string | null>}
|
|
1264
|
+
*/
|
|
1265
|
+
export async function readInstalledVhost(name) {
|
|
1266
|
+
try {
|
|
1267
|
+
return await readFile(path.join(SITES_AVAILABLE, name), 'utf8');
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
if (err.code === 'ENOENT') return null;
|
|
1270
|
+
throw err;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* The certificate directory a vhost's `ssl_certificate` names, without the
|
|
1276
|
+
* trailing slash — so a vhost can be rendered again on the certificate it was
|
|
1277
|
+
* written for (a tunnel may be on a wildcard certificate). Null when the
|
|
1278
|
+
* vhost names none.
|
|
1279
|
+
*
|
|
1280
|
+
* @param {string} config
|
|
1281
|
+
* @returns {string | null}
|
|
1282
|
+
*/
|
|
1283
|
+
export function vhostCertDir(config) {
|
|
1284
|
+
const match = /^\s*ssl_certificate\s+(\S+)\/fullchain\.pem;/m.exec(config);
|
|
1285
|
+
return match ? match[1] : null;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Install a rendered vhost (vhost-reconcile.js) through the same backup /
|
|
1289
|
+
// write / link state / test / reload / rollback path as every vhost write.
|
|
1290
|
+
export { safeWriteVhost as installVhost };
|
|
1291
|
+
|
|
1038
1292
|
/**
|
|
1039
1293
|
* Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
1040
1294
|
* Idempotent: if files don't exist, proceeds silently.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Panel notifications — messages for the relay's administrators: the
|
|
2
|
+
// `notify-admins` effect of plugins' lifecycle hooks (rate-limited by the
|
|
3
|
+
// effects executor) and Lamaste's own events (an approval waiting, a rule
|
|
4
|
+
// pack updated). Stored in state.db; read state is shared because the panel
|
|
5
|
+
// has one administrator identity. Plain text only — the panel renders it as
|
|
6
|
+
// text, never as HTML.
|
|
7
|
+
|
|
8
|
+
import { getStateDb } from './state-db.js';
|
|
9
|
+
|
|
10
|
+
export const NOTIFICATION_LEVELS = ['info', 'warning', 'critical'];
|
|
11
|
+
export const NOTIFICATION_MESSAGE_MAX = 500;
|
|
12
|
+
export const NOTIFICATIONS_MAX_ROWS = 1000;
|
|
13
|
+
export const NOTIFICATIONS_RETENTION_DAYS = 30;
|
|
14
|
+
|
|
15
|
+
const SOURCE_RE = /^[A-Za-z0-9:._-]{1,120}$/;
|
|
16
|
+
// eslint-disable-next-line no-control-regex
|
|
17
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]+/g;
|
|
18
|
+
|
|
19
|
+
function cleanMessage(message) {
|
|
20
|
+
// Control characters out (a message is one line of text), length capped.
|
|
21
|
+
const text = String(message).replace(CONTROL_CHARS, ' ').trim();
|
|
22
|
+
return text.length > NOTIFICATION_MESSAGE_MAX
|
|
23
|
+
? `${text.slice(0, NOTIFICATION_MESSAGE_MAX - 1)}…`
|
|
24
|
+
: text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ source: string, level?: 'info'|'warning'|'critical', message: string }} n
|
|
29
|
+
* `source` — `plugin:<route>` or `lamaste`.
|
|
30
|
+
*/
|
|
31
|
+
export async function notifyAdmins({ source, level = 'info', message }) {
|
|
32
|
+
if (!SOURCE_RE.test(source)) throw new Error('Invalid notification source');
|
|
33
|
+
if (!NOTIFICATION_LEVELS.includes(level)) throw new Error('Invalid notification level');
|
|
34
|
+
const text = cleanMessage(message);
|
|
35
|
+
if (text.length === 0) throw new Error('Empty notification');
|
|
36
|
+
const db = await getStateDb();
|
|
37
|
+
const at = new Date().toISOString();
|
|
38
|
+
const result = db
|
|
39
|
+
.prepare('INSERT INTO notifications (at, source, level, message) VALUES (?, ?, ?, ?)')
|
|
40
|
+
.run(at, source, level, text);
|
|
41
|
+
const cutoff = new Date(Date.now() - NOTIFICATIONS_RETENTION_DAYS * 86_400_000).toISOString();
|
|
42
|
+
db.prepare('DELETE FROM notifications WHERE at < ?').run(cutoff);
|
|
43
|
+
db.prepare(
|
|
44
|
+
'DELETE FROM notifications WHERE id <= (SELECT id FROM notifications ORDER BY id DESC LIMIT 1 OFFSET ?)',
|
|
45
|
+
).run(NOTIFICATIONS_MAX_ROWS);
|
|
46
|
+
return { id: Number(result.lastInsertRowid), at };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function notifyAdminsBestEffort(notification, logger) {
|
|
50
|
+
try {
|
|
51
|
+
return await notifyAdmins(notification);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger?.error?.({ err: err.message }, 'Failed to store a notification');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rowToNotification(row) {
|
|
59
|
+
return {
|
|
60
|
+
id: row.id,
|
|
61
|
+
at: row.at,
|
|
62
|
+
source: row.source,
|
|
63
|
+
level: row.level,
|
|
64
|
+
message: row.message,
|
|
65
|
+
read: row.read_at !== null,
|
|
66
|
+
readAt: row.read_at,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Newest first, with the unread count. */
|
|
71
|
+
export async function listNotifications({ unreadOnly = false, before, limit = 50 } = {}) {
|
|
72
|
+
const db = await getStateDb();
|
|
73
|
+
const where = [];
|
|
74
|
+
const args = [];
|
|
75
|
+
if (unreadOnly) where.push('read_at IS NULL');
|
|
76
|
+
if (before) {
|
|
77
|
+
where.push('id < ?');
|
|
78
|
+
args.push(before);
|
|
79
|
+
}
|
|
80
|
+
args.push(Math.min(Math.max(1, limit), 200));
|
|
81
|
+
const rows = db
|
|
82
|
+
.prepare(
|
|
83
|
+
`SELECT id, at, source, level, message, read_at FROM notifications${
|
|
84
|
+
where.length ? ` WHERE ${where.join(' AND ')}` : ''
|
|
85
|
+
} ORDER BY id DESC LIMIT ?`,
|
|
86
|
+
)
|
|
87
|
+
.all(...args);
|
|
88
|
+
const { unread } = db
|
|
89
|
+
.prepare('SELECT COUNT(*) AS unread FROM notifications WHERE read_at IS NULL')
|
|
90
|
+
.get();
|
|
91
|
+
return { notifications: rows.map(rowToNotification), unread: Number(unread) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mark the given ids — or every unread notification — read. Returns how many changed. */
|
|
95
|
+
export async function markNotificationsRead({ ids, all = false }) {
|
|
96
|
+
const db = await getStateDb();
|
|
97
|
+
const at = new Date().toISOString();
|
|
98
|
+
if (all) {
|
|
99
|
+
return Number(
|
|
100
|
+
db.prepare('UPDATE notifications SET read_at = ? WHERE read_at IS NULL').run(at).changes,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
let changed = 0;
|
|
104
|
+
const stmt = db.prepare('UPDATE notifications SET read_at = ? WHERE id = ? AND read_at IS NULL');
|
|
105
|
+
for (const id of ids ?? []) changed += Number(stmt.run(at, id).changes);
|
|
106
|
+
return changed;
|
|
107
|
+
}
|