@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.
- package/package.json +2 -2
- package/scripts/bundle-vendor.js +20 -3
- package/scripts/lamaste-priv +56 -5
- package/scripts/pin-release.js +98 -0
- package/src/index.js +8 -1
- package/src/lib/env.js +19 -0
- package/src/lib/service-config.js +7 -0
- package/src/tasks/redeploy.js +8 -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 -2
- package/vendor/docs/01-concepts/authorization.md +4 -0
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +28 -3
- 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 +5 -5
- 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/managing-users.md +5 -11
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/second-domain.md +82 -0
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/02-guides/static-sites.md +4 -0
- 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 +80 -12
- package/vendor/docs/03-architecture/overview.md +5 -5
- 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 +3 -3
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/domains.md +192 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +9 -0
- package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
- package/vendor/docs/04-api-reference/sites.md +9 -0
- package/vendor/docs/04-api-reference/tunnels.md +35 -28
- package/vendor/docs/04-api-reference/users.md +41 -29
- 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 +15 -6
- package/vendor/docs/06-reference/config-files.md +19 -15
- 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 +8 -8
- package/vendor/docs/_index.json +105 -19
- package/vendor/docs/external-members-design.md +816 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +3 -3
- package/vendor/docs/multi-domain-relay-design.md +836 -0
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/index.d.ts +3 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -2
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.js +31 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.js +5 -2
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +20 -13
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/secret.js +12 -0
- package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/templates.js +80 -14
- package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +56 -11
- 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 +325 -55
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +7 -0
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/domains.js +214 -0
- package/vendor/server/src/commands/plugin-identity.js +2 -16
- package/vendor/server/src/commands/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +175 -14
- package/vendor/server/src/commands/registry-credential.js +10 -3
- package/vendor/server/src/commands/sites.js +18 -1
- package/vendor/server/src/commands/status.js +19 -0
- package/vendor/server/src/commands/tunnels.js +4 -1
- package/vendor/server/src/commands/users.js +8 -19
- package/vendor/server/src/domains.js +92 -0
- package/vendor/server/src/fail.js +21 -0
- package/vendor/server/src/oidc-client-grants.js +63 -0
- package/vendor/server/src/panel-api.js +15 -0
- package/vendor/server/src/plugin-approval.js +80 -1
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
- package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +50 -19
- package/vendor/serverd/src/lib/app-error.js +24 -0
- package/vendor/serverd/src/lib/audience.js +12 -3
- package/vendor/serverd/src/lib/authelia.js +341 -35
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- package/vendor/serverd/src/lib/directory.js +67 -52
- package/vendor/serverd/src/lib/domains.js +1792 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
- package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
- package/vendor/serverd/src/lib/identity-sync.js +35 -0
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
- package/vendor/serverd/src/lib/nginx.js +576 -131
- package/vendor/serverd/src/lib/owned-groups.js +326 -0
- package/vendor/serverd/src/lib/plugin-domain.js +86 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
- package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
- package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +82 -9
- package/vendor/serverd/src/lib/registry-credential.js +39 -6
- package/vendor/serverd/src/lib/services.js +9 -1
- package/vendor/serverd/src/lib/state.js +29 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
- package/vendor/serverd/src/lib/user-surface.js +77 -31
- package/vendor/serverd/src/lib/users-lock.js +23 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
- package/vendor/serverd/src/lib/web-surface.js +783 -0
- package/vendor/serverd/src/middleware/user-access-session.js +11 -4
- package/vendor/serverd/src/routes/invite.js +15 -5
- package/vendor/serverd/src/routes/management/domains.js +154 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
- package/vendor/serverd/src/routes/management/identity.js +21 -7
- package/vendor/serverd/src/routes/management/invitations.js +33 -4
- package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +6 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
- package/vendor/serverd/src/routes/management/sites.js +188 -71
- package/vendor/serverd/src/routes/management/tunnels.js +37 -22
- package/vendor/serverd/src/routes/management/users.js +184 -64
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
- package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
- package/vendor/serverd/src/routes/user-access.js +13 -1
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
RELAY_PANEL_PORT,
|
|
7
7
|
USER_SURFACE_MAX_RESPONSE_SECONDS,
|
|
8
8
|
isUserSurfaceRoute,
|
|
9
|
+
labelUnder,
|
|
9
10
|
userSurfaceApiPath,
|
|
11
|
+
WEB_TREE_CONTENT_TYPES,
|
|
10
12
|
} from '@lamalibre/lamaste';
|
|
11
13
|
|
|
12
14
|
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
@@ -287,6 +289,46 @@ server {
|
|
|
287
289
|
return { name: PANEL_DOMAIN_VHOST, fqdn, config };
|
|
288
290
|
}
|
|
289
291
|
|
|
292
|
+
/** The site-name prefixes of the vhosts named by a hostname (lamaste-priv checks the same). */
|
|
293
|
+
const APP_VHOST_PREFIX = 'lamalibre-lamaste-app-';
|
|
294
|
+
const AGENT_PANEL_VHOST_PREFIX = 'lamalibre-lamaste-agent-panel-';
|
|
295
|
+
const AUTH_VHOST_PREFIX = 'lamalibre-lamaste-auth-';
|
|
296
|
+
|
|
297
|
+
/** Site name of an app or plugin tunnel's vhost: its full hostname. */
|
|
298
|
+
export function appVhostName(fqdn) {
|
|
299
|
+
assertLiteralHostname(fqdn);
|
|
300
|
+
return `${APP_VHOST_PREFIX}${fqdn.toLowerCase()}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Site name of an agent panel tunnel's vhost: its full hostname. */
|
|
304
|
+
export function agentPanelVhostName(fqdn) {
|
|
305
|
+
assertLiteralHostname(fqdn);
|
|
306
|
+
return `${AGENT_PANEL_VHOST_PREFIX}${fqdn.toLowerCase()}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Site names of tunnel vhosts before the multi-domain relay, by subdomain
|
|
311
|
+
* alone (one domain): the startup reconcile renames them
|
|
312
|
+
* (vhost-reconcile.js).
|
|
313
|
+
*/
|
|
314
|
+
export function legacyTunnelVhostName(type, subdomain) {
|
|
315
|
+
return `${type === 'panel' ? AGENT_PANEL_VHOST_PREFIX : APP_VHOST_PREFIX}${subdomain}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The label of tunnel hostname `fqdn` under `domain` — one label, as a
|
|
320
|
+
* tunnel's is — or a refusal.
|
|
321
|
+
*/
|
|
322
|
+
function assertHostUnderDomain(fqdn, domain) {
|
|
323
|
+
assertLiteralHostname(fqdn);
|
|
324
|
+
assertLiteralHostname(domain);
|
|
325
|
+
const label = labelUnder(fqdn, domain);
|
|
326
|
+
if (label === null || label.includes('.')) {
|
|
327
|
+
throw new Error(`Hostname ${fqdn} is not a subdomain of ${domain}`);
|
|
328
|
+
}
|
|
329
|
+
return label;
|
|
330
|
+
}
|
|
331
|
+
|
|
290
332
|
/**
|
|
291
333
|
* A hostname written into a vhost as a literal. Where it names the vhost to
|
|
292
334
|
* an authorization subrequest it must stay a literal — a `$` would make nginx
|
|
@@ -335,61 +377,69 @@ const AUTHZ_CLEARED_HEADERS = `
|
|
|
335
377
|
proxy_set_header X-Forwarded-Uri "";
|
|
336
378
|
proxy_set_header X-Forwarded-Method "";`;
|
|
337
379
|
|
|
380
|
+
/** Site name of the auth portal vhost of `domain` (`auth.<domain>`). */
|
|
381
|
+
export function authVhostName(domain) {
|
|
382
|
+
assertLiteralHostname(domain);
|
|
383
|
+
return `${AUTH_VHOST_PREFIX}${domain.toLowerCase()}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Site name of the auth portal vhost before the multi-domain relay (the primary's alone). */
|
|
387
|
+
export const LEGACY_AUTH_VHOST = 'lamalibre-lamaste-auth';
|
|
388
|
+
|
|
338
389
|
/**
|
|
339
|
-
* Write the Authelia auth portal vhost
|
|
390
|
+
* Write the Authelia auth portal vhost of one of the relay's domains
|
|
391
|
+
* (`primary`: the panel's — see {@link renderAuthVhost}).
|
|
392
|
+
*
|
|
393
|
+
* @param {string} domain
|
|
394
|
+
* @param {{ primary: boolean, enabled?: boolean }} opts
|
|
340
395
|
*/
|
|
341
|
-
export async function writeAuthVhost(domain) {
|
|
342
|
-
const { name, config } = renderAuthVhost(domain);
|
|
343
|
-
await
|
|
396
|
+
export async function writeAuthVhost(domain, { primary, enabled = true }) {
|
|
397
|
+
const { name, config } = renderAuthVhost(domain, undefined, { primary });
|
|
398
|
+
await safeWriteVhost(name, config, `auth.${domain}`, { enabled });
|
|
344
399
|
return path.join(SITES_AVAILABLE, name);
|
|
345
400
|
}
|
|
346
401
|
|
|
347
402
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
403
|
+
* Remove the auth portal vhost of a removed domain, then test and reload:
|
|
404
|
+
* `auth.<domain>` stops being served at once.
|
|
405
|
+
*/
|
|
406
|
+
export async function removeAuthVhost(domain) {
|
|
407
|
+
const name = authVhostName(domain);
|
|
408
|
+
await siteAction('remove', name);
|
|
409
|
+
|
|
410
|
+
const result = await testConfig();
|
|
411
|
+
if (!result.valid) {
|
|
412
|
+
throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await reload();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Render the Authelia auth portal vhost of one domain. Pure; the startup
|
|
420
|
+
* reconcile renders it on the certificate directory the installed vhost
|
|
421
|
+
* names. Every domain's portal serves Authelia and the invitation page; only
|
|
422
|
+
* the primary's — the panel's domain, which the desktop's user login
|
|
423
|
+
* exchanges at — serves `/api/user-access/authorize` and declares its rate
|
|
424
|
+
* limit zone (an http-level name, declared once).
|
|
350
425
|
*
|
|
351
426
|
* @param {string} domain
|
|
352
427
|
* @param {string} [certPath] - certificate directory (default: the portal's own lineage)
|
|
428
|
+
* @param {{ primary?: boolean }} [opts]
|
|
353
429
|
* @returns {{ name: string, fqdn: string, config: string }}
|
|
354
430
|
*/
|
|
355
|
-
export function renderAuthVhost(domain, certPath) {
|
|
431
|
+
export function renderAuthVhost(domain, certPath, { primary = true } = {}) {
|
|
356
432
|
const fqdn = `auth.${domain}`;
|
|
357
433
|
let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
358
434
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
359
|
-
const
|
|
435
|
+
const zone = primary
|
|
436
|
+
? `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
|
|
360
437
|
limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
|
|
361
438
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
367
|
-
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
368
|
-
|
|
369
|
-
ssl_protocols TLSv1.2 TLSv1.3;
|
|
370
|
-
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
371
|
-
ssl_prefer_server_ciphers on;
|
|
372
|
-
|
|
373
|
-
proxy_set_header Host $host;
|
|
374
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
375
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
376
|
-
proxy_set_header X-Forwarded-Proto $scheme;
|
|
377
|
-
|
|
378
|
-
# Invitation page — static HTML
|
|
379
|
-
location /invite/ {
|
|
380
|
-
alias /var/www/lamaste/invite/;
|
|
381
|
-
try_files $uri /invite/index.html;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
# Invitation API — proxied to panel server (no mTLS)
|
|
385
|
-
location /api/invite/ {
|
|
386
|
-
proxy_pass http://127.0.0.1:3100/api/invite/;
|
|
387
|
-
proxy_set_header Host $host;
|
|
388
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
389
|
-
proxy_set_header X-Forwarded-For $remote_addr;
|
|
390
|
-
proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
|
|
391
|
-
}
|
|
392
|
-
|
|
439
|
+
`
|
|
440
|
+
: '';
|
|
441
|
+
const userAccess = primary
|
|
442
|
+
? `
|
|
393
443
|
# User access authorization — Authelia forward auth protects this endpoint
|
|
394
444
|
# so Remote-User header is set by Authelia on successful authentication.
|
|
395
445
|
location /internal/authelia/authz {
|
|
@@ -433,15 +483,55 @@ server {
|
|
|
433
483
|
|
|
434
484
|
error_page 401 =302 $redirection_url;
|
|
435
485
|
}
|
|
486
|
+
`
|
|
487
|
+
: '';
|
|
488
|
+
const config = `${zone}server {
|
|
489
|
+
listen 443 ssl;
|
|
490
|
+
server_name ${fqdn};
|
|
491
|
+
|
|
492
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
493
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
494
|
+
|
|
495
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
496
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
497
|
+
ssl_prefer_server_ciphers on;
|
|
498
|
+
|
|
499
|
+
proxy_set_header Host $host;
|
|
500
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
501
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
502
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
503
|
+
|
|
504
|
+
# Invitation page — static HTML
|
|
505
|
+
location /invite/ {
|
|
506
|
+
alias /var/www/lamaste/invite/;
|
|
507
|
+
try_files $uri /invite/index.html;
|
|
508
|
+
}
|
|
436
509
|
|
|
437
|
-
#
|
|
510
|
+
# Invitation API — proxied to panel server (no mTLS)
|
|
511
|
+
location /api/invite/ {
|
|
512
|
+
proxy_pass http://127.0.0.1:3100/api/invite/;
|
|
513
|
+
proxy_set_header Host $host;
|
|
514
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
515
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
516
|
+
proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
|
|
517
|
+
}
|
|
518
|
+
${userAccess}
|
|
519
|
+
# Default — proxy to Authelia. Authelia takes the OIDC issuer and the
|
|
520
|
+
# session cookie's domain from X-Forwarded-Host / -Proto: this portal's
|
|
521
|
+
# own name, never what the client sent. (A location that sets any
|
|
522
|
+
# header inherits none of the server's.)
|
|
438
523
|
location / {
|
|
439
524
|
proxy_pass http://127.0.0.1:9091;
|
|
525
|
+
proxy_set_header Host ${fqdn};
|
|
526
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
527
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
528
|
+
proxy_set_header X-Forwarded-Host ${fqdn};
|
|
529
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
440
530
|
}
|
|
441
531
|
}
|
|
442
532
|
`;
|
|
443
533
|
|
|
444
|
-
return { name:
|
|
534
|
+
return { name: authVhostName(domain), fqdn, config };
|
|
445
535
|
}
|
|
446
536
|
|
|
447
537
|
/**
|
|
@@ -590,12 +680,14 @@ const USER_SURFACE_PROXY_HEADERS = `
|
|
|
590
680
|
* `X-Original-URL` and in `Host` — never by the client's `Host` header (see
|
|
591
681
|
* {@link originalUrl}) — and the session cookie is passed on.
|
|
592
682
|
*
|
|
593
|
-
* @param {'tunnel'|'user-surface'} check
|
|
683
|
+
* @param {'tunnel'|'user-surface'|'user-surface-shell'} check
|
|
594
684
|
* @param {string} fqdn
|
|
595
685
|
* @returns {string}
|
|
596
686
|
*/
|
|
597
687
|
function gatekeeperSubrequest(check, fqdn) {
|
|
598
688
|
return `internal;
|
|
689
|
+
# A subrequest is never authorized itself (a host may authorize at server level)
|
|
690
|
+
auth_request off;
|
|
599
691
|
|
|
600
692
|
proxy_pass http://127.0.0.1:9294/authz/${check}/${fqdn};
|
|
601
693
|
proxy_pass_request_body off;
|
|
@@ -626,6 +718,11 @@ function gatekeeperSubrequest(check, fqdn) {
|
|
|
626
718
|
* - `/internal/lamaste/user-surface-401` answers a request without a
|
|
627
719
|
* session with Gatekeeper's JSON 401 — an application's request is never
|
|
628
720
|
* redirected to the login page.
|
|
721
|
+
* - `/internal/lamaste/user-surface-403` answers a refused request with its
|
|
722
|
+
* refusal as JSON: under `auth_request` nginx keeps only the subrequest's
|
|
723
|
+
* status and headers, never its body, so Gatekeeper names the refusal in
|
|
724
|
+
* `X-Lamalibre-Refusal` and the location answers `not_a_member` for that
|
|
725
|
+
* value and `forbidden` for anything else.
|
|
629
726
|
*
|
|
630
727
|
* Each route's locations pass the assertion, and nothing else of the
|
|
631
728
|
* browser's credentials ({@link USER_SURFACE_PROXY_HEADERS}), to the panel
|
|
@@ -651,6 +748,7 @@ function userSurfaceLocations(fqdn, userSurface) {
|
|
|
651
748
|
const block = `{
|
|
652
749
|
auth_request /internal/lamaste/user-surface-authz;
|
|
653
750
|
auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
|
|
751
|
+
auth_request_set $lamaste_refusal $upstream_http_x_lamalibre_refusal;
|
|
654
752
|
|
|
655
753
|
# The user, vouched for by Gatekeeper to the panel for this one request
|
|
656
754
|
proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
|
|
@@ -665,15 +763,16 @@ ${USER_SURFACE_PROXY_HEADERS}
|
|
|
665
763
|
proxy_read_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
|
|
666
764
|
proxy_send_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
|
|
667
765
|
|
|
668
|
-
# No session: a JSON 401, never a redirect
|
|
766
|
+
# No session: a JSON 401, never a redirect; refused: the refusal as JSON
|
|
669
767
|
error_page 401 = /internal/lamaste/user-surface-401;
|
|
768
|
+
error_page 403 = /internal/lamaste/user-surface-403;
|
|
670
769
|
}`;
|
|
671
770
|
const apiPath = userSurfaceApiPath(route);
|
|
672
771
|
return `
|
|
673
772
|
# The user surface of plugin "${route}"
|
|
674
773
|
location = ${apiPath} ${block}
|
|
675
774
|
|
|
676
|
-
location ${apiPath}/ ${block}
|
|
775
|
+
location ^~ ${apiPath}/ ${block}
|
|
677
776
|
`;
|
|
678
777
|
});
|
|
679
778
|
return `
|
|
@@ -683,8 +782,20 @@ ${USER_SURFACE_PROXY_HEADERS}
|
|
|
683
782
|
${gatekeeperSubrequest('user-surface', fqdn)}
|
|
684
783
|
}
|
|
685
784
|
|
|
785
|
+
location = /internal/lamaste/user-surface-403 {
|
|
786
|
+
internal;
|
|
787
|
+
default_type application/json;
|
|
788
|
+
add_header Cache-Control "no-store" always;
|
|
789
|
+
add_header X-Content-Type-Options "nosniff" always;
|
|
790
|
+
if ($lamaste_refusal = not_a_member) {
|
|
791
|
+
return 403 '{"error":"not_a_member"}';
|
|
792
|
+
}
|
|
793
|
+
return 403 '{"error":"forbidden"}';
|
|
794
|
+
}
|
|
795
|
+
|
|
686
796
|
location = /internal/lamaste/user-surface-401 {
|
|
687
797
|
internal;
|
|
798
|
+
auth_request off;
|
|
688
799
|
|
|
689
800
|
proxy_pass http://127.0.0.1:9294/authz/user-surface-unauthenticated;
|
|
690
801
|
proxy_pass_request_body off;
|
|
@@ -705,13 +816,13 @@ ${routes.join('')}`;
|
|
|
705
816
|
* Used for tunnels with accessMode='public'.
|
|
706
817
|
*/
|
|
707
818
|
export async function writePublicVhost(
|
|
708
|
-
|
|
819
|
+
fqdn,
|
|
709
820
|
domain,
|
|
710
821
|
port,
|
|
711
822
|
certPath,
|
|
712
823
|
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
713
824
|
) {
|
|
714
|
-
const { name,
|
|
825
|
+
const { name, config } = renderPublicVhost(fqdn, domain, port, certPath, {
|
|
715
826
|
pathPrefix,
|
|
716
827
|
maxBodySizeMb,
|
|
717
828
|
userSurface,
|
|
@@ -720,7 +831,7 @@ export async function writePublicVhost(
|
|
|
720
831
|
}
|
|
721
832
|
|
|
722
833
|
function renderPublicVhost(
|
|
723
|
-
|
|
834
|
+
fqdn,
|
|
724
835
|
domain,
|
|
725
836
|
port,
|
|
726
837
|
certPath,
|
|
@@ -734,7 +845,7 @@ function renderPublicVhost(
|
|
|
734
845
|
throw new Error("A public tunnel vhost cannot serve a plugin's user surface");
|
|
735
846
|
}
|
|
736
847
|
|
|
737
|
-
|
|
848
|
+
assertHostUnderDomain(fqdn, domain);
|
|
738
849
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
739
850
|
let certDirClean = certDir;
|
|
740
851
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
@@ -772,63 +883,42 @@ ${TUNNEL_PROXY_HEADERS}
|
|
|
772
883
|
}
|
|
773
884
|
`;
|
|
774
885
|
|
|
775
|
-
return { name:
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Write an authenticated vhost — Authelia via Gatekeeper, all authenticated users allowed.
|
|
780
|
-
* Used for tunnels with accessMode='authenticated'.
|
|
781
|
-
* Gatekeeper handles Authelia cookie validation and forwards identity headers.
|
|
782
|
-
*/
|
|
783
|
-
export async function writeAuthenticatedVhost(
|
|
784
|
-
subdomain,
|
|
785
|
-
domain,
|
|
786
|
-
port,
|
|
787
|
-
certPath,
|
|
788
|
-
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
789
|
-
) {
|
|
790
|
-
const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
791
|
-
pathPrefix,
|
|
792
|
-
maxBodySizeMb,
|
|
793
|
-
userSurface,
|
|
794
|
-
restricted: false,
|
|
795
|
-
});
|
|
796
|
-
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
886
|
+
return { name: appVhostName(fqdn), fqdn, config };
|
|
797
887
|
}
|
|
798
888
|
|
|
799
889
|
/**
|
|
800
|
-
* Write
|
|
801
|
-
*
|
|
802
|
-
*
|
|
890
|
+
* Write the vhost of an `authenticated` or `restricted` tunnel — one and
|
|
891
|
+
* the same: nginx asks Gatekeeper about every request, and Gatekeeper
|
|
892
|
+
* decides by the tunnel's access mode in tunnels.json (any member of the
|
|
893
|
+
* domain, or only those with a grant) and serves its refusal pages inline.
|
|
803
894
|
*/
|
|
804
|
-
export async function
|
|
805
|
-
|
|
895
|
+
export async function writeGatekeeperVhost(
|
|
896
|
+
fqdn,
|
|
806
897
|
domain,
|
|
807
898
|
port,
|
|
808
899
|
certPath,
|
|
809
900
|
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
810
901
|
) {
|
|
811
|
-
const { name,
|
|
902
|
+
const { name, config } = renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
|
|
812
903
|
pathPrefix,
|
|
813
904
|
maxBodySizeMb,
|
|
814
905
|
userSurface,
|
|
815
|
-
restricted: true,
|
|
816
906
|
});
|
|
817
907
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
818
908
|
}
|
|
819
909
|
|
|
820
910
|
function renderGatekeeperTunnelVhost(
|
|
821
|
-
|
|
911
|
+
fqdn,
|
|
822
912
|
domain,
|
|
823
913
|
port,
|
|
824
914
|
certPath,
|
|
825
|
-
{ pathPrefix, maxBodySizeMb, userSurface
|
|
915
|
+
{ pathPrefix, maxBodySizeMb, userSurface },
|
|
826
916
|
) {
|
|
827
917
|
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
828
918
|
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
829
919
|
}
|
|
830
920
|
|
|
831
|
-
|
|
921
|
+
assertHostUnderDomain(fqdn, domain);
|
|
832
922
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
833
923
|
let certDirClean = certDir;
|
|
834
924
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
@@ -837,10 +927,9 @@ function renderGatekeeperTunnelVhost(
|
|
|
837
927
|
pathPrefix,
|
|
838
928
|
maxBodySizeMb,
|
|
839
929
|
userSurface,
|
|
840
|
-
restricted,
|
|
841
930
|
});
|
|
842
931
|
|
|
843
|
-
return { name:
|
|
932
|
+
return { name: appVhostName(fqdn), fqdn, config };
|
|
844
933
|
}
|
|
845
934
|
|
|
846
935
|
/**
|
|
@@ -849,28 +938,27 @@ function renderGatekeeperTunnelVhost(
|
|
|
849
938
|
* the generators produce today (see vhost-reconcile.js).
|
|
850
939
|
*
|
|
851
940
|
* @param {'public'|'authenticated'|'restricted'} accessMode
|
|
852
|
-
* @param {string}
|
|
853
|
-
* @param {string} domain
|
|
941
|
+
* @param {string} fqdn the tunnel's hostname
|
|
942
|
+
* @param {string} domain the base domain it is under (its sign-in, `auth.<domain>`)
|
|
854
943
|
* @param {number} port
|
|
855
944
|
* @param {string} [certPath]
|
|
856
945
|
* @param {{ pathPrefix?: string, maxBodySizeMb?: number, userSurface?: ReadonlyArray<{ route: string, maxBodySizeMb: number }> }} [opts]
|
|
857
946
|
* `userSurface`: the plugins' user surfaces the vhost serves (not on a public vhost)
|
|
858
947
|
* @returns {{ name: string, fqdn: string, config: string }}
|
|
859
948
|
*/
|
|
860
|
-
export function renderTunnelVhost(accessMode,
|
|
949
|
+
export function renderTunnelVhost(accessMode, fqdn, domain, port, certPath, opts = {}) {
|
|
861
950
|
const { pathPrefix, maxBodySizeMb, userSurface } = opts;
|
|
862
951
|
if (accessMode === 'public') {
|
|
863
|
-
return renderPublicVhost(
|
|
952
|
+
return renderPublicVhost(fqdn, domain, port, certPath, {
|
|
864
953
|
pathPrefix,
|
|
865
954
|
maxBodySizeMb,
|
|
866
955
|
userSurface,
|
|
867
956
|
});
|
|
868
957
|
}
|
|
869
|
-
return renderGatekeeperTunnelVhost(
|
|
958
|
+
return renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
|
|
870
959
|
pathPrefix,
|
|
871
960
|
maxBodySizeMb,
|
|
872
961
|
userSurface,
|
|
873
|
-
restricted: accessMode !== 'authenticated',
|
|
874
962
|
});
|
|
875
963
|
}
|
|
876
964
|
|
|
@@ -878,7 +966,8 @@ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath,
|
|
|
878
966
|
* Build a Gatekeeper-backed nginx vhost config string.
|
|
879
967
|
* Both 'authenticated' and 'restricted' modes use this — the difference
|
|
880
968
|
* is handled server-side by Gatekeeper based on the tunnel's accessMode.
|
|
881
|
-
*
|
|
969
|
+
* Gatekeeper's 403 body is served inline: a restricted tunnel's access
|
|
970
|
+
* request, or the refusal page of a user who is no member of the domain.
|
|
882
971
|
*
|
|
883
972
|
* The subrequest names the tunnel in its path, `/authz/tunnel/<fqdn>`,
|
|
884
973
|
* written here as the agent-panel vhost writes its agent's label, and
|
|
@@ -900,7 +989,7 @@ function buildGatekeeperVhost(
|
|
|
900
989
|
certDirClean,
|
|
901
990
|
port,
|
|
902
991
|
domain,
|
|
903
|
-
{ pathPrefix, maxBodySizeMb, userSurface = []
|
|
992
|
+
{ pathPrefix, maxBodySizeMb, userSurface = [] } = {},
|
|
904
993
|
) {
|
|
905
994
|
const url = originalUrl(fqdn);
|
|
906
995
|
return `server {
|
|
@@ -961,12 +1050,9 @@ ${TUNNEL_PROXY_HEADERS}
|
|
|
961
1050
|
# Authentication failure -> redirect to Authelia login, back to this vhost
|
|
962
1051
|
error_page 401 =302 https://auth.${domain}/?rd=${url};
|
|
963
1052
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
error_page 403 = /internal/lamaste/authz;`
|
|
968
|
-
: ''
|
|
969
|
-
}
|
|
1053
|
+
# Authorization failure -> Gatekeeper's inline page: the access
|
|
1054
|
+
# request of a restricted tunnel, or not a member of this domain
|
|
1055
|
+
error_page 403 = /internal/lamaste/authz;
|
|
970
1056
|
}
|
|
971
1057
|
}
|
|
972
1058
|
`;
|
|
@@ -1080,7 +1166,8 @@ export async function writeStaticSiteVhost(site, certDir, domain) {
|
|
|
1080
1166
|
* @param {object} site - as for writeStaticSiteVhost
|
|
1081
1167
|
* @param {string} certDir
|
|
1082
1168
|
* @param {string} [domain]
|
|
1083
|
-
* @returns {{ name: string, fqdn: string, config: string }}
|
|
1169
|
+
* @returns {{ name: string, fqdn: string, config: string, closed: boolean }} `closed`: a
|
|
1170
|
+
* protected site under no domain, answering 403 to everyone
|
|
1084
1171
|
*/
|
|
1085
1172
|
export function renderStaticSiteVhost(site, certDir, domain) {
|
|
1086
1173
|
let certDirClean = certDir;
|
|
@@ -1090,8 +1177,13 @@ export function renderStaticSiteVhost(site, certDir, domain) {
|
|
|
1090
1177
|
|
|
1091
1178
|
let autheliaBlock = '';
|
|
1092
1179
|
let locationAuthDirectives = '';
|
|
1180
|
+
// A protected site asks its domain's sign-in; one under none of the
|
|
1181
|
+
// relay's domains has no sign-in that could admit anyone: it is closed,
|
|
1182
|
+
// never served without authentication.
|
|
1183
|
+
const gated = site.autheliaProtected && Boolean(domain);
|
|
1184
|
+
const closed = site.autheliaProtected && !domain;
|
|
1093
1185
|
|
|
1094
|
-
if (
|
|
1186
|
+
if (gated) {
|
|
1095
1187
|
autheliaBlock = `
|
|
1096
1188
|
# Authelia forward authentication (AuthRequest implementation for nginx)
|
|
1097
1189
|
location /internal/authelia/authz {
|
|
@@ -1111,6 +1203,23 @@ export function renderStaticSiteVhost(site, certDir, domain) {
|
|
|
1111
1203
|
proxy_buffers 4 32k;
|
|
1112
1204
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
1113
1205
|
}
|
|
1206
|
+
|
|
1207
|
+
# Signed in, refused (not a member of the site's domain, or not one of
|
|
1208
|
+
# its users): Gatekeeper's page says so instead of nginx's bare 403
|
|
1209
|
+
location = /internal/lamaste/refusal {
|
|
1210
|
+
internal;
|
|
1211
|
+
auth_request off;
|
|
1212
|
+
|
|
1213
|
+
proxy_pass http://127.0.0.1:9294/authz/refusal/${site.fqdn};
|
|
1214
|
+
proxy_pass_request_body off;
|
|
1215
|
+
|
|
1216
|
+
proxy_set_header Content-Length "";
|
|
1217
|
+
proxy_set_header Cookie "";
|
|
1218
|
+
proxy_set_header Authorization "";
|
|
1219
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
1220
|
+
|
|
1221
|
+
proxy_http_version 1.1;
|
|
1222
|
+
}
|
|
1114
1223
|
`;
|
|
1115
1224
|
locationAuthDirectives = `
|
|
1116
1225
|
# Clear client-supplied identity headers (Authelia re-injects on success)
|
|
@@ -1147,22 +1256,286 @@ export function renderStaticSiteVhost(site, certDir, domain) {
|
|
|
1147
1256
|
add_header X-Content-Type-Options nosniff always;
|
|
1148
1257
|
${autheliaBlock}
|
|
1149
1258
|
location / {${locationAuthDirectives}
|
|
1150
|
-
${tryFiles};
|
|
1259
|
+
${closed ? 'return 403' : tryFiles};
|
|
1151
1260
|
}
|
|
1152
1261
|
${
|
|
1153
|
-
|
|
1262
|
+
gated
|
|
1154
1263
|
? `
|
|
1155
1264
|
# Redirect unauthenticated requests to Authelia login portal
|
|
1156
1265
|
error_page 401 =302 $redirection_url;
|
|
1266
|
+
error_page 403 = /internal/lamaste/refusal;
|
|
1157
1267
|
`
|
|
1158
1268
|
: ''
|
|
1159
1269
|
}
|
|
1160
1270
|
}
|
|
1161
1271
|
${aliasBlock}`;
|
|
1162
1272
|
|
|
1273
|
+
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config, closed };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The Content-Security-Policy of a plugin's web surface: its shell loads
|
|
1278
|
+
* scripts, styles, fonts and images from its own host only and talks to its
|
|
1279
|
+
* own host only (its user surface's API is there). Inline styles are
|
|
1280
|
+
* allowed — frameworks set `style` attributes and insert component styles
|
|
1281
|
+
* at run time — inline scripts never; no framing, no plugins, no foreign
|
|
1282
|
+
* form targets.
|
|
1283
|
+
*/
|
|
1284
|
+
const WEB_SURFACE_CSP =
|
|
1285
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Response headers of the shell of a plugin's web surface, set in its
|
|
1289
|
+
* locations — not at server level, where the API's answers (headed by the
|
|
1290
|
+
* panel) would get a second copy. `no-cache`: a browser revalidates each file —
|
|
1291
|
+
* a 304 while the tree is the same, the new file once an update switched
|
|
1292
|
+
* it — so no page mixes two versions of the shell from its cache.
|
|
1293
|
+
*/
|
|
1294
|
+
const WEB_SURFACE_HEADERS = `
|
|
1295
|
+
add_header Content-Security-Policy "${WEB_SURFACE_CSP}" always;
|
|
1296
|
+
add_header X-Content-Type-Options nosniff always;
|
|
1297
|
+
add_header X-Frame-Options DENY always;
|
|
1298
|
+
add_header Referrer-Policy same-origin always;
|
|
1299
|
+
add_header Cross-Origin-Opener-Policy same-origin always;
|
|
1300
|
+
add_header Cross-Origin-Resource-Policy same-origin always;
|
|
1301
|
+
add_header Cache-Control no-cache always;`;
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* The content types of a web tree's files, by extension
|
|
1305
|
+
* (`WEB_TREE_CONTENT_TYPES` — the only extensions a tree may hold).
|
|
1306
|
+
*/
|
|
1307
|
+
const WEB_SURFACE_TYPES = (() => {
|
|
1308
|
+
const byType = new Map();
|
|
1309
|
+
for (const [ext, type] of Object.entries(WEB_TREE_CONTENT_TYPES)) {
|
|
1310
|
+
byType.set(type, [...(byType.get(type) ?? []), ext.slice(1)]);
|
|
1311
|
+
}
|
|
1312
|
+
const entries = [...byType].map(([type, exts]) => ` ${type} ${exts.join(' ')};`);
|
|
1313
|
+
return `
|
|
1314
|
+
types {
|
|
1315
|
+
${entries.join('\n')}
|
|
1316
|
+
}`;
|
|
1317
|
+
})();
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* The files a page still running the previous shell may ask for after an
|
|
1321
|
+
* update — its scripts, styles, fonts and images — and nothing else: a
|
|
1322
|
+
* document, an SVG or a manifest is never served from the previous tree.
|
|
1323
|
+
*/
|
|
1324
|
+
const WEB_SURFACE_SUBRESOURCE_RE = '[.](?:js|mjs|css|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|ico)$';
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Service workers: never registered from a plugin's site. A worker outlives
|
|
1328
|
+
* the tree that installed it — a revert, the site's deletion, the
|
|
1329
|
+
* hostname's next owner — and answers in its place; asked for its script
|
|
1330
|
+
* (`Service-Worker: script`), the site answers 404, which also
|
|
1331
|
+
* unregisters a worker already installed at its next update check.
|
|
1332
|
+
*/
|
|
1333
|
+
const NO_SERVICE_WORKER = `
|
|
1334
|
+
if ($http_service_worker) {
|
|
1335
|
+
return 404;
|
|
1336
|
+
}`;
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Render the vhost of a plugin's web surface — a site the plugin owns
|
|
1340
|
+
* (`site.owner`). Pure; the startup reconcile renders it again to compare.
|
|
1341
|
+
*
|
|
1342
|
+
* While the plugin's surface is served (`opts.served`: its installed
|
|
1343
|
+
* version approved, declaring identity and a web surface, hosted), the
|
|
1344
|
+
* host serves two things under one Authelia session:
|
|
1345
|
+
*
|
|
1346
|
+
* - **the shell**: every path but the API, from the plugin's current tree
|
|
1347
|
+
* (`<treesDir>/current`), GET and HEAD only, each file under its own
|
|
1348
|
+
* content type (`WEB_SURFACE_TYPES`), never a service worker's script. Every request asks
|
|
1349
|
+
* Gatekeeper first (`/authz/user-surface-shell/<fqdn>`: a session in the
|
|
1350
|
+
* plugin's audience, and the site's grant when it is restricted) — a page
|
|
1351
|
+
* without a session goes to the login, a user without access gets the
|
|
1352
|
+
* access-request page. A script, style, font or image the current tree
|
|
1353
|
+
* lacks is looked for in the previous tree (a page still running the old
|
|
1354
|
+
* shell loads its lazy files after an update), else 404; any other
|
|
1355
|
+
* unknown path gets the current shell's `index.html`.
|
|
1356
|
+
* - **the API**: `/api/<route>/app/*`, exactly as on a bound tunnel
|
|
1357
|
+
* ({@link userSurfaceLocations}).
|
|
1358
|
+
*
|
|
1359
|
+
* While it is not served the host answers every request with 503 — and,
|
|
1360
|
+
* once its plugin is no longer installed (`opts.clearSiteData`), tells
|
|
1361
|
+
* browsers to clear the site's storage.
|
|
1362
|
+
*
|
|
1363
|
+
* @param {{ id: string, fqdn: string }} site
|
|
1364
|
+
* @param {string} certDir
|
|
1365
|
+
* @param {string} domain
|
|
1366
|
+
* @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string, clearSiteData?: boolean }} opts
|
|
1367
|
+
* @returns {{ name: string, fqdn: string, config: string }}
|
|
1368
|
+
*/
|
|
1369
|
+
export function renderWebSurfaceVhost(site, certDir, domain, opts) {
|
|
1370
|
+
const { route, served, maxBodySizeMb, treesDir, clearSiteData = false } = opts;
|
|
1371
|
+
assertLiteralHostname(site.fqdn);
|
|
1372
|
+
if (!isUserSurfaceRoute(route)) throw new Error(`Invalid web surface route: ${route}`);
|
|
1373
|
+
if (!path.isAbsolute(treesDir) || !/^[A-Za-z0-9/._-]+$/.test(treesDir)) {
|
|
1374
|
+
throw new Error(`Invalid web tree directory for nginx vhost: ${treesDir}`);
|
|
1375
|
+
}
|
|
1376
|
+
let certDirClean = certDir;
|
|
1377
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
1378
|
+
|
|
1379
|
+
const head = `server {
|
|
1380
|
+
listen 443 ssl;
|
|
1381
|
+
server_name ${site.fqdn};
|
|
1382
|
+
|
|
1383
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
1384
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
1385
|
+
|
|
1386
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
1387
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
1388
|
+
ssl_prefer_server_ciphers on;
|
|
1389
|
+
`;
|
|
1390
|
+
|
|
1391
|
+
if (!served) {
|
|
1392
|
+
// A plugin no longer installed: whatever comes next on this hostname is
|
|
1393
|
+
// not its shell, so browsers drop what it left — its storage, caches
|
|
1394
|
+
// and service workers. Never while the plugin is only unhosted or
|
|
1395
|
+
// awaiting approval: its users' data stays.
|
|
1396
|
+
const clear = clearSiteData
|
|
1397
|
+
? `
|
|
1398
|
+
add_header Clear-Site-Data '"storage"' always;`
|
|
1399
|
+
: '';
|
|
1400
|
+
return {
|
|
1401
|
+
name: `lamalibre-lamaste-site-${site.id}`,
|
|
1402
|
+
fqdn: site.fqdn,
|
|
1403
|
+
config: `${head}
|
|
1404
|
+
# The web surface of plugin "${route}" is not served now (not installed,
|
|
1405
|
+
# not approved, not hosted, or its version declares no web surface)
|
|
1406
|
+
location / {${NO_SERVICE_WORKER}
|
|
1407
|
+
add_header Cache-Control no-store always;${clear}
|
|
1408
|
+
return 503;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
`,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const url = originalUrl(site.fqdn);
|
|
1416
|
+
const config = `${head}
|
|
1417
|
+
root ${treesDir}/current;
|
|
1418
|
+
index index.html;
|
|
1419
|
+
|
|
1420
|
+
# Every path of the shell: Gatekeeper first (the API's locations ask on their own)
|
|
1421
|
+
auth_request /internal/lamaste/shell-authz;
|
|
1422
|
+
error_page 401 =302 https://auth.${domain}/?rd=${url};
|
|
1423
|
+
error_page 403 = /internal/lamaste/shell-authz;
|
|
1424
|
+
|
|
1425
|
+
location = /internal/lamaste/shell-authz {
|
|
1426
|
+
${gatekeeperSubrequest('user-surface-shell', site.fqdn)}
|
|
1427
|
+
}
|
|
1428
|
+
${userSurfaceLocations(site.fqdn, [{ route, maxBodySizeMb }])}
|
|
1429
|
+
# A script, style, font or image the current tree lacks: the previous
|
|
1430
|
+
# tree's (a page still running the old shell), else 404
|
|
1431
|
+
location ~* ${WEB_SURFACE_SUBRESOURCE_RE} {
|
|
1432
|
+
if ($request_method !~ ^(GET|HEAD)$) {
|
|
1433
|
+
return 405;
|
|
1434
|
+
}${NO_SERVICE_WORKER}
|
|
1435
|
+
${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
|
|
1436
|
+
try_files $uri @previous;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
# Everything else: the current tree's file, else the shell
|
|
1440
|
+
location / {
|
|
1441
|
+
if ($request_method !~ ^(GET|HEAD)$) {
|
|
1442
|
+
return 405;
|
|
1443
|
+
}${NO_SERVICE_WORKER}
|
|
1444
|
+
${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
|
|
1445
|
+
try_files $uri /index.html;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
location @previous {
|
|
1449
|
+
${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
|
|
1450
|
+
root ${treesDir}/previous;
|
|
1451
|
+
try_files $uri =404;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
`;
|
|
1163
1455
|
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
|
|
1164
1456
|
}
|
|
1165
1457
|
|
|
1458
|
+
/**
|
|
1459
|
+
* Write the vhost of a plugin's web surface (see {@link renderWebSurfaceVhost}).
|
|
1460
|
+
*
|
|
1461
|
+
* @param {{ id: string, fqdn: string }} site
|
|
1462
|
+
* @param {string} certDir
|
|
1463
|
+
* @param {string} domain
|
|
1464
|
+
* @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
|
|
1465
|
+
*/
|
|
1466
|
+
export async function writeWebSurfaceVhost(site, certDir, domain, opts) {
|
|
1467
|
+
const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
|
|
1468
|
+
return safeWriteVhost(name, config, fqdn, { enabled: true });
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Hand a hostname from one vhost to another in one nginx reload: vhost
|
|
1473
|
+
* `fromName` is unlinked, `name` written with `config` and linked, the
|
|
1474
|
+
* configuration tested and nginx reloaded once; `fromName` stays installed,
|
|
1475
|
+
* unlinked. Until the reload nginx serves the old vhost, after it the new
|
|
1476
|
+
* one; there is no moment in between. On any failure before the reload
|
|
1477
|
+
* both are put back as they were.
|
|
1478
|
+
*
|
|
1479
|
+
* @param {string} fromName the vhost giving the hostname up (linked)
|
|
1480
|
+
* @param {string} name a vhost that does not exist yet
|
|
1481
|
+
* @param {string} config
|
|
1482
|
+
* @param {string} fqdn
|
|
1483
|
+
*/
|
|
1484
|
+
async function handOverVhost(fromName, name, config, fqdn) {
|
|
1485
|
+
if (await siteExists(name)) throw new Error(`Vhost ${name} exists already`);
|
|
1486
|
+
const fromEnabled = await isSiteEnabled(fromName);
|
|
1487
|
+
const restore = async () => {
|
|
1488
|
+
await siteAction('remove', name).catch(() => {});
|
|
1489
|
+
if (fromEnabled) await enableSite(fromName).catch(() => {});
|
|
1490
|
+
};
|
|
1491
|
+
try {
|
|
1492
|
+
await disableSite(fromName);
|
|
1493
|
+
await writeVhostFile(name, config);
|
|
1494
|
+
await enableSite(name);
|
|
1495
|
+
const result = await testConfig();
|
|
1496
|
+
if (!result.valid) {
|
|
1497
|
+
throw new Error(`Nginx config test failed handing ${fqdn} to ${name}: ${result.error}`);
|
|
1498
|
+
}
|
|
1499
|
+
await reload();
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
await restore();
|
|
1502
|
+
throw err;
|
|
1503
|
+
}
|
|
1504
|
+
// `fromName` stays installed, only unlinked: the tunnel it served can be
|
|
1505
|
+
// enabled again by hand once the new vhost is gone.
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Unlink the vhost of tunnel `fqdn` when it is still enabled although a
|
|
1510
|
+
* plugin's site holds its hostname — a move the panel did not finish (it
|
|
1511
|
+
* wrote the state, then stopped before the handover). Does not reload:
|
|
1512
|
+
* the caller's next vhost install does, or it reloads itself. Returns
|
|
1513
|
+
* whether it unlinked.
|
|
1514
|
+
*
|
|
1515
|
+
* @param {string} fqdn the moved tunnel's hostname
|
|
1516
|
+
*/
|
|
1517
|
+
export async function unlinkMovedAppVhost(fqdn) {
|
|
1518
|
+
const name = appVhostName(fqdn);
|
|
1519
|
+
if (!(await isSiteEnabled(name))) return false;
|
|
1520
|
+
await disableSite(name);
|
|
1521
|
+
return true;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Serve a plugin's web surface on the hostname of tunnel `fqdn` in one
|
|
1526
|
+
* reload (see {@link handOverVhost}).
|
|
1527
|
+
*
|
|
1528
|
+
* @param {string} fqdn the tunnel's hostname
|
|
1529
|
+
* @param {{ id: string, fqdn: string }} site
|
|
1530
|
+
* @param {string} certDir
|
|
1531
|
+
* @param {string} domain
|
|
1532
|
+
* @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
|
|
1533
|
+
*/
|
|
1534
|
+
export async function moveAppVhostToWebSurface(fqdn, site, certDir, domain, opts) {
|
|
1535
|
+
const { name, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
|
|
1536
|
+
await handOverVhost(appVhostName(fqdn), name, config, site.fqdn);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1166
1539
|
/**
|
|
1167
1540
|
* Server block answering a site's aliases with a permanent redirect to the
|
|
1168
1541
|
* primary hostname, on the same certificate (its lineage covers every alias).
|
|
@@ -1221,10 +1594,10 @@ export async function removeStaticSiteVhost(siteId) {
|
|
|
1221
1594
|
* Remove an app vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
1222
1595
|
* Idempotent: if files don't exist, proceeds silently.
|
|
1223
1596
|
*
|
|
1224
|
-
* @param {string}
|
|
1597
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1225
1598
|
*/
|
|
1226
|
-
export async function removeAppVhost(
|
|
1227
|
-
const name =
|
|
1599
|
+
export async function removeAppVhost(fqdn) {
|
|
1600
|
+
const name = appVhostName(fqdn);
|
|
1228
1601
|
await siteAction('remove', name);
|
|
1229
1602
|
|
|
1230
1603
|
const result = await testConfig();
|
|
@@ -1277,10 +1650,10 @@ export async function disableSite(name) {
|
|
|
1277
1650
|
/**
|
|
1278
1651
|
* Enable an app tunnel vhost (restore symlink), test and reload nginx.
|
|
1279
1652
|
*
|
|
1280
|
-
* @param {string}
|
|
1653
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1281
1654
|
*/
|
|
1282
|
-
export async function enableAppVhost(
|
|
1283
|
-
const name =
|
|
1655
|
+
export async function enableAppVhost(fqdn) {
|
|
1656
|
+
const name = appVhostName(fqdn);
|
|
1284
1657
|
await enableSite(name);
|
|
1285
1658
|
|
|
1286
1659
|
const result = await testConfig();
|
|
@@ -1296,10 +1669,10 @@ export async function enableAppVhost(subdomain) {
|
|
|
1296
1669
|
/**
|
|
1297
1670
|
* Disable an app tunnel vhost (remove symlink only, keep config), test and reload nginx.
|
|
1298
1671
|
*
|
|
1299
|
-
* @param {string}
|
|
1672
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1300
1673
|
*/
|
|
1301
|
-
export async function disableAppVhost(
|
|
1302
|
-
const name =
|
|
1674
|
+
export async function disableAppVhost(fqdn) {
|
|
1675
|
+
const name = appVhostName(fqdn);
|
|
1303
1676
|
await disableSite(name);
|
|
1304
1677
|
|
|
1305
1678
|
const result = await testConfig();
|
|
@@ -1345,19 +1718,13 @@ export async function enableIpVhost() {
|
|
|
1345
1718
|
*
|
|
1346
1719
|
* Written through safeWriteVhost (backup, test, rollback).
|
|
1347
1720
|
*
|
|
1348
|
-
* @param {string}
|
|
1721
|
+
* @param {string} fqdn - The hostname (e.g., "agent-my-agent.example.com")
|
|
1349
1722
|
* @param {string} domain - The base domain (e.g., "example.com")
|
|
1350
1723
|
* @param {number} port - The local port to proxy to
|
|
1351
1724
|
* @param {string} [certPath] - Optional cert directory path override (e.g. for wildcard certs)
|
|
1352
1725
|
*/
|
|
1353
|
-
export async function writeAgentPanelVhost(
|
|
1354
|
-
|
|
1355
|
-
domain,
|
|
1356
|
-
port,
|
|
1357
|
-
certPath,
|
|
1358
|
-
{ enabled = true } = {},
|
|
1359
|
-
) {
|
|
1360
|
-
const { name, fqdn, config } = renderAgentPanelVhost(subdomain, domain, port, certPath);
|
|
1726
|
+
export async function writeAgentPanelVhost(fqdn, domain, port, certPath, { enabled = true } = {}) {
|
|
1727
|
+
const { name, config } = renderAgentPanelVhost(fqdn, domain, port, certPath);
|
|
1361
1728
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
1362
1729
|
}
|
|
1363
1730
|
|
|
@@ -1379,13 +1746,14 @@ export async function writeAgentPanelVhost(
|
|
|
1379
1746
|
* cookie never does ({@link BACKEND_COOKIE_HEADER}): the agent panel's host
|
|
1380
1747
|
* is under the base domain like every other.
|
|
1381
1748
|
*
|
|
1382
|
-
* @param {string}
|
|
1749
|
+
* @param {string} fqdn - `agent-<label>.<domain>`
|
|
1383
1750
|
* @param {string} domain
|
|
1384
1751
|
* @param {number} port
|
|
1385
1752
|
* @param {string} [certPath]
|
|
1386
1753
|
* @returns {{ name: string, fqdn: string, config: string }}
|
|
1387
1754
|
*/
|
|
1388
|
-
export function renderAgentPanelVhost(
|
|
1755
|
+
export function renderAgentPanelVhost(fqdn, domain, port, certPath) {
|
|
1756
|
+
const subdomain = assertHostUnderDomain(fqdn, domain);
|
|
1389
1757
|
if (!/^agent-[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) {
|
|
1390
1758
|
throw new Error(`Invalid agent panel subdomain for nginx vhost: ${subdomain}`);
|
|
1391
1759
|
}
|
|
@@ -1393,7 +1761,6 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
|
|
|
1393
1761
|
throw new Error(`Invalid port for nginx vhost: ${port}`);
|
|
1394
1762
|
}
|
|
1395
1763
|
const label = subdomain.slice('agent-'.length);
|
|
1396
|
-
const fqdn = `${subdomain}.${domain}`;
|
|
1397
1764
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
1398
1765
|
let certDirClean = certDir;
|
|
1399
1766
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
@@ -1483,7 +1850,7 @@ ${BACKEND_COOKIE_HEADER}
|
|
|
1483
1850
|
}
|
|
1484
1851
|
`;
|
|
1485
1852
|
|
|
1486
|
-
return { name:
|
|
1853
|
+
return { name: agentPanelVhostName(fqdn), fqdn, config };
|
|
1487
1854
|
}
|
|
1488
1855
|
|
|
1489
1856
|
/**
|
|
@@ -1524,10 +1891,10 @@ export { safeWriteVhost as installVhost };
|
|
|
1524
1891
|
* Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
1525
1892
|
* Idempotent: if files don't exist, proceeds silently.
|
|
1526
1893
|
*
|
|
1527
|
-
* @param {string}
|
|
1894
|
+
* @param {string} fqdn - The agent panel hostname (e.g., "agent-my-agent.example.com")
|
|
1528
1895
|
*/
|
|
1529
|
-
export async function removeAgentPanelVhost(
|
|
1530
|
-
const name =
|
|
1896
|
+
export async function removeAgentPanelVhost(fqdn) {
|
|
1897
|
+
const name = agentPanelVhostName(fqdn);
|
|
1531
1898
|
await siteAction('remove', name);
|
|
1532
1899
|
|
|
1533
1900
|
const result = await testConfig();
|
|
@@ -1541,10 +1908,10 @@ export async function removeAgentPanelVhost(subdomain) {
|
|
|
1541
1908
|
/**
|
|
1542
1909
|
* Enable an agent panel vhost (restore symlink), test and reload nginx.
|
|
1543
1910
|
*
|
|
1544
|
-
* @param {string}
|
|
1911
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1545
1912
|
*/
|
|
1546
|
-
export async function enableAgentPanelVhost(
|
|
1547
|
-
const name =
|
|
1913
|
+
export async function enableAgentPanelVhost(fqdn) {
|
|
1914
|
+
const name = agentPanelVhostName(fqdn);
|
|
1548
1915
|
await enableSite(name);
|
|
1549
1916
|
|
|
1550
1917
|
const result = await testConfig();
|
|
@@ -1559,10 +1926,10 @@ export async function enableAgentPanelVhost(subdomain) {
|
|
|
1559
1926
|
/**
|
|
1560
1927
|
* Disable an agent panel vhost (remove symlink only, keep config), test and reload nginx.
|
|
1561
1928
|
*
|
|
1562
|
-
* @param {string}
|
|
1929
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1563
1930
|
*/
|
|
1564
|
-
export async function disableAgentPanelVhost(
|
|
1565
|
-
const name =
|
|
1931
|
+
export async function disableAgentPanelVhost(fqdn) {
|
|
1932
|
+
const name = agentPanelVhostName(fqdn);
|
|
1566
1933
|
await disableSite(name);
|
|
1567
1934
|
|
|
1568
1935
|
const result = await testConfig();
|
|
@@ -1573,3 +1940,81 @@ export async function disableAgentPanelVhost(subdomain) {
|
|
|
1573
1940
|
|
|
1574
1941
|
await reload();
|
|
1575
1942
|
}
|
|
1943
|
+
|
|
1944
|
+
/** The file operations {@link renameVhosts} uses (tests pass stand-ins). */
|
|
1945
|
+
const renameFileOps = {
|
|
1946
|
+
readInstalledVhost,
|
|
1947
|
+
siteExists,
|
|
1948
|
+
isSiteEnabled,
|
|
1949
|
+
writeVhostFile,
|
|
1950
|
+
enableSite,
|
|
1951
|
+
disableSite,
|
|
1952
|
+
removeSite: (name) => siteAction('remove', name),
|
|
1953
|
+
testConfig,
|
|
1954
|
+
reload,
|
|
1955
|
+
};
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* Rename installed vhosts — `[{ from, to, serverName? }]` — in one nginx
|
|
1959
|
+
* reload: each `from` that exists is written again under `to` with the same
|
|
1960
|
+
* text and link state, and unlinked; then `nginx -t`. When it passes, nginx
|
|
1961
|
+
* reloads and the old files go. When it fails — or a step does — every new
|
|
1962
|
+
* file goes, every old link comes back, nothing reloads, and the error is
|
|
1963
|
+
* thrown. The multi-domain relay's start-up migration of vhosts named by
|
|
1964
|
+
* subdomain to vhosts named by hostname.
|
|
1965
|
+
*
|
|
1966
|
+
* A `from` whose `to` exists already — a migration a crash cut short — is
|
|
1967
|
+
* finished, not skipped: `to` takes `from`'s link and `from` goes, so a
|
|
1968
|
+
* stale old file never outlives its tunnel. `serverName`, when given, must
|
|
1969
|
+
* be the old file's `server_name`: a file that serves another host is
|
|
1970
|
+
* nobody's to rename.
|
|
1971
|
+
*
|
|
1972
|
+
* @param {ReadonlyArray<{ from: string, to: string, serverName?: string }>} renames
|
|
1973
|
+
* @param {typeof renameFileOps} [ops]
|
|
1974
|
+
* @returns {Promise<string[]>} the `to` names written or finished
|
|
1975
|
+
*/
|
|
1976
|
+
export async function renameVhosts(renames, ops = renameFileOps) {
|
|
1977
|
+
const done = [];
|
|
1978
|
+
try {
|
|
1979
|
+
for (const { from, to, serverName } of renames) {
|
|
1980
|
+
const text = await ops.readInstalledVhost(from);
|
|
1981
|
+
if (text === null) continue;
|
|
1982
|
+
if (serverName !== undefined && !servesHost(text, serverName)) continue;
|
|
1983
|
+
const linked = await ops.isSiteEnabled(from);
|
|
1984
|
+
if (await ops.siteExists(to)) {
|
|
1985
|
+
const linkTo = linked && !(await ops.isSiteEnabled(to));
|
|
1986
|
+
done.push({ from, to, linked, existed: true, linkedTo: linkTo });
|
|
1987
|
+
if (linkTo) await ops.enableSite(to);
|
|
1988
|
+
if (linked) await ops.disableSite(from);
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
done.push({ from, to, linked, existed: false, linkedTo: linked });
|
|
1992
|
+
await ops.writeVhostFile(to, text);
|
|
1993
|
+
if (linked) {
|
|
1994
|
+
await ops.enableSite(to);
|
|
1995
|
+
await ops.disableSite(from);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
if (done.length === 0) return [];
|
|
1999
|
+
const result = await ops.testConfig();
|
|
2000
|
+
if (!result.valid) throw new Error(`nginx -t failed after renaming vhosts: ${result.error}`);
|
|
2001
|
+
await ops.reload();
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
for (const { from, to, linked, existed, linkedTo } of done.reverse()) {
|
|
2004
|
+
if (!existed) await ops.removeSite(to).catch(() => {});
|
|
2005
|
+
else if (linkedTo) await ops.disableSite(to).catch(() => {});
|
|
2006
|
+
if (linked) await ops.enableSite(from).catch(() => {});
|
|
2007
|
+
}
|
|
2008
|
+
throw err;
|
|
2009
|
+
}
|
|
2010
|
+
for (const { from } of done) {
|
|
2011
|
+
await ops.removeSite(from).catch(() => {});
|
|
2012
|
+
}
|
|
2013
|
+
return done.map((d) => d.to);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/** True when vhost `text`'s first `server_name` is exactly `host`. */
|
|
2017
|
+
function servesHost(text, host) {
|
|
2018
|
+
const match = /^\s*server_name\s+([^;\s]+)\s*;/m.exec(text);
|
|
2019
|
+
return match !== null && match[1].toLowerCase() === host.toLowerCase();
|
|
2020
|
+
}
|