@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
|
@@ -37,15 +37,27 @@
|
|
|
37
37
|
* tunnel or agent backend keeps Authelia's session cookie from it
|
|
38
38
|
* (`$lamaste_backend_cookie`), so an installed vhost from before that is
|
|
39
39
|
* rewritten too.
|
|
40
|
+
*
|
|
41
|
+
* From the multi-domain relay, tunnel vhosts are named by their full
|
|
42
|
+
* hostname and every domain has its own auth portal
|
|
43
|
+
* (`lamalibre-lamaste-auth-<domain>`): {@link renameLegacyVhosts} renames
|
|
44
|
+
* the ones an older version named by subdomain, in one reload, before the
|
|
45
|
+
* reconcile renders them.
|
|
40
46
|
*/
|
|
41
47
|
|
|
42
|
-
import { tunnelUserSurfaceRoutes } from '@lamalibre/lamaste';
|
|
43
|
-
import { withTunnelLock } from '@lamalibre/lamaste/server';
|
|
48
|
+
import { domainOfHost, tunnelUserSurfaceRoutes } from '@lamalibre/lamaste';
|
|
49
|
+
import { tunnelHost, vhostTunnelHost, withTunnelLock } from '@lamalibre/lamaste/server';
|
|
44
50
|
import {
|
|
51
|
+
LEGACY_AUTH_VHOST,
|
|
45
52
|
PANEL_DOMAIN_VHOST,
|
|
53
|
+
agentPanelVhostName,
|
|
54
|
+
appVhostName,
|
|
55
|
+
authVhostName,
|
|
46
56
|
installVhost,
|
|
47
57
|
isSiteEnabled,
|
|
58
|
+
legacyTunnelVhostName,
|
|
48
59
|
readInstalledVhost,
|
|
60
|
+
renameVhosts,
|
|
49
61
|
renderAgentPanelVhost,
|
|
50
62
|
renderAuthVhost,
|
|
51
63
|
renderPanelVhost,
|
|
@@ -53,13 +65,12 @@ import {
|
|
|
53
65
|
renderTunnelVhost,
|
|
54
66
|
vhostCertDir,
|
|
55
67
|
} from './nginx.js';
|
|
68
|
+
import { notifyAdminsBestEffort } from './notifications.js';
|
|
56
69
|
import { readSites, readTunnels } from './state.js';
|
|
57
70
|
|
|
58
71
|
/** A subdomain or site id as it may appear in a site name. */
|
|
59
72
|
const SITE_NAME_PART_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/;
|
|
60
73
|
|
|
61
|
-
const AUTH_VHOST = 'lamalibre-lamaste-auth';
|
|
62
|
-
|
|
63
74
|
/**
|
|
64
75
|
* Which of a tunnel's bound routes its vhost serves now — the panel's
|
|
65
76
|
* plugins and their hostings decide (`lib/user-surface.js`), loaded when
|
|
@@ -84,11 +95,10 @@ async function servedUserSurface(routes, tunnel) {
|
|
|
84
95
|
* @returns {{ name: string, fqdn: string, config: string } | null}
|
|
85
96
|
*/
|
|
86
97
|
export function expectedTunnelVhost(tunnel, certDir, userSurface = []) {
|
|
87
|
-
if (!managedTunnelVhostName(tunnel)
|
|
88
|
-
|
|
89
|
-
const domain = tunnel.fqdn.slice(tunnel.subdomain.length + 1);
|
|
98
|
+
if (!managedTunnelVhostName(tunnel)) return null;
|
|
99
|
+
const { fqdn, domain } = tunnelHost(tunnel);
|
|
90
100
|
if (tunnel.type === 'panel') {
|
|
91
|
-
return renderAgentPanelVhost(
|
|
101
|
+
return renderAgentPanelVhost(fqdn, domain, tunnel.port, certDir);
|
|
92
102
|
}
|
|
93
103
|
// As createTunnel and updateTunnel write it: the stored access mode
|
|
94
104
|
// (restricted when absent, as Gatekeeper reads it), the plugin route of a
|
|
@@ -97,21 +107,27 @@ export function expectedTunnelVhost(tunnel, certDir, userSurface = []) {
|
|
|
97
107
|
// rendered, and a bound tunnel cannot be made public).
|
|
98
108
|
const accessMode = tunnel.accessMode ?? 'restricted';
|
|
99
109
|
if (accessMode === 'public' && userSurface.length > 0) return null;
|
|
100
|
-
return renderTunnelVhost(accessMode,
|
|
110
|
+
return renderTunnelVhost(accessMode, fqdn, domain, tunnel.port, certDir, {
|
|
101
111
|
...(tunnel.type === 'plugin' && tunnel.pluginRoute ? { pathPrefix: tunnel.pluginRoute } : {}),
|
|
102
112
|
...(tunnel.maxBodySizeMb !== undefined ? { maxBodySizeMb: tunnel.maxBodySizeMb } : {}),
|
|
103
113
|
...(userSurface.length > 0 ? { userSurface } : {}),
|
|
104
114
|
});
|
|
105
115
|
}
|
|
106
116
|
|
|
107
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Site name of a tunnel's vhost — by its hostname — or null for a tunnel
|
|
119
|
+
* without one (internal, or whose state names no `<subdomain>.<domain>`).
|
|
120
|
+
*/
|
|
108
121
|
function managedTunnelVhostName(tunnel) {
|
|
109
|
-
if (tunnel.
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
if (typeof tunnel.subdomain !== 'string' || !SITE_NAME_PART_RE.test(tunnel.subdomain)) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const host = vhostTunnelHost(tunnel);
|
|
126
|
+
if (host === null) return null;
|
|
127
|
+
if (tunnel.type === 'panel') return agentPanelVhostName(host.fqdn);
|
|
112
128
|
// Tunnels persisted before `type` existed are app tunnels.
|
|
113
129
|
if (tunnel.type === 'app' || tunnel.type === 'plugin' || tunnel.type === undefined) {
|
|
114
|
-
return
|
|
130
|
+
return appVhostName(host.fqdn);
|
|
115
131
|
}
|
|
116
132
|
return null;
|
|
117
133
|
}
|
|
@@ -295,43 +311,58 @@ export async function reconcileUserSurfaceVhosts(
|
|
|
295
311
|
});
|
|
296
312
|
}
|
|
297
313
|
|
|
298
|
-
const defaultSiteDeps = {
|
|
314
|
+
const defaultSiteDeps = {
|
|
315
|
+
readSites,
|
|
316
|
+
readInstalledVhost,
|
|
317
|
+
installVhost,
|
|
318
|
+
isSiteEnabled,
|
|
319
|
+
notify: notifyAdminsBestEffort,
|
|
320
|
+
};
|
|
299
321
|
|
|
300
322
|
/**
|
|
301
323
|
* Reconcile the vhosts that proxy a browser to the panel or ask Authelia
|
|
302
|
-
* directly: the panel's own (`panel.<
|
|
303
|
-
* (`auth.<domain>`) and every static site but a plugin's web surface.
|
|
304
|
-
* it before the panel listens — then nothing can change a site
|
|
324
|
+
* directly: the panel's own (`panel.<primary>`), every active domain's auth
|
|
325
|
+
* portal (`auth.<domain>`) and every static site but a plugin's web surface.
|
|
326
|
+
* Call it before the panel listens — then nothing can change a site
|
|
327
|
+
* meanwhile.
|
|
305
328
|
*
|
|
306
329
|
* @param {Parameters<typeof logResult>[0]} log
|
|
307
|
-
* @param {string}
|
|
330
|
+
* @param {ReadonlyArray<{ name: string, primary: boolean }>} domains the active domains
|
|
308
331
|
* @param {typeof defaultSiteDeps} [deps]
|
|
309
332
|
* @returns {Promise<ReturnType<typeof newResult>>}
|
|
310
333
|
*/
|
|
311
|
-
export async function reconcileSiteVhosts(log,
|
|
334
|
+
export async function reconcileSiteVhosts(log, domains, deps = defaultSiteDeps) {
|
|
312
335
|
const result = newResult();
|
|
336
|
+
const primary = domains.find((d) => d.primary)?.name;
|
|
313
337
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
338
|
+
if (primary) {
|
|
339
|
+
await reconcileOne(
|
|
340
|
+
{
|
|
341
|
+
name: PANEL_DOMAIN_VHOST,
|
|
342
|
+
label: `panel.${primary}`,
|
|
343
|
+
render: (certDir) => renderPanelVhost(primary, certDir),
|
|
344
|
+
enabled: () => deps.isSiteEnabled(PANEL_DOMAIN_VHOST),
|
|
345
|
+
},
|
|
346
|
+
result,
|
|
347
|
+
deps,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
324
350
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
351
|
+
for (const domain of domains) {
|
|
352
|
+
const name = authVhostName(domain.name);
|
|
353
|
+
await reconcileOne(
|
|
354
|
+
{
|
|
355
|
+
name,
|
|
356
|
+
label: `auth.${domain.name}`,
|
|
357
|
+
render: (certDir) => renderAuthVhost(domain.name, certDir, { primary: domain.primary }),
|
|
358
|
+
enabled: () => deps.isSiteEnabled(name),
|
|
359
|
+
},
|
|
360
|
+
result,
|
|
361
|
+
deps,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const names = domains.map((d) => d.name);
|
|
335
366
|
|
|
336
367
|
let sites = [];
|
|
337
368
|
try {
|
|
@@ -339,17 +370,29 @@ export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
|
|
|
339
370
|
} catch (err) {
|
|
340
371
|
result.failed.push({ vhost: 'sites', error: err instanceof Error ? err.message : String(err) });
|
|
341
372
|
}
|
|
373
|
+
const closed = [];
|
|
342
374
|
for (const site of sites) {
|
|
343
375
|
if (typeof site.id !== 'string' || !SITE_NAME_PART_RE.test(site.id)) continue;
|
|
344
376
|
// A plugin's web surface is its plugin's to render (lib/web-surface.js
|
|
345
377
|
// `reconcileWebSurfaces`, once plugins and hostings can be read).
|
|
346
378
|
if (site.owner) continue;
|
|
379
|
+
|
|
347
380
|
const name = `lamalibre-lamaste-site-${site.id}`;
|
|
348
381
|
await reconcileOne(
|
|
349
382
|
{
|
|
350
383
|
name,
|
|
351
384
|
label: site.fqdn ?? site.id,
|
|
352
|
-
render: (certDir) =>
|
|
385
|
+
render: (certDir) => {
|
|
386
|
+
const rendered = renderStaticSiteVhost(
|
|
387
|
+
site,
|
|
388
|
+
certDir,
|
|
389
|
+
typeof site.fqdn === 'string' ? domainOfHost(site.fqdn, names) : null,
|
|
390
|
+
);
|
|
391
|
+
// Protected, but under none of the relay's domains (possible since
|
|
392
|
+
// 4.6): it renders closed — said once, at the end.
|
|
393
|
+
if (rendered.closed) closed.push(site.fqdn);
|
|
394
|
+
return rendered;
|
|
395
|
+
},
|
|
353
396
|
enabled: () => deps.isSiteEnabled(name),
|
|
354
397
|
// A site still waiting for its DNS or certificate has no vhost yet.
|
|
355
398
|
reportMissing: site.certIssued === true,
|
|
@@ -359,6 +402,71 @@ export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
|
|
|
359
402
|
);
|
|
360
403
|
}
|
|
361
404
|
|
|
405
|
+
if (closed.length > 0) {
|
|
406
|
+
log.warn(
|
|
407
|
+
{ sites: closed },
|
|
408
|
+
'protected static sites under none of the relay domains are closed (403)',
|
|
409
|
+
);
|
|
410
|
+
await deps.notify(
|
|
411
|
+
{
|
|
412
|
+
source: 'lamaste',
|
|
413
|
+
level: 'warning',
|
|
414
|
+
message: `Protected static sites under none of this relay's domains answer 403 to everyone: ${closed.join(', ')}. Add their domain, or turn protection off.`,
|
|
415
|
+
},
|
|
416
|
+
log,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
result.closed = closed;
|
|
420
|
+
|
|
362
421
|
logResult(log, 'site', result);
|
|
363
422
|
return result;
|
|
364
423
|
}
|
|
424
|
+
|
|
425
|
+
const defaultRenameDeps = { readTunnels, renameVhosts };
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Rename the vhosts an older version named by subdomain — every tunnel's
|
|
429
|
+
* (`lamalibre-lamaste-app-<subdomain>`, `-agent-panel-<subdomain>`) and the
|
|
430
|
+
* auth portal (`lamalibre-lamaste-auth`, the primary's) — to their names by
|
|
431
|
+
* hostname, in one reload ({@link renameVhosts} in nginx.js: same text, same
|
|
432
|
+
* link state; all of it undone when nginx refuses). Idempotent: a vhost
|
|
433
|
+
* already renamed, or never installed, is left alone. Before the panel
|
|
434
|
+
* listens, under the tunnel lock; a failure is logged and the old names keep
|
|
435
|
+
* serving (the next start tries again).
|
|
436
|
+
*
|
|
437
|
+
* @param {Parameters<typeof logResult>[0]} log
|
|
438
|
+
* @param {string} primary the primary domain
|
|
439
|
+
* @param {typeof defaultRenameDeps} [deps]
|
|
440
|
+
* @returns {Promise<string[]>} the names written
|
|
441
|
+
*/
|
|
442
|
+
export async function renameLegacyVhosts(log, primary, deps = defaultRenameDeps) {
|
|
443
|
+
return withTunnelLock(async () => {
|
|
444
|
+
const renames = [
|
|
445
|
+
{ from: LEGACY_AUTH_VHOST, to: authVhostName(primary), serverName: `auth.${primary}` },
|
|
446
|
+
];
|
|
447
|
+
for (const tunnel of await deps.readTunnels()) {
|
|
448
|
+
const to = managedTunnelVhostName(tunnel);
|
|
449
|
+
if (to === null) continue;
|
|
450
|
+
// Names by subdomain existed only on the primary domain, and an old
|
|
451
|
+
// file is the tunnel's only when it serves the tunnel's hostname — a
|
|
452
|
+
// same-subdomain tunnel of another domain never takes it.
|
|
453
|
+
if (tunnel.fqdn !== `${tunnel.subdomain}.${primary}`) continue;
|
|
454
|
+
renames.push({
|
|
455
|
+
from: legacyTunnelVhostName(tunnel.type, tunnel.subdomain),
|
|
456
|
+
to,
|
|
457
|
+
serverName: tunnel.fqdn,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const renamed = await deps.renameVhosts(renames);
|
|
462
|
+
if (renamed.length > 0) log.info({ renamed }, 'Renamed vhosts to their hostnames');
|
|
463
|
+
return renamed;
|
|
464
|
+
} catch (err) {
|
|
465
|
+
log.error(
|
|
466
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
467
|
+
'Renaming vhosts to their hostnames failed; the old names keep serving',
|
|
468
|
+
);
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
@@ -41,10 +41,12 @@ import {
|
|
|
41
41
|
import {
|
|
42
42
|
TUNNEL_MAX_BODY_MB,
|
|
43
43
|
WEB_TREES_ROOT,
|
|
44
|
+
hostnameHolder,
|
|
44
45
|
readWebTreeStore,
|
|
45
46
|
removeWebTreeStore,
|
|
46
47
|
stageWebTree,
|
|
47
48
|
switchWebTree,
|
|
49
|
+
tunnelHost,
|
|
48
50
|
webTreeStoreDir,
|
|
49
51
|
withTunnelLock,
|
|
50
52
|
} from '@lamalibre/lamaste/server';
|
|
@@ -53,12 +55,14 @@ import { recordAuditBestEffort } from './audit.js';
|
|
|
53
55
|
import { getCertPath, issueTunnelCert } from './certbot.js';
|
|
54
56
|
import { syncChisel } from './chisel.js';
|
|
55
57
|
import { getConfig } from './config.js';
|
|
58
|
+
import { domainOfHostname, pluginDomainName } from './domains.js';
|
|
56
59
|
import {
|
|
57
60
|
createGatekeeperGrant,
|
|
58
61
|
listResourceGrants,
|
|
59
62
|
revokeGatekeeperGrant,
|
|
60
63
|
} from './gatekeeper-client.js';
|
|
61
64
|
import {
|
|
65
|
+
appVhostName,
|
|
62
66
|
installVhost,
|
|
63
67
|
moveAppVhostToWebSurface,
|
|
64
68
|
readInstalledVhost,
|
|
@@ -208,15 +212,25 @@ export async function webSurfaceServing(route, tunnels) {
|
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
/**
|
|
211
|
-
* The
|
|
212
|
-
*
|
|
215
|
+
* The active domain a plugin's site is under — always one: it is created
|
|
216
|
+
* on its plugin's domain, and a domain a site uses cannot be removed.
|
|
213
217
|
*/
|
|
214
|
-
async function
|
|
218
|
+
async function siteDomain(site) {
|
|
219
|
+
const domain = await domainOfHostname(site.fqdn);
|
|
220
|
+
if (domain === null) throw new Error(`${site.fqdn} is under none of the relay's active domains`);
|
|
221
|
+
return domain;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The vhost of the site `site` (owned by plugin `route`, under `domain`)
|
|
226
|
+
* as it should be now, on certificate directory `certDir`.
|
|
227
|
+
*/
|
|
228
|
+
async function renderOwnedSite(site, route, certDir, tunnels, domain) {
|
|
215
229
|
const serving = await webSurfaceServing(route, tunnels);
|
|
216
230
|
const maxBodySizeMb = serving.plugin
|
|
217
231
|
? await internalTunnelBodyLimit(serving.plugin, tunnels)
|
|
218
232
|
: undefined;
|
|
219
|
-
return renderWebSurfaceVhost(site, certDir,
|
|
233
|
+
return renderWebSurfaceVhost(site, certDir, domain, {
|
|
220
234
|
route,
|
|
221
235
|
served: serving.served,
|
|
222
236
|
maxBodySizeMb: maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default,
|
|
@@ -234,12 +248,12 @@ async function reconcileOwnedSiteVhost(site, route, tunnels) {
|
|
|
234
248
|
// A move stopped between its state writes and the handover leaves the
|
|
235
249
|
// tunnel's vhost linked beside the site's, on the same hostname.
|
|
236
250
|
const moved = tunnels.find((t) => t.fqdn === site.fqdn && t.enabled === false && t.subdomain);
|
|
237
|
-
const unlinked = moved ? await deps.unlinkMovedAppVhost(moved.
|
|
251
|
+
const unlinked = moved ? await deps.unlinkMovedAppVhost(moved.fqdn) : false;
|
|
238
252
|
const installed = await deps.readInstalledVhost(siteVhostName(site));
|
|
253
|
+
const domain = await siteDomain(site);
|
|
239
254
|
const certDir =
|
|
240
|
-
(installed && vhostCertDir(installed)) ||
|
|
241
|
-
|
|
242
|
-
const { name, fqdn, config } = await renderOwnedSite(site, route, certDir, tunnels);
|
|
255
|
+
(installed && vhostCertDir(installed)) || (await deps.getCertPath(site.fqdn, domain));
|
|
256
|
+
const { name, fqdn, config } = await renderOwnedSite(site, route, certDir, tunnels, domain);
|
|
243
257
|
if (installed === config) {
|
|
244
258
|
if (unlinked) await deps.reloadNginx();
|
|
245
259
|
return unlinked;
|
|
@@ -328,10 +342,12 @@ export async function webSurfaceStatus(route) {
|
|
|
328
342
|
let offer = null;
|
|
329
343
|
if (!site && declared && !boundTunnel) {
|
|
330
344
|
const subdomain = declared.defaultSubdomain;
|
|
345
|
+
const domain = await pluginDomainName(plugin);
|
|
331
346
|
offer = {
|
|
332
347
|
subdomain,
|
|
348
|
+
domain,
|
|
333
349
|
accessMode: declared.accessMode,
|
|
334
|
-
available: (await hostnameTaken(subdomain, sites, tunnels)) === null,
|
|
350
|
+
available: (await hostnameTaken(subdomain, domain, sites, tunnels)) === null,
|
|
335
351
|
};
|
|
336
352
|
}
|
|
337
353
|
return {
|
|
@@ -369,21 +385,18 @@ export async function webSurfaceStatus(route) {
|
|
|
369
385
|
// ---------------------------------------------------------------------------
|
|
370
386
|
|
|
371
387
|
/**
|
|
372
|
-
* Why `subdomain`
|
|
373
|
-
*
|
|
374
|
-
*
|
|
388
|
+
* Why `subdomain` of `domain` cannot be a new web surface's hostname, or
|
|
389
|
+
* null when it can: reserved, held by a tunnel, or taken by a site (its
|
|
390
|
+
* hostname or an alias — or its name: site names are unique relay-wide).
|
|
375
391
|
*/
|
|
376
|
-
async function hostnameTaken(subdomain, sites, tunnels) {
|
|
392
|
+
async function hostnameTaken(subdomain, domain, sites, tunnels) {
|
|
377
393
|
if (!isWebSurfaceSubdomain(subdomain))
|
|
378
394
|
return `'${subdomain}' cannot be a web surface's subdomain`;
|
|
379
|
-
const fqdn = `${subdomain}.${
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
sites.some((s) => s.name === subdomain || s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn))
|
|
385
|
-
) {
|
|
386
|
-
return `a site holds '${subdomain}'`;
|
|
395
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
396
|
+
const holder = hostnameHolder(fqdn, { tunnels, sites });
|
|
397
|
+
if (holder) return `${holder.kind === 'tunnel' ? 'a tunnel' : 'a site'} holds '${fqdn}'`;
|
|
398
|
+
if (sites.some((s) => s.name === subdomain)) {
|
|
399
|
+
return `a site is named '${subdomain}'`;
|
|
387
400
|
}
|
|
388
401
|
return null;
|
|
389
402
|
}
|
|
@@ -446,7 +459,7 @@ async function serveApprovedTree(plugin, logger) {
|
|
|
446
459
|
}
|
|
447
460
|
|
|
448
461
|
/**
|
|
449
|
-
* Give plugin `route` its web surface on `subdomain` of the
|
|
462
|
+
* Give plugin `route` its web surface on `subdomain` of the plugin's domain
|
|
450
463
|
* (default: the one its manifest declares) with `accessMode` (default: the
|
|
451
464
|
* declared one): a certificate, the site entry, the vhost. The plugin must
|
|
452
465
|
* declare a web surface and own no site, and no tunnel may be bound as its
|
|
@@ -479,16 +492,23 @@ export function createWebSurface(route, input, { actor, logger }) {
|
|
|
479
492
|
'bound_to_tunnel',
|
|
480
493
|
);
|
|
481
494
|
}
|
|
482
|
-
const
|
|
495
|
+
const domain = await pluginDomainName(plugin);
|
|
496
|
+
const taken = await hostnameTaken(subdomain, domain, sites, tunnels);
|
|
483
497
|
if (taken) throw refuse(`Cannot use '${subdomain}': ${taken}`, 409, 'hostname_taken');
|
|
484
498
|
|
|
485
499
|
await serveApprovedTree(plugin, logger);
|
|
486
|
-
const fqdn = `${subdomain}.${
|
|
500
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
487
501
|
const cert = await deps.issueTunnelCert(fqdn, config.email);
|
|
488
502
|
const site = ownedSiteEntry({ id: crypto.randomUUID(), subdomain, fqdn, plugin, accessMode });
|
|
489
503
|
await writeSites([...sites, site]);
|
|
490
504
|
try {
|
|
491
|
-
const { name, config: vhost } = await renderOwnedSite(
|
|
505
|
+
const { name, config: vhost } = await renderOwnedSite(
|
|
506
|
+
site,
|
|
507
|
+
route,
|
|
508
|
+
cert.certPath,
|
|
509
|
+
tunnels,
|
|
510
|
+
domain,
|
|
511
|
+
);
|
|
492
512
|
await deps.installVhost(name, vhost, fqdn, { enabled: true });
|
|
493
513
|
} catch (err) {
|
|
494
514
|
await writeSites(sites);
|
|
@@ -697,13 +717,10 @@ export function moveWebSurfaceFromTunnel(route, tunnelId, { actor, logger }) {
|
|
|
697
717
|
let wroteSites = false;
|
|
698
718
|
let wroteTunnels = false;
|
|
699
719
|
try {
|
|
700
|
-
const
|
|
701
|
-
const installed = await deps.readInstalledVhost(
|
|
702
|
-
`lamalibre-lamaste-app-${tunnel.subdomain}`,
|
|
703
|
-
);
|
|
720
|
+
const { domain } = tunnelHost(tunnel);
|
|
721
|
+
const installed = await deps.readInstalledVhost(appVhostName(tunnel.fqdn));
|
|
704
722
|
const certDir =
|
|
705
|
-
(installed && vhostCertDir(installed)) ||
|
|
706
|
-
(await deps.getCertPath(tunnel.fqdn, config.domain));
|
|
723
|
+
(installed && vhostCertDir(installed)) || (await deps.getCertPath(tunnel.fqdn, domain));
|
|
707
724
|
// The state first — Gatekeeper reads both files on a change of
|
|
708
725
|
// either, so the old vhost's API requests already resolve to the
|
|
709
726
|
// site — then one reload hands the hostname over, then the port
|
|
@@ -712,7 +729,7 @@ export function moveWebSurfaceFromTunnel(route, tunnelId, { actor, logger }) {
|
|
|
712
729
|
wroteSites = true;
|
|
713
730
|
await writeTunnels(movedTunnels);
|
|
714
731
|
wroteTunnels = true;
|
|
715
|
-
await deps.moveAppVhostToWebSurface(tunnel.
|
|
732
|
+
await deps.moveAppVhostToWebSurface(tunnel.fqdn, site, certDir, domain, {
|
|
716
733
|
route,
|
|
717
734
|
served: (await webSurfaceServing(route, movedTunnels)).served,
|
|
718
735
|
maxBodySizeMb: await internalTunnelBodyLimit(plugin, movedTunnels),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fp from 'fastify-plugin';
|
|
2
2
|
import { getConfig } from '../lib/config.js';
|
|
3
3
|
import { findDirectoryUser } from '../lib/directory.js';
|
|
4
|
+
import { isPrimaryMember } from '../lib/domains.js';
|
|
4
5
|
import {
|
|
5
6
|
REFRESH_THRESHOLD,
|
|
6
7
|
validateUserSession,
|
|
@@ -38,11 +39,17 @@ async function userAccessSessionPlugin(fastify, _opts) {
|
|
|
38
39
|
return reply.code(401).send({ error: 'user_session_required' });
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
// The session's user must still hold the username, and be
|
|
42
|
-
// deleted user's username given to
|
|
43
|
-
// a disabled user
|
|
42
|
+
// The session's user must still hold the username, be enabled and be a
|
|
43
|
+
// member of the primary domain: a deleted user's username given to
|
|
44
|
+
// someone else never inherits it, and a disabled user, one whose
|
|
45
|
+
// membership ended or one made an external member is out at once.
|
|
44
46
|
const holder = await findDirectoryUser(result.payload.username);
|
|
45
|
-
if (
|
|
47
|
+
if (
|
|
48
|
+
!holder ||
|
|
49
|
+
holder.userId !== result.payload.userId ||
|
|
50
|
+
holder.status !== 'active' ||
|
|
51
|
+
!(await isPrimaryMember(holder.userId))
|
|
52
|
+
) {
|
|
46
53
|
return reply.code(401).send({ error: 'user_session_required' });
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
generateTotpSecret,
|
|
9
9
|
writeTotpToDatabase,
|
|
10
10
|
} from '../lib/authelia.js';
|
|
11
|
-
import { getConfig } from '../lib/config.js';
|
|
12
11
|
import { httpError } from '../lib/app-error.js';
|
|
13
12
|
import { changeUsers, usersFileError } from '../lib/directory.js';
|
|
13
|
+
import { invitationDomainsNow, invitationSignInDomain } from '../lib/domains.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Find an invitation by token using a constant-time comparison.
|
|
@@ -119,8 +119,11 @@ export default async function inviteRoutes(fastify, _opts) {
|
|
|
119
119
|
return reply.code(500).send({ error: 'Failed to process password' });
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
const grantedDomains = await invitationDomainsNow(invitation);
|
|
123
|
+
|
|
124
|
+
// Through the users lock: the directory gives the user their id, and
|
|
125
|
+
// the domains are rendered (their memberships, with the kinds the
|
|
126
|
+
// invitation named), before they can sign in.
|
|
124
127
|
try {
|
|
125
128
|
await changeUsers(
|
|
126
129
|
async () => {
|
|
@@ -149,6 +152,12 @@ export default async function inviteRoutes(fastify, _opts) {
|
|
|
149
152
|
// A removed user's grants, groups and second factors under the
|
|
150
153
|
// username go first; a username with an OIDC identity is refused.
|
|
151
154
|
creates: [invitation.username],
|
|
155
|
+
// The domains the invitation names that still are the ones it
|
|
156
|
+
// named (an invitation from before the multi-domain relay names
|
|
157
|
+
// none: the relay's one domain).
|
|
158
|
+
...(Array.isArray(invitation.domains)
|
|
159
|
+
? { domains: { [invitation.username]: grantedDomains } }
|
|
160
|
+
: {}),
|
|
152
161
|
expect: (users) => users[invitation.username]?.password === hashedPassword,
|
|
153
162
|
},
|
|
154
163
|
);
|
|
@@ -191,8 +200,9 @@ export default async function inviteRoutes(fastify, _opts) {
|
|
|
191
200
|
request.log.warn(err, 'Failed to update invitation status (user was created)');
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
|
|
195
|
-
const
|
|
203
|
+
// The sign-in of the first domain the user is a member of.
|
|
204
|
+
const loginDomain = await invitationSignInDomain(invitation);
|
|
205
|
+
const loginUrl = loginDomain ? `https://auth.${loginDomain}/` : null;
|
|
196
206
|
|
|
197
207
|
return {
|
|
198
208
|
ok: true,
|