@lamalibre/create-lamaste 4.6.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 +22 -3
- 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/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 +15 -5
- 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 +17 -13
- 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 +35 -7
- 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 +5 -0
- package/vendor/docs/04-api-reference/tunnels.md +15 -10
- 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 +6 -6
- package/vendor/docs/06-reference/config-files.md +1 -1
- 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/cli/index.js +0 -0
- 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/server/index.d.ts +5 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +28 -2
- 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 +225 -36
- 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/plugins.js +112 -8
- package/vendor/server/src/commands/registry-credential.js +10 -3
- package/vendor/server/src/commands/sites.js +3 -0
- 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-ui/dist/assets/index-B8Tv_ocF.js +84 -0
- package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +42 -16
- 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/directory.js +67 -52
- package/vendor/serverd/src/lib/domains.js +1792 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
- package/vendor/serverd/src/lib/identity-sync.js +35 -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 +318 -140
- 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-lifecycle.js +1 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
- package/vendor/serverd/src/lib/plugins.js +45 -5
- 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 +11 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
- package/vendor/serverd/src/lib/user-surface.js +29 -4
- package/vendor/serverd/src/lib/users-lock.js +23 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
- package/vendor/serverd/src/lib/web-surface.js +49 -32
- 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/plugins.js +4 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
- package/vendor/serverd/src/routes/management/sites.js +50 -11
- package/vendor/serverd/src/routes/management/tunnels.js +29 -5
- package/vendor/serverd/src/routes/management/users.js +184 -64
- package/vendor/serverd/src/routes/management.js +2 -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-BvEfCZYS.js +0 -84
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
RELAY_PANEL_PORT,
|
|
7
7
|
USER_SURFACE_MAX_RESPONSE_SECONDS,
|
|
8
8
|
isUserSurfaceRoute,
|
|
9
|
+
labelUnder,
|
|
9
10
|
userSurfaceApiPath,
|
|
10
11
|
WEB_TREE_CONTENT_TYPES,
|
|
11
12
|
} from '@lamalibre/lamaste';
|
|
@@ -288,6 +289,46 @@ server {
|
|
|
288
289
|
return { name: PANEL_DOMAIN_VHOST, fqdn, config };
|
|
289
290
|
}
|
|
290
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
|
+
|
|
291
332
|
/**
|
|
292
333
|
* A hostname written into a vhost as a literal. Where it names the vhost to
|
|
293
334
|
* an authorization subrequest it must stay a literal — a `$` would make nginx
|
|
@@ -336,61 +377,69 @@ const AUTHZ_CLEARED_HEADERS = `
|
|
|
336
377
|
proxy_set_header X-Forwarded-Uri "";
|
|
337
378
|
proxy_set_header X-Forwarded-Method "";`;
|
|
338
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
|
+
|
|
339
389
|
/**
|
|
340
|
-
* 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
|
|
341
395
|
*/
|
|
342
|
-
export async function writeAuthVhost(domain) {
|
|
343
|
-
const { name, config } = renderAuthVhost(domain);
|
|
344
|
-
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 });
|
|
345
399
|
return path.join(SITES_AVAILABLE, name);
|
|
346
400
|
}
|
|
347
401
|
|
|
348
402
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
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).
|
|
351
425
|
*
|
|
352
426
|
* @param {string} domain
|
|
353
427
|
* @param {string} [certPath] - certificate directory (default: the portal's own lineage)
|
|
428
|
+
* @param {{ primary?: boolean }} [opts]
|
|
354
429
|
* @returns {{ name: string, fqdn: string, config: string }}
|
|
355
430
|
*/
|
|
356
|
-
export function renderAuthVhost(domain, certPath) {
|
|
431
|
+
export function renderAuthVhost(domain, certPath, { primary = true } = {}) {
|
|
357
432
|
const fqdn = `auth.${domain}`;
|
|
358
433
|
let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
359
434
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
360
|
-
const
|
|
435
|
+
const zone = primary
|
|
436
|
+
? `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
|
|
361
437
|
limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
|
|
362
438
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
368
|
-
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
369
|
-
|
|
370
|
-
ssl_protocols TLSv1.2 TLSv1.3;
|
|
371
|
-
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
372
|
-
ssl_prefer_server_ciphers on;
|
|
373
|
-
|
|
374
|
-
proxy_set_header Host $host;
|
|
375
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
376
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
377
|
-
proxy_set_header X-Forwarded-Proto $scheme;
|
|
378
|
-
|
|
379
|
-
# Invitation page — static HTML
|
|
380
|
-
location /invite/ {
|
|
381
|
-
alias /var/www/lamaste/invite/;
|
|
382
|
-
try_files $uri /invite/index.html;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
# Invitation API — proxied to panel server (no mTLS)
|
|
386
|
-
location /api/invite/ {
|
|
387
|
-
proxy_pass http://127.0.0.1:3100/api/invite/;
|
|
388
|
-
proxy_set_header Host $host;
|
|
389
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
390
|
-
proxy_set_header X-Forwarded-For $remote_addr;
|
|
391
|
-
proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
|
|
392
|
-
}
|
|
393
|
-
|
|
439
|
+
`
|
|
440
|
+
: '';
|
|
441
|
+
const userAccess = primary
|
|
442
|
+
? `
|
|
394
443
|
# User access authorization — Authelia forward auth protects this endpoint
|
|
395
444
|
# so Remote-User header is set by Authelia on successful authentication.
|
|
396
445
|
location /internal/authelia/authz {
|
|
@@ -434,15 +483,55 @@ server {
|
|
|
434
483
|
|
|
435
484
|
error_page 401 =302 $redirection_url;
|
|
436
485
|
}
|
|
486
|
+
`
|
|
487
|
+
: '';
|
|
488
|
+
const config = `${zone}server {
|
|
489
|
+
listen 443 ssl;
|
|
490
|
+
server_name ${fqdn};
|
|
437
491
|
|
|
438
|
-
|
|
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
|
+
}
|
|
509
|
+
|
|
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.)
|
|
439
523
|
location / {
|
|
440
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;
|
|
441
530
|
}
|
|
442
531
|
}
|
|
443
532
|
`;
|
|
444
533
|
|
|
445
|
-
return { name:
|
|
534
|
+
return { name: authVhostName(domain), fqdn, config };
|
|
446
535
|
}
|
|
447
536
|
|
|
448
537
|
/**
|
|
@@ -629,6 +718,11 @@ function gatekeeperSubrequest(check, fqdn) {
|
|
|
629
718
|
* - `/internal/lamaste/user-surface-401` answers a request without a
|
|
630
719
|
* session with Gatekeeper's JSON 401 — an application's request is never
|
|
631
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.
|
|
632
726
|
*
|
|
633
727
|
* Each route's locations pass the assertion, and nothing else of the
|
|
634
728
|
* browser's credentials ({@link USER_SURFACE_PROXY_HEADERS}), to the panel
|
|
@@ -654,6 +748,7 @@ function userSurfaceLocations(fqdn, userSurface) {
|
|
|
654
748
|
const block = `{
|
|
655
749
|
auth_request /internal/lamaste/user-surface-authz;
|
|
656
750
|
auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
|
|
751
|
+
auth_request_set $lamaste_refusal $upstream_http_x_lamalibre_refusal;
|
|
657
752
|
|
|
658
753
|
# The user, vouched for by Gatekeeper to the panel for this one request
|
|
659
754
|
proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
|
|
@@ -668,8 +763,9 @@ ${USER_SURFACE_PROXY_HEADERS}
|
|
|
668
763
|
proxy_read_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
|
|
669
764
|
proxy_send_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
|
|
670
765
|
|
|
671
|
-
# No session: a JSON 401, never a redirect
|
|
766
|
+
# No session: a JSON 401, never a redirect; refused: the refusal as JSON
|
|
672
767
|
error_page 401 = /internal/lamaste/user-surface-401;
|
|
768
|
+
error_page 403 = /internal/lamaste/user-surface-403;
|
|
673
769
|
}`;
|
|
674
770
|
const apiPath = userSurfaceApiPath(route);
|
|
675
771
|
return `
|
|
@@ -686,6 +782,17 @@ ${USER_SURFACE_PROXY_HEADERS}
|
|
|
686
782
|
${gatekeeperSubrequest('user-surface', fqdn)}
|
|
687
783
|
}
|
|
688
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
|
+
|
|
689
796
|
location = /internal/lamaste/user-surface-401 {
|
|
690
797
|
internal;
|
|
691
798
|
auth_request off;
|
|
@@ -709,13 +816,13 @@ ${routes.join('')}`;
|
|
|
709
816
|
* Used for tunnels with accessMode='public'.
|
|
710
817
|
*/
|
|
711
818
|
export async function writePublicVhost(
|
|
712
|
-
|
|
819
|
+
fqdn,
|
|
713
820
|
domain,
|
|
714
821
|
port,
|
|
715
822
|
certPath,
|
|
716
823
|
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
717
824
|
) {
|
|
718
|
-
const { name,
|
|
825
|
+
const { name, config } = renderPublicVhost(fqdn, domain, port, certPath, {
|
|
719
826
|
pathPrefix,
|
|
720
827
|
maxBodySizeMb,
|
|
721
828
|
userSurface,
|
|
@@ -724,7 +831,7 @@ export async function writePublicVhost(
|
|
|
724
831
|
}
|
|
725
832
|
|
|
726
833
|
function renderPublicVhost(
|
|
727
|
-
|
|
834
|
+
fqdn,
|
|
728
835
|
domain,
|
|
729
836
|
port,
|
|
730
837
|
certPath,
|
|
@@ -738,7 +845,7 @@ function renderPublicVhost(
|
|
|
738
845
|
throw new Error("A public tunnel vhost cannot serve a plugin's user surface");
|
|
739
846
|
}
|
|
740
847
|
|
|
741
|
-
|
|
848
|
+
assertHostUnderDomain(fqdn, domain);
|
|
742
849
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
743
850
|
let certDirClean = certDir;
|
|
744
851
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
@@ -776,63 +883,42 @@ ${TUNNEL_PROXY_HEADERS}
|
|
|
776
883
|
}
|
|
777
884
|
`;
|
|
778
885
|
|
|
779
|
-
return { name:
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Write an authenticated vhost — Authelia via Gatekeeper, all authenticated users allowed.
|
|
784
|
-
* Used for tunnels with accessMode='authenticated'.
|
|
785
|
-
* Gatekeeper handles Authelia cookie validation and forwards identity headers.
|
|
786
|
-
*/
|
|
787
|
-
export async function writeAuthenticatedVhost(
|
|
788
|
-
subdomain,
|
|
789
|
-
domain,
|
|
790
|
-
port,
|
|
791
|
-
certPath,
|
|
792
|
-
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
793
|
-
) {
|
|
794
|
-
const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
795
|
-
pathPrefix,
|
|
796
|
-
maxBodySizeMb,
|
|
797
|
-
userSurface,
|
|
798
|
-
restricted: false,
|
|
799
|
-
});
|
|
800
|
-
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
886
|
+
return { name: appVhostName(fqdn), fqdn, config };
|
|
801
887
|
}
|
|
802
888
|
|
|
803
889
|
/**
|
|
804
|
-
* Write
|
|
805
|
-
*
|
|
806
|
-
*
|
|
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.
|
|
807
894
|
*/
|
|
808
|
-
export async function
|
|
809
|
-
|
|
895
|
+
export async function writeGatekeeperVhost(
|
|
896
|
+
fqdn,
|
|
810
897
|
domain,
|
|
811
898
|
port,
|
|
812
899
|
certPath,
|
|
813
900
|
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
814
901
|
) {
|
|
815
|
-
const { name,
|
|
902
|
+
const { name, config } = renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
|
|
816
903
|
pathPrefix,
|
|
817
904
|
maxBodySizeMb,
|
|
818
905
|
userSurface,
|
|
819
|
-
restricted: true,
|
|
820
906
|
});
|
|
821
907
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
822
908
|
}
|
|
823
909
|
|
|
824
910
|
function renderGatekeeperTunnelVhost(
|
|
825
|
-
|
|
911
|
+
fqdn,
|
|
826
912
|
domain,
|
|
827
913
|
port,
|
|
828
914
|
certPath,
|
|
829
|
-
{ pathPrefix, maxBodySizeMb, userSurface
|
|
915
|
+
{ pathPrefix, maxBodySizeMb, userSurface },
|
|
830
916
|
) {
|
|
831
917
|
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
832
918
|
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
833
919
|
}
|
|
834
920
|
|
|
835
|
-
|
|
921
|
+
assertHostUnderDomain(fqdn, domain);
|
|
836
922
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
837
923
|
let certDirClean = certDir;
|
|
838
924
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
@@ -841,10 +927,9 @@ function renderGatekeeperTunnelVhost(
|
|
|
841
927
|
pathPrefix,
|
|
842
928
|
maxBodySizeMb,
|
|
843
929
|
userSurface,
|
|
844
|
-
restricted,
|
|
845
930
|
});
|
|
846
931
|
|
|
847
|
-
return { name:
|
|
932
|
+
return { name: appVhostName(fqdn), fqdn, config };
|
|
848
933
|
}
|
|
849
934
|
|
|
850
935
|
/**
|
|
@@ -853,28 +938,27 @@ function renderGatekeeperTunnelVhost(
|
|
|
853
938
|
* the generators produce today (see vhost-reconcile.js).
|
|
854
939
|
*
|
|
855
940
|
* @param {'public'|'authenticated'|'restricted'} accessMode
|
|
856
|
-
* @param {string}
|
|
857
|
-
* @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>`)
|
|
858
943
|
* @param {number} port
|
|
859
944
|
* @param {string} [certPath]
|
|
860
945
|
* @param {{ pathPrefix?: string, maxBodySizeMb?: number, userSurface?: ReadonlyArray<{ route: string, maxBodySizeMb: number }> }} [opts]
|
|
861
946
|
* `userSurface`: the plugins' user surfaces the vhost serves (not on a public vhost)
|
|
862
947
|
* @returns {{ name: string, fqdn: string, config: string }}
|
|
863
948
|
*/
|
|
864
|
-
export function renderTunnelVhost(accessMode,
|
|
949
|
+
export function renderTunnelVhost(accessMode, fqdn, domain, port, certPath, opts = {}) {
|
|
865
950
|
const { pathPrefix, maxBodySizeMb, userSurface } = opts;
|
|
866
951
|
if (accessMode === 'public') {
|
|
867
|
-
return renderPublicVhost(
|
|
952
|
+
return renderPublicVhost(fqdn, domain, port, certPath, {
|
|
868
953
|
pathPrefix,
|
|
869
954
|
maxBodySizeMb,
|
|
870
955
|
userSurface,
|
|
871
956
|
});
|
|
872
957
|
}
|
|
873
|
-
return renderGatekeeperTunnelVhost(
|
|
958
|
+
return renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
|
|
874
959
|
pathPrefix,
|
|
875
960
|
maxBodySizeMb,
|
|
876
961
|
userSurface,
|
|
877
|
-
restricted: accessMode !== 'authenticated',
|
|
878
962
|
});
|
|
879
963
|
}
|
|
880
964
|
|
|
@@ -882,7 +966,8 @@ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath,
|
|
|
882
966
|
* Build a Gatekeeper-backed nginx vhost config string.
|
|
883
967
|
* Both 'authenticated' and 'restricted' modes use this — the difference
|
|
884
968
|
* is handled server-side by Gatekeeper based on the tunnel's accessMode.
|
|
885
|
-
*
|
|
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.
|
|
886
971
|
*
|
|
887
972
|
* The subrequest names the tunnel in its path, `/authz/tunnel/<fqdn>`,
|
|
888
973
|
* written here as the agent-panel vhost writes its agent's label, and
|
|
@@ -904,7 +989,7 @@ function buildGatekeeperVhost(
|
|
|
904
989
|
certDirClean,
|
|
905
990
|
port,
|
|
906
991
|
domain,
|
|
907
|
-
{ pathPrefix, maxBodySizeMb, userSurface = []
|
|
992
|
+
{ pathPrefix, maxBodySizeMb, userSurface = [] } = {},
|
|
908
993
|
) {
|
|
909
994
|
const url = originalUrl(fqdn);
|
|
910
995
|
return `server {
|
|
@@ -965,12 +1050,9 @@ ${TUNNEL_PROXY_HEADERS}
|
|
|
965
1050
|
# Authentication failure -> redirect to Authelia login, back to this vhost
|
|
966
1051
|
error_page 401 =302 https://auth.${domain}/?rd=${url};
|
|
967
1052
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
error_page 403 = /internal/lamaste/authz;`
|
|
972
|
-
: ''
|
|
973
|
-
}
|
|
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;
|
|
974
1056
|
}
|
|
975
1057
|
}
|
|
976
1058
|
`;
|
|
@@ -1084,7 +1166,8 @@ export async function writeStaticSiteVhost(site, certDir, domain) {
|
|
|
1084
1166
|
* @param {object} site - as for writeStaticSiteVhost
|
|
1085
1167
|
* @param {string} certDir
|
|
1086
1168
|
* @param {string} [domain]
|
|
1087
|
-
* @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
|
|
1088
1171
|
*/
|
|
1089
1172
|
export function renderStaticSiteVhost(site, certDir, domain) {
|
|
1090
1173
|
let certDirClean = certDir;
|
|
@@ -1094,8 +1177,13 @@ export function renderStaticSiteVhost(site, certDir, domain) {
|
|
|
1094
1177
|
|
|
1095
1178
|
let autheliaBlock = '';
|
|
1096
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;
|
|
1097
1185
|
|
|
1098
|
-
if (
|
|
1186
|
+
if (gated) {
|
|
1099
1187
|
autheliaBlock = `
|
|
1100
1188
|
# Authelia forward authentication (AuthRequest implementation for nginx)
|
|
1101
1189
|
location /internal/authelia/authz {
|
|
@@ -1115,6 +1203,23 @@ export function renderStaticSiteVhost(site, certDir, domain) {
|
|
|
1115
1203
|
proxy_buffers 4 32k;
|
|
1116
1204
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
1117
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
|
+
}
|
|
1118
1223
|
`;
|
|
1119
1224
|
locationAuthDirectives = `
|
|
1120
1225
|
# Clear client-supplied identity headers (Authelia re-injects on success)
|
|
@@ -1151,20 +1256,21 @@ export function renderStaticSiteVhost(site, certDir, domain) {
|
|
|
1151
1256
|
add_header X-Content-Type-Options nosniff always;
|
|
1152
1257
|
${autheliaBlock}
|
|
1153
1258
|
location / {${locationAuthDirectives}
|
|
1154
|
-
${tryFiles};
|
|
1259
|
+
${closed ? 'return 403' : tryFiles};
|
|
1155
1260
|
}
|
|
1156
1261
|
${
|
|
1157
|
-
|
|
1262
|
+
gated
|
|
1158
1263
|
? `
|
|
1159
1264
|
# Redirect unauthenticated requests to Authelia login portal
|
|
1160
1265
|
error_page 401 =302 $redirection_url;
|
|
1266
|
+
error_page 403 = /internal/lamaste/refusal;
|
|
1161
1267
|
`
|
|
1162
1268
|
: ''
|
|
1163
1269
|
}
|
|
1164
1270
|
}
|
|
1165
1271
|
${aliasBlock}`;
|
|
1166
1272
|
|
|
1167
|
-
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
|
|
1273
|
+
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config, closed };
|
|
1168
1274
|
}
|
|
1169
1275
|
|
|
1170
1276
|
/**
|
|
@@ -1400,34 +1506,34 @@ async function handOverVhost(fromName, name, config, fqdn) {
|
|
|
1400
1506
|
}
|
|
1401
1507
|
|
|
1402
1508
|
/**
|
|
1403
|
-
* Unlink
|
|
1509
|
+
* Unlink the vhost of tunnel `fqdn` when it is still enabled although a
|
|
1404
1510
|
* plugin's site holds its hostname — a move the panel did not finish (it
|
|
1405
1511
|
* wrote the state, then stopped before the handover). Does not reload:
|
|
1406
1512
|
* the caller's next vhost install does, or it reloads itself. Returns
|
|
1407
1513
|
* whether it unlinked.
|
|
1408
1514
|
*
|
|
1409
|
-
* @param {string}
|
|
1515
|
+
* @param {string} fqdn the moved tunnel's hostname
|
|
1410
1516
|
*/
|
|
1411
|
-
export async function unlinkMovedAppVhost(
|
|
1412
|
-
const name =
|
|
1517
|
+
export async function unlinkMovedAppVhost(fqdn) {
|
|
1518
|
+
const name = appVhostName(fqdn);
|
|
1413
1519
|
if (!(await isSiteEnabled(name))) return false;
|
|
1414
1520
|
await disableSite(name);
|
|
1415
1521
|
return true;
|
|
1416
1522
|
}
|
|
1417
1523
|
|
|
1418
1524
|
/**
|
|
1419
|
-
* Serve a plugin's web surface on the hostname of tunnel
|
|
1420
|
-
*
|
|
1525
|
+
* Serve a plugin's web surface on the hostname of tunnel `fqdn` in one
|
|
1526
|
+
* reload (see {@link handOverVhost}).
|
|
1421
1527
|
*
|
|
1422
|
-
* @param {string}
|
|
1528
|
+
* @param {string} fqdn the tunnel's hostname
|
|
1423
1529
|
* @param {{ id: string, fqdn: string }} site
|
|
1424
1530
|
* @param {string} certDir
|
|
1425
1531
|
* @param {string} domain
|
|
1426
1532
|
* @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
|
|
1427
1533
|
*/
|
|
1428
|
-
export async function moveAppVhostToWebSurface(
|
|
1429
|
-
const { name,
|
|
1430
|
-
await handOverVhost(
|
|
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);
|
|
1431
1537
|
}
|
|
1432
1538
|
|
|
1433
1539
|
/**
|
|
@@ -1488,10 +1594,10 @@ export async function removeStaticSiteVhost(siteId) {
|
|
|
1488
1594
|
* Remove an app vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
1489
1595
|
* Idempotent: if files don't exist, proceeds silently.
|
|
1490
1596
|
*
|
|
1491
|
-
* @param {string}
|
|
1597
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1492
1598
|
*/
|
|
1493
|
-
export async function removeAppVhost(
|
|
1494
|
-
const name =
|
|
1599
|
+
export async function removeAppVhost(fqdn) {
|
|
1600
|
+
const name = appVhostName(fqdn);
|
|
1495
1601
|
await siteAction('remove', name);
|
|
1496
1602
|
|
|
1497
1603
|
const result = await testConfig();
|
|
@@ -1544,10 +1650,10 @@ export async function disableSite(name) {
|
|
|
1544
1650
|
/**
|
|
1545
1651
|
* Enable an app tunnel vhost (restore symlink), test and reload nginx.
|
|
1546
1652
|
*
|
|
1547
|
-
* @param {string}
|
|
1653
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1548
1654
|
*/
|
|
1549
|
-
export async function enableAppVhost(
|
|
1550
|
-
const name =
|
|
1655
|
+
export async function enableAppVhost(fqdn) {
|
|
1656
|
+
const name = appVhostName(fqdn);
|
|
1551
1657
|
await enableSite(name);
|
|
1552
1658
|
|
|
1553
1659
|
const result = await testConfig();
|
|
@@ -1563,10 +1669,10 @@ export async function enableAppVhost(subdomain) {
|
|
|
1563
1669
|
/**
|
|
1564
1670
|
* Disable an app tunnel vhost (remove symlink only, keep config), test and reload nginx.
|
|
1565
1671
|
*
|
|
1566
|
-
* @param {string}
|
|
1672
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1567
1673
|
*/
|
|
1568
|
-
export async function disableAppVhost(
|
|
1569
|
-
const name =
|
|
1674
|
+
export async function disableAppVhost(fqdn) {
|
|
1675
|
+
const name = appVhostName(fqdn);
|
|
1570
1676
|
await disableSite(name);
|
|
1571
1677
|
|
|
1572
1678
|
const result = await testConfig();
|
|
@@ -1612,19 +1718,13 @@ export async function enableIpVhost() {
|
|
|
1612
1718
|
*
|
|
1613
1719
|
* Written through safeWriteVhost (backup, test, rollback).
|
|
1614
1720
|
*
|
|
1615
|
-
* @param {string}
|
|
1721
|
+
* @param {string} fqdn - The hostname (e.g., "agent-my-agent.example.com")
|
|
1616
1722
|
* @param {string} domain - The base domain (e.g., "example.com")
|
|
1617
1723
|
* @param {number} port - The local port to proxy to
|
|
1618
1724
|
* @param {string} [certPath] - Optional cert directory path override (e.g. for wildcard certs)
|
|
1619
1725
|
*/
|
|
1620
|
-
export async function writeAgentPanelVhost(
|
|
1621
|
-
|
|
1622
|
-
domain,
|
|
1623
|
-
port,
|
|
1624
|
-
certPath,
|
|
1625
|
-
{ enabled = true } = {},
|
|
1626
|
-
) {
|
|
1627
|
-
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);
|
|
1628
1728
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
1629
1729
|
}
|
|
1630
1730
|
|
|
@@ -1646,13 +1746,14 @@ export async function writeAgentPanelVhost(
|
|
|
1646
1746
|
* cookie never does ({@link BACKEND_COOKIE_HEADER}): the agent panel's host
|
|
1647
1747
|
* is under the base domain like every other.
|
|
1648
1748
|
*
|
|
1649
|
-
* @param {string}
|
|
1749
|
+
* @param {string} fqdn - `agent-<label>.<domain>`
|
|
1650
1750
|
* @param {string} domain
|
|
1651
1751
|
* @param {number} port
|
|
1652
1752
|
* @param {string} [certPath]
|
|
1653
1753
|
* @returns {{ name: string, fqdn: string, config: string }}
|
|
1654
1754
|
*/
|
|
1655
|
-
export function renderAgentPanelVhost(
|
|
1755
|
+
export function renderAgentPanelVhost(fqdn, domain, port, certPath) {
|
|
1756
|
+
const subdomain = assertHostUnderDomain(fqdn, domain);
|
|
1656
1757
|
if (!/^agent-[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) {
|
|
1657
1758
|
throw new Error(`Invalid agent panel subdomain for nginx vhost: ${subdomain}`);
|
|
1658
1759
|
}
|
|
@@ -1660,7 +1761,6 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
|
|
|
1660
1761
|
throw new Error(`Invalid port for nginx vhost: ${port}`);
|
|
1661
1762
|
}
|
|
1662
1763
|
const label = subdomain.slice('agent-'.length);
|
|
1663
|
-
const fqdn = `${subdomain}.${domain}`;
|
|
1664
1764
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
1665
1765
|
let certDirClean = certDir;
|
|
1666
1766
|
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
@@ -1750,7 +1850,7 @@ ${BACKEND_COOKIE_HEADER}
|
|
|
1750
1850
|
}
|
|
1751
1851
|
`;
|
|
1752
1852
|
|
|
1753
|
-
return { name:
|
|
1853
|
+
return { name: agentPanelVhostName(fqdn), fqdn, config };
|
|
1754
1854
|
}
|
|
1755
1855
|
|
|
1756
1856
|
/**
|
|
@@ -1791,10 +1891,10 @@ export { safeWriteVhost as installVhost };
|
|
|
1791
1891
|
* Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
1792
1892
|
* Idempotent: if files don't exist, proceeds silently.
|
|
1793
1893
|
*
|
|
1794
|
-
* @param {string}
|
|
1894
|
+
* @param {string} fqdn - The agent panel hostname (e.g., "agent-my-agent.example.com")
|
|
1795
1895
|
*/
|
|
1796
|
-
export async function removeAgentPanelVhost(
|
|
1797
|
-
const name =
|
|
1896
|
+
export async function removeAgentPanelVhost(fqdn) {
|
|
1897
|
+
const name = agentPanelVhostName(fqdn);
|
|
1798
1898
|
await siteAction('remove', name);
|
|
1799
1899
|
|
|
1800
1900
|
const result = await testConfig();
|
|
@@ -1808,10 +1908,10 @@ export async function removeAgentPanelVhost(subdomain) {
|
|
|
1808
1908
|
/**
|
|
1809
1909
|
* Enable an agent panel vhost (restore symlink), test and reload nginx.
|
|
1810
1910
|
*
|
|
1811
|
-
* @param {string}
|
|
1911
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1812
1912
|
*/
|
|
1813
|
-
export async function enableAgentPanelVhost(
|
|
1814
|
-
const name =
|
|
1913
|
+
export async function enableAgentPanelVhost(fqdn) {
|
|
1914
|
+
const name = agentPanelVhostName(fqdn);
|
|
1815
1915
|
await enableSite(name);
|
|
1816
1916
|
|
|
1817
1917
|
const result = await testConfig();
|
|
@@ -1826,10 +1926,10 @@ export async function enableAgentPanelVhost(subdomain) {
|
|
|
1826
1926
|
/**
|
|
1827
1927
|
* Disable an agent panel vhost (remove symlink only, keep config), test and reload nginx.
|
|
1828
1928
|
*
|
|
1829
|
-
* @param {string}
|
|
1929
|
+
* @param {string} fqdn - The tunnel hostname
|
|
1830
1930
|
*/
|
|
1831
|
-
export async function disableAgentPanelVhost(
|
|
1832
|
-
const name =
|
|
1931
|
+
export async function disableAgentPanelVhost(fqdn) {
|
|
1932
|
+
const name = agentPanelVhostName(fqdn);
|
|
1833
1933
|
await disableSite(name);
|
|
1834
1934
|
|
|
1835
1935
|
const result = await testConfig();
|
|
@@ -1840,3 +1940,81 @@ export async function disableAgentPanelVhost(subdomain) {
|
|
|
1840
1940
|
|
|
1841
1941
|
await reload();
|
|
1842
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
|
+
}
|