@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
|
@@ -13,12 +13,18 @@ import {
|
|
|
13
13
|
SiteError,
|
|
14
14
|
withTunnelLock,
|
|
15
15
|
} from '@lamalibre/lamaste/server';
|
|
16
|
+
import { DomainNameSchema } from '@lamalibre/lamaste';
|
|
16
17
|
import { AppError } from '../../lib/app-error.js';
|
|
17
18
|
import { webSurfaceServing } from '../../lib/web-surface.js';
|
|
18
19
|
import { getConfig } from '../../lib/config.js';
|
|
19
20
|
import { readSites, writeSites, readTunnels, withSitesLock } from '../../lib/state.js';
|
|
20
21
|
import { writeStaticSiteVhost, removeStaticSiteVhost } from '../../lib/nginx.js';
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
activeDomainNames,
|
|
24
|
+
domainNames,
|
|
25
|
+
resolveActiveDomain,
|
|
26
|
+
syncDomainAccess,
|
|
27
|
+
} from '../../lib/domains.js';
|
|
22
28
|
import { issueTunnelCert, getCertPath, issueSiteCert } from '../../lib/certbot.js';
|
|
23
29
|
import {
|
|
24
30
|
createSiteDirectory,
|
|
@@ -57,6 +63,8 @@ const CreateSiteSchema = z.object({
|
|
|
57
63
|
aliases: z.array(AliasSchema).max(10, 'At most 10 aliases').optional(),
|
|
58
64
|
spaMode: z.boolean().optional().default(false),
|
|
59
65
|
autheliaProtected: z.boolean().optional().default(false),
|
|
66
|
+
/** A managed site's domain (an active one; the primary when absent). */
|
|
67
|
+
domain: DomainNameSchema.optional(),
|
|
60
68
|
});
|
|
61
69
|
|
|
62
70
|
const UpdateSiteSchema = z.object({
|
|
@@ -107,8 +115,31 @@ function buildTunnelReadDeps() {
|
|
|
107
115
|
return { readTunnels };
|
|
108
116
|
}
|
|
109
117
|
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Authelia renders every site's protection together with the domains'
|
|
120
|
+
* members (lib/domains.js) — given the sites being saved.
|
|
121
|
+
*/
|
|
122
|
+
function buildAutheliaDeps(logger) {
|
|
123
|
+
return {
|
|
124
|
+
renderAccess: async (sites) => {
|
|
125
|
+
await syncDomainAccess({ logger, sites });
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The active domain a managed site is put on (the primary unless named), as
|
|
132
|
+
* a SiteError the create route answers like its other refusals.
|
|
133
|
+
*
|
|
134
|
+
* @param {string | undefined} requested
|
|
135
|
+
*/
|
|
136
|
+
async function siteDomain(requested) {
|
|
137
|
+
try {
|
|
138
|
+
return await resolveActiveDomain(requested);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (Number.isInteger(err.statusCode)) throw new SiteError(err.message, 'DOMAIN_NOT_CONFIGURED');
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
112
143
|
}
|
|
113
144
|
|
|
114
145
|
/**
|
|
@@ -119,7 +150,6 @@ function siteErrorStatus(code) {
|
|
|
119
150
|
case 'CUSTOM_DOMAIN_REQUIRED':
|
|
120
151
|
case 'NAME_IN_USE':
|
|
121
152
|
case 'RESERVED_NAME':
|
|
122
|
-
case 'NAME_TUNNEL_COLLISION':
|
|
123
153
|
case 'FQDN_IN_USE':
|
|
124
154
|
case 'FQDN_TUNNEL_COLLISION':
|
|
125
155
|
case 'NOT_CUSTOM':
|
|
@@ -127,6 +157,7 @@ function siteErrorStatus(code) {
|
|
|
127
157
|
case 'INVALID_ALIAS':
|
|
128
158
|
case 'ALIAS_IN_USE':
|
|
129
159
|
case 'DNS_MISMATCH':
|
|
160
|
+
case 'NOT_UNDER_DOMAIN':
|
|
130
161
|
return 400;
|
|
131
162
|
case 'NOT_FOUND':
|
|
132
163
|
return 404;
|
|
@@ -227,7 +258,6 @@ export default async function sitesRoutes(fastify, _opts) {
|
|
|
227
258
|
const filesDeps = buildFilesDeps();
|
|
228
259
|
const siteStateDeps = buildSiteStateDeps();
|
|
229
260
|
const tunnelReadDeps = buildTunnelReadDeps();
|
|
230
|
-
const autheliaDeps = buildAutheliaDeps();
|
|
231
261
|
|
|
232
262
|
// GET /api/sites
|
|
233
263
|
//
|
|
@@ -288,9 +318,15 @@ export default async function sitesRoutes(fastify, _opts) {
|
|
|
288
318
|
error: 'Domain and email must be configured before creating sites',
|
|
289
319
|
});
|
|
290
320
|
}
|
|
291
|
-
|
|
321
|
+
if (body.domain !== undefined && body.type !== 'managed') {
|
|
322
|
+
return reply.code(400).send({
|
|
323
|
+
error: "A custom site's hostname names its domain — `domain` is for managed sites",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
292
326
|
try {
|
|
293
|
-
|
|
327
|
+
// The domain is resolved under the hostname locks — those a domain's
|
|
328
|
+
// removal takes — so the site never lands on a removed domain.
|
|
329
|
+
const result = await withHostnameLocks(async () =>
|
|
294
330
|
createSite({
|
|
295
331
|
name: body.name,
|
|
296
332
|
type: body.type,
|
|
@@ -298,7 +334,9 @@ export default async function sitesRoutes(fastify, _opts) {
|
|
|
298
334
|
aliases: body.aliases,
|
|
299
335
|
spaMode: body.spaMode,
|
|
300
336
|
autheliaProtected: body.autheliaProtected,
|
|
301
|
-
domain:
|
|
337
|
+
domain: await siteDomain(body.domain),
|
|
338
|
+
domains: await activeDomainNames(),
|
|
339
|
+
relayDomains: await domainNames(),
|
|
302
340
|
email: config.email,
|
|
303
341
|
nginx: nginxDeps,
|
|
304
342
|
certbot: certbotDeps,
|
|
@@ -388,14 +426,15 @@ export default async function sitesRoutes(fastify, _opts) {
|
|
|
388
426
|
autheliaProtected: body.autheliaProtected,
|
|
389
427
|
allowedUsers: body.allowedUsers,
|
|
390
428
|
aliases: body.aliases,
|
|
391
|
-
|
|
429
|
+
domains: await activeDomainNames(),
|
|
430
|
+
relayDomains: await domainNames(),
|
|
392
431
|
email: config.email,
|
|
393
432
|
serverIp: config.ip,
|
|
394
433
|
nginx: nginxDeps,
|
|
395
434
|
certbot: certbotDeps,
|
|
396
435
|
siteState: siteStateDeps,
|
|
397
436
|
tunnelState: tunnelReadDeps,
|
|
398
|
-
authelia:
|
|
437
|
+
authelia: buildAutheliaDeps(request.log),
|
|
399
438
|
logger: request.log,
|
|
400
439
|
});
|
|
401
440
|
});
|
|
@@ -444,7 +483,7 @@ export default async function sitesRoutes(fastify, _opts) {
|
|
|
444
483
|
return verifyDns({
|
|
445
484
|
id,
|
|
446
485
|
serverIp: config.ip,
|
|
447
|
-
|
|
486
|
+
domains: await activeDomainNames(),
|
|
448
487
|
email: config.email,
|
|
449
488
|
nginx: nginxDeps,
|
|
450
489
|
certbot: certbotDeps,
|
|
@@ -22,16 +22,20 @@ import {
|
|
|
22
22
|
updateTunnel,
|
|
23
23
|
TUNNEL_MAX_BODY_MB,
|
|
24
24
|
grantedTunnelsFor,
|
|
25
|
+
hostnameHolder,
|
|
25
26
|
TunnelError,
|
|
26
27
|
} from '@lamalibre/lamaste/server';
|
|
27
28
|
import {
|
|
28
29
|
AGENT_LABEL_REGEX,
|
|
30
|
+
DomainNameSchema,
|
|
29
31
|
INTERNAL_PLUGIN_PORT_RANGE,
|
|
30
32
|
PLUGIN_AGENT_CN_PREFIX,
|
|
31
33
|
RESERVED_TUNNEL_PORTS,
|
|
32
34
|
isInternalPluginPort,
|
|
33
35
|
} from '@lamalibre/lamaste';
|
|
34
36
|
import { getConfig } from '../../lib/config.js';
|
|
37
|
+
import { primaryDomainName, resolveActiveDomain } from '../../lib/domains.js';
|
|
38
|
+
import { sendFailure } from '../../lib/app-error.js';
|
|
35
39
|
import { readTunnels, readSites } from '../../lib/state.js';
|
|
36
40
|
import { getAgentHostFactsHash, loadAgentRegistry } from '../../lib/mtls.js';
|
|
37
41
|
import { getChiselCredentialIssuedAt } from '../../lib/chisel-users.js';
|
|
@@ -147,6 +151,9 @@ const CreateTunnelSchema = z
|
|
|
147
151
|
agentLabel: AgentLabelSchema.optional(),
|
|
148
152
|
accessMode: AccessModeSchema.optional().default('restricted'),
|
|
149
153
|
maxBodySizeMb: MaxBodySizeSchema.optional(),
|
|
154
|
+
// The base domain the hostname is under (an active one; the primary
|
|
155
|
+
// when absent). An agent panel is always on the primary.
|
|
156
|
+
domain: DomainNameSchema.optional(),
|
|
150
157
|
})
|
|
151
158
|
.refine((d) => d.type !== 'plugin' || (d.pluginName && d.agentLabel), {
|
|
152
159
|
message: 'pluginName and agentLabel are required for plugin tunnels',
|
|
@@ -227,9 +234,8 @@ function maxBodyCeiling(request) {
|
|
|
227
234
|
* every workflow giving a site a hostname holds too.
|
|
228
235
|
*/
|
|
229
236
|
async function siteServing(fqdn) {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
return site ? `by a static site ('${site.name}')` : null;
|
|
237
|
+
const holder = hostnameHolder(fqdn, { sites: await readSites() });
|
|
238
|
+
return holder ? `by a static site ('${holder.name}')` : null;
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
/**
|
|
@@ -342,6 +348,7 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
342
348
|
tunnels: carried.map((t) => ({
|
|
343
349
|
port: t.port,
|
|
344
350
|
subdomain: t.subdomain,
|
|
351
|
+
fqdn: t.fqdn,
|
|
345
352
|
})),
|
|
346
353
|
// The agent-hosted plugins this agent hosts: the agent fetches a
|
|
347
354
|
// plugin's config when it does not hold the secret issued at
|
|
@@ -501,6 +508,17 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
501
508
|
error: 'Domain and email must be configured before creating tunnels',
|
|
502
509
|
});
|
|
503
510
|
}
|
|
511
|
+
let domain;
|
|
512
|
+
try {
|
|
513
|
+
domain = await resolveActiveDomain(body.domain);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
return sendFailure(request, reply, err, 'Failed to read the domains');
|
|
516
|
+
}
|
|
517
|
+
if (type === 'panel' && domain !== (await primaryDomainName())) {
|
|
518
|
+
return reply
|
|
519
|
+
.code(400)
|
|
520
|
+
.send({ error: 'An agent panel is served on the primary domain only' });
|
|
521
|
+
}
|
|
504
522
|
|
|
505
523
|
try {
|
|
506
524
|
const tunnel = await createTunnel({
|
|
@@ -512,7 +530,11 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
512
530
|
pluginName,
|
|
513
531
|
agentLabel,
|
|
514
532
|
maxBodySizeMb,
|
|
515
|
-
domain
|
|
533
|
+
domain,
|
|
534
|
+
// Under the tunnel lock: the domain may have been removed since.
|
|
535
|
+
assertDomainActive: async (d) => {
|
|
536
|
+
await resolveActiveDomain(d);
|
|
537
|
+
},
|
|
516
538
|
email: config.email,
|
|
517
539
|
nginx: nginxDeps,
|
|
518
540
|
certbot: certbotDeps,
|
|
@@ -524,6 +546,9 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
524
546
|
});
|
|
525
547
|
return reply.code(201).send({ ok: true, tunnel });
|
|
526
548
|
} catch (err) {
|
|
549
|
+
if (Number.isInteger(err.statusCode)) {
|
|
550
|
+
return sendFailure(request, reply, err, 'Failed to create tunnel');
|
|
551
|
+
}
|
|
527
552
|
if (err instanceof TunnelError) {
|
|
528
553
|
const status = tunnelErrorStatus(err.code);
|
|
529
554
|
return reply.code(status).send({
|
|
@@ -589,7 +614,6 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
589
614
|
agentLabel: body.agentLabel,
|
|
590
615
|
accessMode: body.accessMode,
|
|
591
616
|
maxBodySizeMb: body.maxBodySizeMb,
|
|
592
|
-
domain: config.domain,
|
|
593
617
|
email: config.email,
|
|
594
618
|
nginx: nginxDeps,
|
|
595
619
|
certbot: certbotDeps,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
USERNAME_MAX_LENGTH,
|
|
4
|
+
USERNAME_MIN_LENGTH,
|
|
5
|
+
USERNAME_REGEX,
|
|
6
|
+
isLamasteOwnedGroup,
|
|
7
|
+
} from '@lamalibre/lamaste';
|
|
3
8
|
import {
|
|
4
9
|
readUsers,
|
|
5
10
|
readUsersRaw,
|
|
@@ -9,7 +14,7 @@ import {
|
|
|
9
14
|
writeTotpToDatabase,
|
|
10
15
|
} from '../../lib/authelia.js';
|
|
11
16
|
import { revokeUserRegistryTokens } from '../../lib/registry-credential.js';
|
|
12
|
-
import { httpError } from '../../lib/app-error.js';
|
|
17
|
+
import { httpError, sendFailure } from '../../lib/app-error.js';
|
|
13
18
|
import { actorFromRequest } from '../../lib/actor.js';
|
|
14
19
|
import { recordAuditBestEffort } from '../../lib/audit.js';
|
|
15
20
|
import {
|
|
@@ -21,6 +26,19 @@ import {
|
|
|
21
26
|
getDirectoryUser,
|
|
22
27
|
usersFileError,
|
|
23
28
|
} from '../../lib/directory.js';
|
|
29
|
+
import {
|
|
30
|
+
MembershipInputSchema,
|
|
31
|
+
adminGroups,
|
|
32
|
+
adminGroupsOf,
|
|
33
|
+
assertAssignableGroups,
|
|
34
|
+
domainNames,
|
|
35
|
+
domainsByUser,
|
|
36
|
+
domainsForNewUser,
|
|
37
|
+
domainsOfUser,
|
|
38
|
+
externalMemberGroupsError,
|
|
39
|
+
normalizeMemberships,
|
|
40
|
+
setUserDomains,
|
|
41
|
+
} from '../../lib/domains.js';
|
|
24
42
|
|
|
25
43
|
const CreateUserSchema = z.object({
|
|
26
44
|
username: z
|
|
@@ -34,7 +52,11 @@ const CreateUserSchema = z.object({
|
|
|
34
52
|
displayname: z.string().min(1).max(100),
|
|
35
53
|
email: z.string().email(),
|
|
36
54
|
password: z.string().min(8).max(128),
|
|
37
|
-
groups: z.array(z.string()).optional().default([]),
|
|
55
|
+
groups: z.array(z.string()).max(100).optional().default([]),
|
|
56
|
+
// The domains the user is a member of — a name (a member) or
|
|
57
|
+
// `{ name, kind }` — required while the relay has two or more; with one,
|
|
58
|
+
// the user becomes its member.
|
|
59
|
+
domains: z.array(MembershipInputSchema).max(50).optional(),
|
|
38
60
|
});
|
|
39
61
|
|
|
40
62
|
const UsernameParamSchema = z.object({
|
|
@@ -50,10 +72,14 @@ const UpdateUserSchema = z
|
|
|
50
72
|
displayname: z.string().min(1).max(100).optional(),
|
|
51
73
|
email: z.string().email().optional(),
|
|
52
74
|
password: z.string().min(8).max(128).optional(),
|
|
53
|
-
groups: z.array(z.string()).optional(),
|
|
75
|
+
groups: z.array(z.string()).max(100).optional(),
|
|
54
76
|
// Authelia's `disabled: true`: the user cannot sign in, every plugin
|
|
55
77
|
// audience shows them disabled, and the user surface refuses them.
|
|
56
78
|
disabled: z.boolean().optional(),
|
|
79
|
+
// The domains the user is a member of afterwards — exactly these (the
|
|
80
|
+
// difference applied in one transaction, each membership audited): a
|
|
81
|
+
// name (a member) or `{ name, kind }`.
|
|
82
|
+
domains: z.array(MembershipInputSchema).max(50).optional(),
|
|
57
83
|
})
|
|
58
84
|
.refine((data) => Object.keys(data).length > 0, {
|
|
59
85
|
message: 'At least one field must be provided',
|
|
@@ -76,17 +102,28 @@ async function readUsersOrFail() {
|
|
|
76
102
|
}
|
|
77
103
|
}
|
|
78
104
|
|
|
79
|
-
async function writeUsersOrFail(usersData) {
|
|
105
|
+
async function writeUsersOrFail(usersData, opts) {
|
|
80
106
|
try {
|
|
81
|
-
await writeUsers(usersData);
|
|
107
|
+
await writeUsers(usersData, opts);
|
|
82
108
|
} catch (err) {
|
|
83
109
|
throw httpError('Failed to update user database', 500, { cause: err });
|
|
84
110
|
}
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The groups an administrator gave a user as a sorted list — the groups
|
|
115
|
+
* Lamaste owns left out (users.yml may hold none). `domains`: every domain
|
|
116
|
+
* record.
|
|
117
|
+
*/
|
|
118
|
+
function groupsOf(entry, domains) {
|
|
119
|
+
return adminGroups(entry?.groups, domains).sort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The groups Lamaste owns that a users.yml entry holds — kept by every edit. */
|
|
123
|
+
function ownedGroupsOf(entry, domains) {
|
|
124
|
+
return Array.isArray(entry?.groups)
|
|
125
|
+
? entry.groups.filter((g) => isLamasteOwnedGroup(g, domains))
|
|
126
|
+
: [];
|
|
90
127
|
}
|
|
91
128
|
|
|
92
129
|
/** Whether two lists hold the same strings. */
|
|
@@ -101,22 +138,6 @@ function isEnabledAdmin(entry) {
|
|
|
101
138
|
);
|
|
102
139
|
}
|
|
103
140
|
|
|
104
|
-
/**
|
|
105
|
-
* Answer a refusal a users change threw (an error with `statusCode`: its
|
|
106
|
-
* message and `code`), or a 500 for anything else — logged either way when
|
|
107
|
-
* it is the panel's own failure.
|
|
108
|
-
*/
|
|
109
|
-
function sendFailure(request, reply, err, fallback) {
|
|
110
|
-
if (Number.isInteger(err.statusCode)) {
|
|
111
|
-
if (err.statusCode >= 500) request.log.error(err.cause ?? err, err.message);
|
|
112
|
-
return reply
|
|
113
|
-
.code(err.statusCode)
|
|
114
|
-
.send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
|
115
|
-
}
|
|
116
|
-
request.log.error(err, fallback);
|
|
117
|
-
return reply.code(500).send({ error: fallback });
|
|
118
|
-
}
|
|
119
|
-
|
|
120
141
|
export default async function usersRoutes(fastify, _opts) {
|
|
121
142
|
// GET /api/users — list all users (no sensitive fields): users.yml's, with
|
|
122
143
|
// each one's directory id (null until the directory has seen them, within
|
|
@@ -130,13 +151,17 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
130
151
|
try {
|
|
131
152
|
const users = await readUsers();
|
|
132
153
|
const directory = await directoryUsersByUsername();
|
|
154
|
+
const memberships = await domainsByUser();
|
|
155
|
+
const domains = await domainNames();
|
|
133
156
|
const listed = users
|
|
134
157
|
.map((user) => {
|
|
135
158
|
const known = directory.get(user.username);
|
|
136
159
|
return {
|
|
137
160
|
...user,
|
|
161
|
+
groups: groupsOf(user, domains),
|
|
138
162
|
userId: known?.userId ?? null,
|
|
139
163
|
status: known?.status ?? (user.disabled ? 'disabled' : 'active'),
|
|
164
|
+
domains: (known && memberships.get(known.userId)) ?? [],
|
|
140
165
|
};
|
|
141
166
|
})
|
|
142
167
|
.sort((a, b) => a.username.localeCompare(b.username));
|
|
@@ -160,6 +185,17 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
160
185
|
async (request, reply) => {
|
|
161
186
|
const body = CreateUserSchema.parse(request.body);
|
|
162
187
|
|
|
188
|
+
let domains;
|
|
189
|
+
try {
|
|
190
|
+
assertAssignableGroups(body.groups);
|
|
191
|
+
domains = await domainsForNewUser(body.domains, { required: true });
|
|
192
|
+
if (body.groups.length > 0 && domains.some((m) => m.kind === 'external')) {
|
|
193
|
+
throw externalMemberGroupsError(400);
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return sendFailure(request, reply, err, 'Failed to read the domains');
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
try {
|
|
164
200
|
// Refused before a password is hashed; checked again under the lock.
|
|
165
201
|
if ((await readUsersOrFail()).users[body.username]) {
|
|
@@ -198,6 +234,7 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
198
234
|
// A removed user's grants, groups and second factors under the
|
|
199
235
|
// username go first; a username with an OIDC identity is refused.
|
|
200
236
|
creates: [body.username],
|
|
237
|
+
domains: { [body.username]: domains },
|
|
201
238
|
expect: (users) => users[body.username]?.password === hash,
|
|
202
239
|
},
|
|
203
240
|
));
|
|
@@ -217,6 +254,7 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
217
254
|
disabled: false,
|
|
218
255
|
userId: created?.userId ?? null,
|
|
219
256
|
status: 'active',
|
|
257
|
+
domains: created ? await domainsOfUser(created.userId) : [],
|
|
220
258
|
},
|
|
221
259
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
222
260
|
});
|
|
@@ -248,68 +286,149 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
248
286
|
}
|
|
249
287
|
}
|
|
250
288
|
|
|
289
|
+
const domains = await domainNames();
|
|
290
|
+
|
|
251
291
|
/** Whether users.yml's entry holds every field this request set. */
|
|
252
292
|
const intended = (entry) =>
|
|
253
293
|
entry !== undefined &&
|
|
254
294
|
entry !== null &&
|
|
255
295
|
(body.displayname === undefined || entry.displayname === body.displayname) &&
|
|
256
296
|
(body.email === undefined || entry.email === body.email) &&
|
|
257
|
-
(body.groups === undefined ||
|
|
297
|
+
(body.groups === undefined ||
|
|
298
|
+
sameList(groupsOf(entry, domains), [...body.groups].sort())) &&
|
|
258
299
|
(passwordHash === undefined || entry.password === passwordHash) &&
|
|
259
300
|
(body.disabled === undefined || (entry.disabled === true) === body.disabled);
|
|
260
301
|
|
|
302
|
+
const { domains: memberOf, ...fields } = body;
|
|
303
|
+
const memberships = memberOf === undefined ? undefined : normalizeMemberships(memberOf);
|
|
304
|
+
const known = await findDirectoryUser(username);
|
|
305
|
+
const userId = known?.userId ?? null;
|
|
306
|
+
if (memberships !== undefined && !userId) {
|
|
307
|
+
return reply.code(409).send({
|
|
308
|
+
error: 'The directory has not seen this user yet — try again within a minute',
|
|
309
|
+
code: 'user_not_in_directory',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const current = userId ? await domainsOfUser(userId) : [];
|
|
313
|
+
const external = (memberships ?? current).some((m) => m.kind === 'external');
|
|
314
|
+
// Refused before anything is written: groups kept or given to a user
|
|
315
|
+
// who is (or becomes) an external member. Checked again under the
|
|
316
|
+
// users lock, by each step.
|
|
317
|
+
if (external) {
|
|
318
|
+
let groups = body.groups;
|
|
319
|
+
if (groups === undefined) {
|
|
320
|
+
try {
|
|
321
|
+
groups = await adminGroupsOf(username);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
return sendFailure(request, reply, err, 'Failed to read user database');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (groups.length > 0) {
|
|
327
|
+
return sendFailure(request, reply, externalMemberGroupsError(409), 'Refused');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// A user leaving every external membership with groups given in the
|
|
331
|
+
// same request: the memberships first, then the groups (each step is
|
|
332
|
+
// checked against the other under the users lock).
|
|
333
|
+
const membershipsFirst =
|
|
334
|
+
memberships !== undefined && !external && current.some((m) => m.kind === 'external');
|
|
335
|
+
|
|
336
|
+
const warnings = [];
|
|
337
|
+
const changeMemberships = async () => {
|
|
338
|
+
if (memberships === undefined) return;
|
|
339
|
+
const membership = await setUserDomains(userId, memberships, {
|
|
340
|
+
actor: actorFromRequest(request),
|
|
341
|
+
logger: request.log,
|
|
342
|
+
});
|
|
343
|
+
warnings.push(...(membership.warnings ?? []));
|
|
344
|
+
};
|
|
345
|
+
if (membershipsFirst) {
|
|
346
|
+
try {
|
|
347
|
+
await changeMemberships();
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return sendFailure(request, reply, err, 'Failed to change the memberships');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
261
353
|
let outcome;
|
|
262
354
|
let before = null;
|
|
263
355
|
try {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
356
|
+
// Only memberships change: users.yml is not written, the user is read.
|
|
357
|
+
if (Object.keys(fields).length === 0) {
|
|
358
|
+
const usersData = await readUsersOrFail();
|
|
359
|
+
const user = usersData.users[username];
|
|
360
|
+
if (!user) throw httpError('User not found', 404);
|
|
361
|
+
before = { groups: groupsOf(user, domains), disabled: user.disabled === true };
|
|
362
|
+
outcome = { result: user, warnings: [], persisted: true };
|
|
363
|
+
} else
|
|
364
|
+
outcome = await changeUsers(
|
|
365
|
+
async () => {
|
|
366
|
+
const usersData = await readUsersOrFail();
|
|
367
|
+
const user = usersData.users[username];
|
|
368
|
+
if (!user) throw httpError('User not found', 404);
|
|
369
|
+
// As it was before this request (a second attempt finds it changed already).
|
|
370
|
+
before ??= { groups: groupsOf(user, domains), disabled: user.disabled === true };
|
|
371
|
+
if (body.groups !== undefined) {
|
|
372
|
+
assertAssignableGroups(body.groups, before.groups);
|
|
373
|
+
// Checked under the users lock, which a change of kind takes too.
|
|
374
|
+
if (
|
|
375
|
+
body.groups.length > 0 &&
|
|
376
|
+
userId &&
|
|
377
|
+
(await domainsOfUser(userId)).some((m) => m.kind === 'external')
|
|
378
|
+
) {
|
|
379
|
+
throw externalMemberGroupsError(409);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (body.disabled === true && isEnabledAdmin(user)) {
|
|
383
|
+
const enabledAdmins = Object.values(usersData.users).filter(isEnabledAdmin).length;
|
|
384
|
+
if (enabledAdmins <= 1) {
|
|
385
|
+
throw httpError('Cannot disable the last enabled administrator', 400, {
|
|
386
|
+
code: 'last_admin',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
277
389
|
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (body.displayname !== undefined) user.displayname = body.displayname;
|
|
281
|
-
if (body.email !== undefined) user.email = body.email;
|
|
282
|
-
if (body.groups !== undefined) user.groups = body.groups;
|
|
283
|
-
if (passwordHash !== undefined) user.password = passwordHash;
|
|
284
|
-
if (body.disabled === true) user.disabled = true;
|
|
285
|
-
if (body.disabled === false) delete user.disabled;
|
|
286
390
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
391
|
+
if (body.displayname !== undefined) user.displayname = body.displayname;
|
|
392
|
+
if (body.email !== undefined) user.email = body.email;
|
|
393
|
+
if (body.groups !== undefined) {
|
|
394
|
+
user.groups = [...body.groups, ...ownedGroupsOf(user, domains)];
|
|
395
|
+
}
|
|
396
|
+
if (passwordHash !== undefined) user.password = passwordHash;
|
|
397
|
+
if (body.disabled === true) user.disabled = true;
|
|
398
|
+
if (body.disabled === false) delete user.disabled;
|
|
399
|
+
|
|
400
|
+
await writeUsersOrFail(usersData);
|
|
401
|
+
return user;
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
actor: actorFromRequest(request),
|
|
405
|
+
logger: request.log,
|
|
406
|
+
// Authelia's own password reset may rewrite users.yml under the
|
|
407
|
+
// change: it is checked after the rendering, and made once more.
|
|
408
|
+
expect: (users) => intended(users[username]),
|
|
409
|
+
},
|
|
410
|
+
);
|
|
298
411
|
} catch (err) {
|
|
299
412
|
return sendFailure(request, reply, err, 'Failed to update user database');
|
|
300
413
|
}
|
|
301
414
|
|
|
302
415
|
const user = outcome.result;
|
|
303
416
|
const disabled = user.disabled === true;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
417
|
+
warnings.push(...outcome.warnings);
|
|
418
|
+
|
|
419
|
+
if (!membershipsFirst) {
|
|
420
|
+
try {
|
|
421
|
+
await changeMemberships();
|
|
422
|
+
} catch (err) {
|
|
423
|
+
return sendFailure(request, reply, err, 'Failed to change the memberships');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
307
426
|
|
|
308
427
|
// Disabling is offboarding, which the change did as the directory saw
|
|
309
428
|
// the user become disabled (`changeUsers`): sessions ended, registry
|
|
310
429
|
// tokens revoked. Registry scopes are granted at login from feria-*
|
|
311
430
|
// groups too: losing one must not leave a valid token behind either.
|
|
312
|
-
const groupsAfter = groupsOf(user);
|
|
431
|
+
const groupsAfter = groupsOf(user, domains);
|
|
313
432
|
const groupsChanged = body.groups !== undefined && !sameList(before.groups, groupsAfter);
|
|
314
433
|
if (groupsChanged && !(disabled && !before.disabled)) {
|
|
315
434
|
try {
|
|
@@ -348,10 +467,11 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
348
467
|
username,
|
|
349
468
|
displayname: user.displayname,
|
|
350
469
|
email: user.email,
|
|
351
|
-
groups:
|
|
470
|
+
groups: groupsAfter,
|
|
352
471
|
disabled,
|
|
353
472
|
userId,
|
|
354
473
|
status: disabled ? 'disabled' : 'active',
|
|
474
|
+
domains: userId ? await domainsOfUser(userId) : [],
|
|
355
475
|
},
|
|
356
476
|
...(outcome.persisted ? {} : { persisted: false }),
|
|
357
477
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
@@ -386,7 +506,7 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
386
506
|
throw httpError('Cannot delete the last user', 400);
|
|
387
507
|
}
|
|
388
508
|
delete usersData.users[username];
|
|
389
|
-
await writeUsersOrFail(usersData);
|
|
509
|
+
await writeUsersOrFail(usersData, { deleting: [username] });
|
|
390
510
|
},
|
|
391
511
|
{
|
|
392
512
|
actor,
|
|
@@ -20,6 +20,7 @@ import agentsRoutes from './management/agents.js';
|
|
|
20
20
|
import registryCredentialRoutes from './management/registry-credential.js';
|
|
21
21
|
import auditRoutes from './management/audit.js';
|
|
22
22
|
import agentPanelAuthzRoutes from './management/agent-panel-authz.js';
|
|
23
|
+
import domainRoutes from './management/domains.js';
|
|
23
24
|
|
|
24
25
|
export default async function managementRoutes(fastify, _opts) {
|
|
25
26
|
fastify.addHook('onRequest', managementOnly());
|
|
@@ -45,4 +46,5 @@ export default async function managementRoutes(fastify, _opts) {
|
|
|
45
46
|
await fastify.register(registryCredentialRoutes);
|
|
46
47
|
await fastify.register(auditRoutes);
|
|
47
48
|
await fastify.register(agentPanelAuthzRoutes);
|
|
49
|
+
await fastify.register(domainRoutes);
|
|
48
50
|
}
|
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { CORE_SUBDOMAINS, DNS_WILDCARD_PROBE_LABEL } from '@lamalibre/lamaste';
|
|
2
|
+
import { resolveHostA as resolveA } from '@lamalibre/lamaste/server';
|
|
3
3
|
import { getConfig, updateConfig } from '../../lib/config.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Resolve A records for a hostname, returning an empty array on expected DNS errors.
|
|
7
|
-
*/
|
|
8
|
-
async function resolveA(hostname) {
|
|
9
|
-
try {
|
|
10
|
-
return await dns.resolve4(hostname);
|
|
11
|
-
} catch (err) {
|
|
12
|
-
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA' || err.code === 'ETIMEOUT') {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
|
-
throw err;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
5
|
/**
|
|
20
6
|
* Build a human-readable diagnostic message for the DNS verification result.
|
|
21
7
|
*/
|
|
@@ -69,7 +55,7 @@ export default async function dnsRoute(fastify, _opts) {
|
|
|
69
55
|
const [coreResolved, apexResolvedIps, wildcardResolvedIps] = await Promise.all([
|
|
70
56
|
Promise.all(coreNames.map((name) => resolveA(name))),
|
|
71
57
|
resolveA(domain),
|
|
72
|
-
resolveA(
|
|
58
|
+
resolveA(`${DNS_WILDCARD_PROBE_LABEL}.${domain}`),
|
|
73
59
|
]);
|
|
74
60
|
|
|
75
61
|
const records = coreNames.map((name, i) => {
|