@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.
Files changed (149) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +22 -3
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  30. package/vendor/docs/03-architecture/installer.md +4 -4
  31. package/vendor/docs/03-architecture/management-flow.md +1 -1
  32. package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
  33. package/vendor/docs/03-architecture/overview.md +5 -5
  34. package/vendor/docs/03-architecture/panel-client.md +6 -6
  35. package/vendor/docs/03-architecture/panel-server.md +2 -2
  36. package/vendor/docs/03-architecture/state-management.md +3 -3
  37. package/vendor/docs/03-architecture/system-overview.md +1 -1
  38. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
  39. package/vendor/docs/04-api-reference/app-host.md +3 -3
  40. package/vendor/docs/04-api-reference/domains.md +192 -0
  41. package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +9 -0
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  46. package/vendor/docs/04-api-reference/sites.md +5 -0
  47. package/vendor/docs/04-api-reference/tunnels.md +15 -10
  48. package/vendor/docs/04-api-reference/users.md +41 -29
  49. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +6 -6
  52. package/vendor/docs/06-reference/config-files.md +1 -1
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  57. package/vendor/docs/_index.json +105 -19
  58. package/vendor/docs/external-members-design.md +816 -0
  59. package/vendor/docs/gatekeeper-design.md +8 -8
  60. package/vendor/docs/identity-directory-design.md +3 -3
  61. package/vendor/docs/multi-domain-relay-design.md +836 -0
  62. package/vendor/docs/package.json +1 -1
  63. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  64. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  65. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  67. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  69. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  70. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  72. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  74. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  76. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  77. package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
  78. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.js +28 -2
  80. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  81. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
  83. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  85. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  87. package/vendor/gatekeeper/package.json +2 -2
  88. package/vendor/server/bin/lamaste-server.js +7 -0
  89. package/vendor/server/package.json +2 -2
  90. package/vendor/server/src/commands/domains.js +214 -0
  91. package/vendor/server/src/commands/plugin-identity.js +2 -16
  92. package/vendor/server/src/commands/plugins.js +112 -8
  93. package/vendor/server/src/commands/registry-credential.js +10 -3
  94. package/vendor/server/src/commands/sites.js +3 -0
  95. package/vendor/server/src/commands/status.js +19 -0
  96. package/vendor/server/src/commands/tunnels.js +4 -1
  97. package/vendor/server/src/commands/users.js +8 -19
  98. package/vendor/server/src/domains.js +92 -0
  99. package/vendor/server/src/fail.js +21 -0
  100. package/vendor/server/src/oidc-client-grants.js +63 -0
  101. package/vendor/server/src/panel-api.js +15 -0
  102. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  103. package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
  104. package/vendor/server-ui/dist/index.html +2 -2
  105. package/vendor/serverd/package.json +2 -2
  106. package/vendor/serverd/src/index.js +42 -16
  107. package/vendor/serverd/src/lib/app-error.js +24 -0
  108. package/vendor/serverd/src/lib/audience.js +12 -3
  109. package/vendor/serverd/src/lib/authelia.js +341 -35
  110. package/vendor/serverd/src/lib/directory.js +67 -52
  111. package/vendor/serverd/src/lib/domains.js +1792 -0
  112. package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
  113. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  114. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  115. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  116. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  117. package/vendor/serverd/src/lib/nginx.js +318 -140
  118. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  119. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  120. package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
  121. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  122. package/vendor/serverd/src/lib/plugins.js +45 -5
  123. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  124. package/vendor/serverd/src/lib/services.js +9 -1
  125. package/vendor/serverd/src/lib/state.js +11 -0
  126. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  127. package/vendor/serverd/src/lib/user-surface.js +29 -4
  128. package/vendor/serverd/src/lib/users-lock.js +23 -0
  129. package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
  130. package/vendor/serverd/src/lib/web-surface.js +49 -32
  131. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  132. package/vendor/serverd/src/routes/invite.js +15 -5
  133. package/vendor/serverd/src/routes/management/domains.js +154 -0
  134. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  135. package/vendor/serverd/src/routes/management/identity.js +21 -7
  136. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  137. package/vendor/serverd/src/routes/management/plugins.js +4 -2
  138. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  139. package/vendor/serverd/src/routes/management/sites.js +50 -11
  140. package/vendor/serverd/src/routes/management/tunnels.js +29 -5
  141. package/vendor/serverd/src/routes/management/users.js +184 -64
  142. package/vendor/serverd/src/routes/management.js +2 -0
  143. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  144. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  145. package/vendor/serverd/src/routes/user-access.js +13 -1
  146. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  147. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  148. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  149. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
@@ -0,0 +1,214 @@
1
+ /**
2
+ * lamaste-server domains — the relay's base domains and their members
3
+ * (the multi-domain relay).
4
+ *
5
+ * Subcommands:
6
+ * list Domains with their state, member count and what uses them
7
+ * add <domain> Add a domain (pending until activated)
8
+ * verify-dns <domain> The records it needs, and whether they point here
9
+ * activate <domain> Issue auth.<domain>, serve its sign-in, admit its members
10
+ * remove <domain> Remove an unused, non-primary domain (its memberships end)
11
+ * members <domain> Its members, each with the kind of the membership
12
+ * members <domain> add <user> [--external]
13
+ * Make a user (directory id or username) a member —
14
+ * an external one with --external
15
+ * members <domain> kind <user> member|external
16
+ * Change the kind of a membership
17
+ * members <domain> remove <user> End a membership
18
+ *
19
+ * Also `plugins domain <name> [<domain>]` ({@link pluginDomainCmd}).
20
+ *
21
+ * Every subcommand goes through the running panel (`/api/domains`, admin
22
+ * certificate), which decides every rule. What the panel says is printed
23
+ * only as `terminalText`; `--json` emits it as it came.
24
+ */
25
+
26
+ import chalk from 'chalk';
27
+ import { panelRequest } from '../panel-api.js';
28
+ import { emit, emitComplete } from '../ndjson.js';
29
+ import { fail } from '../fail.js';
30
+ import { terminalText } from '../plugin-settings.js';
31
+ import { domainPath, domainRows, memberRequest } from '../domains.js';
32
+
33
+ /** A panel request, or exit with its refusal. */
34
+ const call = (json, ...args) => panelRequest(...args).catch((err) => fail(err.message, json, err));
35
+
36
+ /**
37
+ * @param {string[]} args
38
+ * @param {{ json: boolean }} options
39
+ */
40
+ export async function runDomains(args, { json }) {
41
+ const [sub, name, ...rest] = args;
42
+ if (sub === 'list') return listDomains({ json });
43
+ if (sub === 'members' && name) return membersCmd(name.toLowerCase(), rest, { json });
44
+ const command = DOMAIN_COMMANDS[sub];
45
+ if (!command || !name) return usage(sub ? 1 : 0);
46
+ const domain = name.toLowerCase();
47
+ const result = await call(json, ...command.request(domain), { timeoutMs: command.timeoutMs });
48
+ if (json) emitComplete(result);
49
+ else command.print(terminalText(domain), result);
50
+ }
51
+
52
+ function usage(code) {
53
+ const b = chalk.bold;
54
+ const c = chalk.cyan;
55
+ console.log(`
56
+ ${b('Usage:')} lamaste-server domains <subcommand>
57
+
58
+ ${b('Subcommands:')}
59
+ ${c('list')} Domains: state, members, what uses each
60
+ ${c('add <domain>')} Add a domain (pending until activated)
61
+ ${c('verify-dns <domain>')} The DNS records it needs, and whether they point here
62
+ ${c('activate <domain>')} Issue auth.<domain> and serve its sign-in
63
+ ${c('remove <domain>')} Remove an unused, non-primary domain
64
+ ${c('members <domain>')} Its members and external members
65
+ ${c('members <domain> add <user> [--external]')}
66
+ Make a user (directory id or username) a member
67
+ ${c('members <domain> kind <user> member|external')}
68
+ Change the kind of a membership
69
+ ${c('members <domain> remove <user>')} End a membership
70
+
71
+ Every domain has its own sign-in (auth.<domain>) and member list; a user reaches a
72
+ domain's protected hosts only as its member. An external member reaches only what
73
+ names them — restricted hosts that grant them, sites that list them, OIDC clients
74
+ granted to them — and holds no Authelia groups. The panel and tunnel. stay on the primary.
75
+ `);
76
+ process.exit(code);
77
+ }
78
+
79
+ async function listDomains({ json }) {
80
+ const { domains } = await call(json, 'GET', '/api/domains');
81
+ if (json) {
82
+ emit({ domains });
83
+ return;
84
+ }
85
+ const d = chalk.dim;
86
+ console.log('');
87
+ console.log(chalk.bold(' Domains'));
88
+ console.log(d(' ' + '─'.repeat(50)));
89
+ for (const row of domainRows(domains)) {
90
+ const state = row.state === 'pending' ? chalk.yellow(row.state) : chalk.green(row.state);
91
+ const used = row.usedBy ? d(` used by ${row.usedBy}`) : '';
92
+ const external = row.externalMembers > 0 ? `, ${row.externalMembers} external` : '';
93
+ console.log(` ${chalk.cyan(row.name)} ${state} ${row.members} member(s)${external}${used}`);
94
+ }
95
+ console.log('');
96
+ }
97
+
98
+ /** One entry per domain subcommand: its request, its time budget, how it is printed. */
99
+ const DOMAIN_COMMANDS = {
100
+ add: {
101
+ request: (name) => ['POST', '/api/domains', { name }],
102
+ timeoutMs: 30000,
103
+ print: (domain) => {
104
+ console.log(`\n ${chalk.green('✓')} ${domain} added (pending).`);
105
+ console.log(
106
+ ` Create its DNS records (${chalk.cyan(`lamaste-server domains verify-dns ${domain}`)}),`,
107
+ );
108
+ console.log(` then ${chalk.cyan(`lamaste-server domains activate ${domain}`)}\n`);
109
+ },
110
+ },
111
+ 'verify-dns': {
112
+ request: (name) => ['POST', domainPath(name, '/verify-dns'), null],
113
+ timeoutMs: 30000,
114
+ print: (domain, result) => {
115
+ const mark = result.ok ? chalk.green('✓') : chalk.yellow('!');
116
+ console.log(
117
+ `\n ${mark} ${domain}: ${result.ok ? 'its sign-in points at this relay' : 'records still missing'}`,
118
+ );
119
+ for (const r of result.records ?? []) {
120
+ const state = r.ok
121
+ ? chalk.green('in place')
122
+ : chalk.yellow(r.required ? 'missing' : 'optional, absent');
123
+ console.log(
124
+ ` ${terminalText(r.type)} ${chalk.cyan(terminalText(r.name))} → ${terminalText(r.value)} ${state}`,
125
+ );
126
+ console.log(chalk.dim(` ${terminalText(r.purpose)}`));
127
+ }
128
+ console.log('');
129
+ },
130
+ },
131
+ activate: {
132
+ request: (name) => ['POST', domainPath(name, '/activate'), null],
133
+ // Activation issues a certificate: give it time.
134
+ timeoutMs: 180000,
135
+ print: (domain) =>
136
+ console.log(`\n ${chalk.green('✓')} ${domain} is active — its sign-in is auth.${domain}\n`),
137
+ },
138
+ remove: {
139
+ request: (name) => ['DELETE', domainPath(name), null],
140
+ timeoutMs: 30000,
141
+ print: (domain) =>
142
+ console.log(`\n ${chalk.green('✓')} ${domain} removed; its memberships ended.\n`),
143
+ },
144
+ };
145
+
146
+ async function membersCmd(name, rest, { json }) {
147
+ if (rest.length === 0) {
148
+ const { members } = await call(json, 'GET', domainPath(name, '/members'));
149
+ if (json) {
150
+ emit({ members });
151
+ return;
152
+ }
153
+ console.log('');
154
+ for (const m of members) {
155
+ const status = m.status === 'active' ? '' : chalk.yellow(` (${m.status})`);
156
+ const kind = m.kind === 'external' ? chalk.magenta(' external') : '';
157
+ console.log(
158
+ ` ${chalk.cyan(terminalText(m.username ?? m.userId))}${kind}${status} ${chalk.dim(m.userId)}`,
159
+ );
160
+ }
161
+ if (members.length === 0) console.log(chalk.dim(' No members.'));
162
+ console.log('');
163
+ return;
164
+ }
165
+ const request = memberRequest(name, rest);
166
+ if (!request) return usage(1);
167
+ // The panel takes a directory id or a username.
168
+ const result = await call(json, request.method, request.path, request.body);
169
+ if (json) {
170
+ emitComplete(result);
171
+ return;
172
+ }
173
+ const who = terminalText(request.user);
174
+ const where = terminalText(name);
175
+ console.log(
176
+ request.kind === null
177
+ ? `\n ${chalk.green('✓')} ${who} is no longer a member of ${where}\n`
178
+ : `\n ${chalk.green('✓')} ${who} is now ${request.kind === 'external' ? 'an external member' : 'a member'} of ${where}\n`,
179
+ );
180
+ }
181
+
182
+ /**
183
+ * `plugins domain <name> [<domain>]`: the domain a plugin belongs to, or
184
+ * move it to another active one.
185
+ *
186
+ * @param {string[]} args
187
+ * @param {{ json: boolean }} options
188
+ */
189
+ export async function pluginDomainCmd(args, { json }) {
190
+ const [name, domain] = args;
191
+ if (!name) {
192
+ console.error('\n Usage: lamaste-server plugins domain <name> [<domain>]\n');
193
+ process.exit(1);
194
+ }
195
+ if (domain === undefined) {
196
+ const { plugin } = await call(json, 'GET', `/api/plugins/${encodeURIComponent(name)}`);
197
+ if (json) {
198
+ emit({ name, domain: plugin.domain });
199
+ return;
200
+ }
201
+ console.log(`\n ${terminalText(name)}: ${terminalText(plugin.domain)}\n`);
202
+ return;
203
+ }
204
+ const result = await call(json, 'PUT', `/api/plugins/${encodeURIComponent(name)}/domain`, {
205
+ domain: domain.toLowerCase(),
206
+ });
207
+ if (json) {
208
+ emitComplete(result);
209
+ return;
210
+ }
211
+ console.log(
212
+ `\n ${chalk.green('✓')} ${terminalText(name)} ${result.changed ? 'now belongs to' : 'already belongs to'} ${terminalText(result.domain)}\n`,
213
+ );
214
+ }
@@ -28,7 +28,8 @@
28
28
 
29
29
  import chalk from 'chalk';
30
30
  import { panelRequest } from '../panel-api.js';
31
- import { emit, emitComplete, emitError } from '../ndjson.js';
31
+ import { emit, emitComplete } from '../ndjson.js';
32
+ import { fail } from '../fail.js';
32
33
  import { identityBlockLines } from '../plugin-approval.js';
33
34
  import {
34
35
  audienceRowText,
@@ -45,21 +46,6 @@ function pluginPath(name, rest) {
45
46
  return `/api/plugins/${encodeURIComponent(name)}/${rest}`;
46
47
  }
47
48
 
48
- /**
49
- * Exit with a usage error or a refused request, as text or NDJSON (with
50
- * the refusal's `code`).
51
- *
52
- * @param {string} message
53
- * @param {boolean} json
54
- * @param {{ code?: string }} [err]
55
- * @returns {never}
56
- */
57
- function fail(message, json, err = {}) {
58
- if (json) emitError(message, false, typeof err.code === 'string' ? { code: err.code } : {});
59
- else console.error(`\n ${chalk.red(terminalText(message))}\n`);
60
- process.exit(1);
61
- }
62
-
63
49
  /** Print a change's warnings: what the panel could not complete beside it. */
64
50
  function printWarnings(warnings) {
65
51
  for (const warning of warnings ?? []) {
@@ -10,7 +10,12 @@
10
10
  * Remove a plugin (must be disabled first); a hosted one
11
11
  * through its uninstall pipeline (an operation); --purge also
12
12
  * deletes the data of the agent half Lamaste installed
13
- * oidc-clients <name> The OIDC clients a plugin declares on Authelia
13
+ * oidc-clients <name> [grants]
14
+ * The OIDC clients a plugin declares on Authelia, and who is
15
+ * granted each user-facing one
16
+ * oidc-clients <name> grant|revoke <clientId> --user <u>|--group <g>
17
+ * Grant (or revoke) a user-facing client to a user or a Lamaste
18
+ * group: what admits an external member to its sign-in
14
19
  * rotate-secret <name> <clientId>
15
20
  * New secret for a confidential OIDC client (shown once)
16
21
  * hosting <name> Which agent hosts an agent-hosted plugin's agent half
@@ -59,6 +64,7 @@
59
64
  * Who holds which of its roles; give or take them away
60
65
  * identity-sync <name> [--reconcile]
61
66
  * Sync its audience to its agent half now
67
+ * domain <name> [<domain>] The domain a plugin belongs to; move it to another active one
62
68
  * web-surface <name> [create [--subdomain <s>] [--access-mode <m>] | move --tunnel <t> | delete [--yes]]
63
69
  * The site an agent-hosted plugin owns for its web surface: what it
64
70
  * declares, whether it is served, its trees; create, move a bound
@@ -82,6 +88,14 @@ import { emit, emitStep, emitError, emitComplete, emitLog } from '../ndjson.js';
82
88
  import { configureCmd, settingsCmd } from './plugin-settings.js';
83
89
  import { audienceCmd, entitlementsCmd, identityCmd, identitySyncCmd } from './plugin-identity.js';
84
90
  import { webSurfaceCmd } from './plugin-web-surface.js';
91
+ import { pluginDomainCmd } from './domains.js';
92
+ import { fail as failPanel } from '../fail.js';
93
+ import {
94
+ OIDC_CLIENT_RESOURCE,
95
+ findClientGrants,
96
+ parsePrincipal,
97
+ principalText,
98
+ } from '../oidc-client-grants.js';
85
99
  import { settingsDiffLines, terminalText } from '../plugin-settings.js';
86
100
  import {
87
101
  APPROVE_HOOKS_USAGE,
@@ -116,7 +130,7 @@ export async function runPlugins(args, { json }) {
116
130
  case 'uninstall':
117
131
  return uninstallPluginCmd(args[1], args.slice(2), { json });
118
132
  case 'oidc-clients':
119
- return oidcClientsCmd(args[1], { json });
133
+ return oidcClientsCmd(args[1], args.slice(2), { json });
120
134
  case 'rotate-secret':
121
135
  return rotateSecretCmd(args[1], args[2], { json });
122
136
  case 'hosting':
@@ -161,6 +175,8 @@ export async function runPlugins(args, { json }) {
161
175
  return identitySyncCmd(args.slice(1), { json });
162
176
  case 'web-surface':
163
177
  return webSurfaceCmd(args.slice(1), { json });
178
+ case 'domain':
179
+ return pluginDomainCmd(args.slice(1), { json });
164
180
  default:
165
181
  printPluginUsage();
166
182
  process.exit(sub ? 1 : 0);
@@ -181,7 +197,11 @@ ${b('Subcommands:')}
181
197
  ${c('uninstall <name> [--force] [--purge]')}
182
198
  Remove a disabled plugin (a hosted one through its uninstall pipeline;
183
199
  --purge deletes the data of the agent half Lamaste installed)
184
- ${c('oidc-clients <name>')} OIDC clients the plugin declares on Authelia (no secrets)
200
+ ${c('oidc-clients <name> [grants]')}
201
+ OIDC clients the plugin declares on Authelia (no secrets), with grants
202
+ ${c('oidc-clients <name> grant|revoke <clientId> --user <u>|--group <g>')}
203
+ Admit (or no longer) an external member to a client's sign-in —
204
+ members of the plugin's domain need no grant
185
205
  ${c('rotate-secret <name> <clientId>')}
186
206
  Replace a confidential client's secret — printed once
187
207
  ${c('hosting <name>')} Which agent hosts the plugin's agent half, port, health
@@ -228,6 +248,8 @@ ${b('Subcommands:')}
228
248
  Take every role away
229
249
  ${c('identity-sync <name> [--reconcile]')}
230
250
  Sync its audience to its agent half now; --reconcile replays all of it
251
+ ${c('domain <name> [<d>]')} The domain a plugin belongs to (its OIDC sign-in, surfaces, audience);
252
+ with <d>, move it to that active domain (refused while a surface is served)
231
253
  ${c('web-surface <name>')} The site a plugin owns for its web surface: what it declares, its site,
232
254
  whether it is served (and why not), its trees, the bound tunnel, the offer
233
255
  ${c('web-surface <name> create [--subdomain <label>] [--access-mode authenticated|restricted]')}
@@ -1433,13 +1455,25 @@ async function uninstallPluginCmd(name, rest, { json }) {
1433
1455
  * @param {string | undefined} name
1434
1456
  * @param {{ json: boolean }} options
1435
1457
  */
1436
- async function oidcClientsCmd(name, { json }) {
1437
- if (!name) {
1438
- const msg = 'Usage: lamaste-server plugins oidc-clients <name>';
1458
+ async function oidcClientsCmd(name, rest, { json }) {
1459
+ const usage = () => {
1460
+ const msg =
1461
+ 'Usage: lamaste-server plugins oidc-clients <name> [grants | grant|revoke <clientId> --user <u>|--group <g>]';
1439
1462
  if (json) emitError(msg);
1440
1463
  else console.error(`\n ${msg}\n`);
1441
1464
  process.exit(1);
1442
- }
1465
+ };
1466
+ if (!name) return usage();
1467
+ const [action, clientId, ...principalArgs] = rest;
1468
+ if (action === 'grant' || action === 'revoke') {
1469
+ const principal = parsePrincipal(principalArgs);
1470
+ if (!clientId || !principal) return usage();
1471
+ return action === 'grant'
1472
+ ? grantOidcClientCmd(clientId, principal, { json })
1473
+ : revokeOidcClientCmd(name, clientId, principal, { json });
1474
+ }
1475
+ if (action !== undefined && (action !== 'grants' || clientId !== undefined)) return usage();
1476
+
1443
1477
  const result = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/oidc-clients`);
1444
1478
  if (json) {
1445
1479
  emit(result);
@@ -1459,7 +1493,7 @@ async function oidcClientsCmd(name, { json }) {
1459
1493
  const kind = c.confidential ? chalk.yellow('confidential') : chalk.green('public');
1460
1494
  const served = c.enabled ? '' : d(' (plugin disabled — not served)');
1461
1495
  console.log(` ${chalk.cyan(c.id)} ${kind}${served}`);
1462
- console.log(` grants: ${c.grantTypes.join(', ')}`);
1496
+ console.log(` flows: ${c.grantTypes.join(', ')}`);
1463
1497
  console.log(` scopes: ${c.scopes.join(', ')}`);
1464
1498
  if (c.audience.length > 0) console.log(` audience: ${c.audience.join(', ')}`);
1465
1499
  if (c.accessTokenClaims.length > 0) {
@@ -1472,10 +1506,80 @@ async function oidcClientsCmd(name, { json }) {
1472
1506
  }`,
1473
1507
  );
1474
1508
  }
1509
+ if (c.userFacing) {
1510
+ const granted = Array.isArray(c.grants)
1511
+ ? c.grants.length > 0
1512
+ ? c.grants.map(principalText).join(', ')
1513
+ : d('none — external members do not sign in')
1514
+ : chalk.yellow('unknown (Gatekeeper could not be read)');
1515
+ console.log(` granted: ${granted}`);
1516
+ }
1475
1517
  }
1476
1518
  console.log('');
1477
1519
  }
1478
1520
 
1521
+ /** Grant a user-facing client to a user or a Lamaste group (a Gatekeeper grant the panel checks). */
1522
+ async function grantOidcClientCmd(clientId, principal, { json }) {
1523
+ let result;
1524
+ try {
1525
+ result = await panelRequest('POST', '/api/gatekeeper/grants', {
1526
+ ...principal,
1527
+ resourceType: OIDC_CLIENT_RESOURCE,
1528
+ resourceId: clientId,
1529
+ });
1530
+ } catch (err) {
1531
+ failPanel(err.message, json, err);
1532
+ }
1533
+ if (json) {
1534
+ emitComplete(result);
1535
+ return;
1536
+ }
1537
+ console.log(
1538
+ `\n ${chalk.green('✓')} ${principalText(principal)} may sign in with ${terminalText(clientId)}\n`,
1539
+ );
1540
+ }
1541
+
1542
+ /** Revoke the grant of a user-facing client to a user or a Lamaste group. */
1543
+ async function revokeOidcClientCmd(name, clientId, principal, { json }) {
1544
+ let found;
1545
+ try {
1546
+ const { clients } = await panelRequest(
1547
+ 'GET',
1548
+ `/api/plugins/${encodeURIComponent(name)}/oidc-clients`,
1549
+ );
1550
+ found = findClientGrants(clients ?? [], clientId, principal);
1551
+ } catch (err) {
1552
+ failPanel(err.message, json, err);
1553
+ }
1554
+ if ('error' in found) {
1555
+ const reason = {
1556
+ no_client: `Plugin ${name} declares no user-facing OIDC client ${clientId}`,
1557
+ unreadable: "Gatekeeper's grants could not be read — try again",
1558
+ no_grant: `${clientId} is not granted to ${principalText(principal)}`,
1559
+ }[found.error];
1560
+ failPanel(reason, json);
1561
+ }
1562
+ // Every grant naming the principal goes: one left behind would still let
1563
+ // them sign in.
1564
+ const results = [];
1565
+ try {
1566
+ for (const grantId of found.grantIds) {
1567
+ results.push(
1568
+ await panelRequest('DELETE', `/api/gatekeeper/grants/${encodeURIComponent(grantId)}`),
1569
+ );
1570
+ }
1571
+ } catch (err) {
1572
+ failPanel(err.message, json, err);
1573
+ }
1574
+ if (json) {
1575
+ emitComplete({ revoked: found.grantIds.length, results });
1576
+ return;
1577
+ }
1578
+ console.log(
1579
+ `\n ${chalk.green('✓')} ${principalText(principal)} no longer signs in with ${terminalText(clientId)}\n`,
1580
+ );
1581
+ }
1582
+
1479
1583
  /**
1480
1584
  * @param {string | undefined} name
1481
1585
  * @param {string | undefined} clientId
@@ -4,7 +4,7 @@
4
4
  * for agents.
5
5
  *
6
6
  * registry-credential show
7
- * registry-credential set --url <registry> --oidc
7
+ * registry-credential set --url <registry> --oidc [--issuer-domain <domain>]
8
8
  * The panel authenticates as its own `lamaste-server` OIDC client on
9
9
  * the relay's Authelia (client-credentials grant, Feria login). Nothing
10
10
  * to paste; Feria's auth.json must grant the relay's lamaste-server client
@@ -35,7 +35,7 @@ export async function runRegistryCredential(args, { json }) {
35
35
  return clear({ json });
36
36
  default:
37
37
  process.stderr.write(
38
- `Usage: lamaste-server registry-credential <show | set --url <registry> (--oidc | --token-stdin) | clear>\n`,
38
+ `Usage: lamaste-server registry-credential <show | set --url <registry> (--oidc [--issuer-domain <domain>] | --token-stdin) | clear>\n`,
39
39
  );
40
40
  process.exit(1);
41
41
  }
@@ -68,6 +68,7 @@ async function set(args, { json }) {
68
68
  let url;
69
69
  let tokenFromStdin = false;
70
70
  let oidc = false;
71
+ let issuerDomain;
71
72
  for (let i = 0; i < args.length; i += 1) {
72
73
  const arg = args[i];
73
74
  if (arg === '--url') {
@@ -78,6 +79,8 @@ async function set(args, { json }) {
78
79
  tokenFromStdin = true;
79
80
  } else if (arg === '--oidc') {
80
81
  oidc = true;
82
+ } else if (arg === '--issuer-domain') {
83
+ issuerDomain = args[++i]?.toLowerCase();
81
84
  } else if (arg === '--token' || arg.startsWith('--token=')) {
82
85
  throw new Error(
83
86
  'The token is never accepted on the command line — set LAMASTE_REGISTRY_TOKEN or use --token-stdin',
@@ -87,13 +90,17 @@ async function set(args, { json }) {
87
90
  }
88
91
  }
89
92
  if (!url) throw new Error('registry-credential set: --url <registry> is required');
93
+ if (issuerDomain !== undefined && !oidc) {
94
+ throw new Error('registry-credential set: --issuer-domain goes with --oidc');
95
+ }
90
96
  if (oidc && tokenFromStdin) {
91
97
  throw new Error('registry-credential set: choose --oidc or --token-stdin, not both');
92
98
  }
93
99
 
94
100
  let auth;
95
101
  if (oidc) {
96
- auth = { kind: 'oidc' };
102
+ // The domain whose issuer (https://auth.<domain>) the registry trusts; the primary when absent.
103
+ auth = { kind: 'oidc', ...(issuerDomain ? { issuerDomain } : {}) };
97
104
  } else {
98
105
  let token = tokenFromStdin ? (await readStdin()).trim() : process.env.LAMASTE_REGISTRY_TOKEN;
99
106
  if (!token) {
@@ -61,6 +61,7 @@ ${b('Create options:')}
61
61
  --type <type> Site type: managed (default) or custom
62
62
  --custom-domain <d> Custom domain (required if type is custom)
63
63
  --aliases <a,b> Hostnames that redirect to the custom domain (e.g. www.<domain>)
64
+ --domain <domain> A managed site's base domain, an active one (default: the primary)
64
65
  --spa Enable SPA mode (single-page application routing)
65
66
  --authelia Enable Authelia protection
66
67
  `);
@@ -124,6 +125,7 @@ async function createSite(args, { json }) {
124
125
  const type = getArg(args, 'type') || 'managed';
125
126
  const customDomain = getArg(args, 'custom-domain') || undefined;
126
127
  const aliasArg = getArg(args, 'aliases');
128
+ const domain = getArg(args, 'domain');
127
129
  const spaMode = args.includes('--spa');
128
130
  const autheliaProtected = args.includes('--authelia');
129
131
 
@@ -152,6 +154,7 @@ async function createSite(args, { json }) {
152
154
  const body = { name, type, spaMode, autheliaProtected };
153
155
  if (customDomain) body.customDomain = customDomain;
154
156
  if (aliasArg) body.aliases = splitAliases(aliasArg);
157
+ if (domain) body.domain = domain.toLowerCase();
155
158
 
156
159
  if (!json) process.stderr.write(` Creating site ${chalk.cyan(name)}...`);
157
160
 
@@ -10,6 +10,8 @@ import { readConfigSafe, PANEL_SERVICE, GATEKEEPER_SERVICE, PKI_DIR } from '../c
10
10
  import { getMtlsCerts } from '@lamalibre/lamaste/server';
11
11
  import { exec } from '../exec.js';
12
12
  import { emit } from '../ndjson.js';
13
+ import { panelRequest } from '../panel-api.js';
14
+ import { domainRows } from '../domains.js';
13
15
 
14
16
  /**
15
17
  * @param {{ json: boolean }} options
@@ -20,6 +22,15 @@ export async function runStatus({ json }) {
20
22
 
21
23
  // Collect service statuses
22
24
  const panelStatus = await getServiceStatus(PANEL_SERVICE);
25
+ // The relay's domains, from the running panel — asked while the other
26
+ // checks run, briefly; none while it is down or not onboarded.
27
+ const domainsRequest =
28
+ domain && panelStatus.active
29
+ ? panelRequest('GET', '/api/domains', null, { timeoutMs: 4000 }).then(
30
+ (r) => r.domains ?? [],
31
+ () => null,
32
+ )
33
+ : Promise.resolve(null);
23
34
  const gatekeeperStatus = await getServiceStatus(GATEKEEPER_SERVICE);
24
35
  const nginxStatus = await getServiceStatus('nginx');
25
36
  const autheliaStatus = await getServiceStatus('authelia');
@@ -35,10 +46,12 @@ export async function runStatus({ json }) {
35
46
  // Collect system info
36
47
  const uptime = await getUptime();
37
48
  const memory = await getMemoryUsage();
49
+ const domains = await domainsRequest;
38
50
 
39
51
  if (json) {
40
52
  emit({
41
53
  domain,
54
+ domains,
42
55
  services: {
43
56
  panel: panelStatus,
44
57
  gatekeeper: gatekeeperStatus,
@@ -66,6 +79,12 @@ export async function runStatus({ json }) {
66
79
  if (domain) {
67
80
  console.log(` ${b('Domain:')} ${c(String(domain))}`);
68
81
  }
82
+ if (domains && domains.length > 1) {
83
+ console.log(` ${b('Domains:')}`);
84
+ for (const row of domainRows(domains)) {
85
+ console.log(` ${c(row.name)} ${d(`${row.state}, ${row.members} member(s)`)}`);
86
+ }
87
+ }
69
88
 
70
89
  if (uptime) {
71
90
  console.log(` ${b('Uptime:')} ${d(uptime)}`);
@@ -89,6 +89,7 @@ ${b('Create options:')}
89
89
  --description <text> Optional description
90
90
  --access-mode <mode> Access mode: public, authenticated, restricted (default: restricted)
91
91
  --max-body-mb <n> Largest request body in MiB, 1-10240 (default: 10)
92
+ --domain <domain> Base domain of the hostname, an active one (default: the primary)
92
93
  `);
93
94
  }
94
95
 
@@ -125,7 +126,7 @@ async function listTunnels({ json }) {
125
126
  const carrier = t.agentLabel ? d(`via ${t.agentLabel}`) : r('no agent');
126
127
  const bodyLimit = t.type !== 'panel' ? d(`${t.maxBodySizeMb ?? 1} MiB`) : '';
127
128
  console.log(
128
- ` ${c(t.subdomain)} \u2192 :${t.port} ${enabled} ${carrier} ${accessMode} ${bodyLimit} ${type}`.trimEnd(),
129
+ ` ${c(t.fqdn ?? t.subdomain)} \u2192 :${t.port} ${enabled} ${carrier} ${accessMode} ${bodyLimit} ${type}`.trimEnd(),
129
130
  );
130
131
  if (t.description) {
131
132
  console.log(` ${d(t.description)}`);
@@ -150,6 +151,7 @@ async function createTunnel(args, { json }) {
150
151
  const accessMode = getArg(args, 'access-mode') || undefined;
151
152
  const agentLabel = getArg(args, 'agent');
152
153
  const maxBodyStr = getArg(args, 'max-body-mb');
154
+ const domain = getArg(args, 'domain');
153
155
 
154
156
  if (!subdomain || !portStr || !agentLabel) {
155
157
  const msg = 'Error: --subdomain, --port and --agent are required';
@@ -180,6 +182,7 @@ async function createTunnel(args, { json }) {
180
182
  if (description) body.description = description;
181
183
  if (accessMode) body.accessMode = accessMode;
182
184
  if (maxBodySizeMb !== undefined) body.maxBodySizeMb = maxBodySizeMb;
185
+ if (domain) body.domain = domain.toLowerCase();
183
186
 
184
187
  if (!json) process.stderr.write(` Creating tunnel ${chalk.cyan(subdomain)} \u2192 :${port}...`);
185
188
 
@@ -31,9 +31,11 @@
31
31
 
32
32
  import chalk from 'chalk';
33
33
  import { panelRequest } from '../panel-api.js';
34
- import { emit, emitComplete, emitError } from '../ndjson.js';
34
+ import { emit, emitComplete } from '../ndjson.js';
35
+ import { fail } from '../fail.js';
35
36
  import { parseUserArgs, warningLine } from '../plugin-identity.js';
36
37
  import { formatMoment, terminalText } from '../plugin-settings.js';
38
+ import { membershipsText } from '../domains.js';
37
39
 
38
40
  /**
39
41
  * @param {string[]} args Subcommand args: ['list'] | ['disable', '<u>'] | ...
@@ -78,21 +80,6 @@ ${b('Subcommands:')}
78
80
  `);
79
81
  }
80
82
 
81
- /**
82
- * Exit with a usage error or a refused request, as text or NDJSON (with
83
- * the refusal's `code`, e.g. `last_admin`, `user_not_removed`).
84
- *
85
- * @param {string} message
86
- * @param {boolean} json
87
- * @param {{ code?: string }} [err]
88
- * @returns {never}
89
- */
90
- function fail(message, json, err = {}) {
91
- if (json) emitError(message, false, typeof err.code === 'string' ? { code: err.code } : {});
92
- else console.error(`\n ${chalk.red(terminalText(message))}\n`);
93
- process.exit(1);
94
- }
95
-
96
83
  /** Print a change's warnings: what the panel could not complete beside it. */
97
84
  function printWarnings(warnings) {
98
85
  for (const warning of warnings ?? []) {
@@ -107,7 +94,8 @@ function statusCell(status) {
107
94
 
108
95
  /**
109
96
  * The rows `users list` prints: each user's username, status, directory id
110
- * (a dash until the directory has seen them) and groups, as terminal text.
97
+ * (a dash until the directory has seen them), groups and domain memberships
98
+ * (external ones marked), as terminal text.
111
99
  * Pure, exported for tests.
112
100
  *
113
101
  * @param {Array<Record<string, any>>} users as `GET /api/users` lists them
@@ -118,6 +106,7 @@ export function userRows(users) {
118
106
  status: u.status === 'disabled' ? 'disabled' : 'active',
119
107
  userId: u.userId ? terminalText(u.userId) : '—',
120
108
  groups: terminalText((u.groups ?? []).join(', ')),
109
+ domains: membershipsText(u.domains),
121
110
  }));
122
111
  }
123
112
 
@@ -142,11 +131,11 @@ async function listUsers({ json }) {
142
131
  console.log('');
143
132
  console.log(chalk.bold(' Users'));
144
133
  console.log(
145
- chalk.dim(` ${'USERNAME'.padEnd(width)} STATUS ${'USER ID'.padEnd(36)} GROUPS`),
134
+ chalk.dim(` ${'USERNAME'.padEnd(width)} STATUS ${'USER ID'.padEnd(36)} DOMAINS GROUPS`),
146
135
  );
147
136
  for (const r of rows) {
148
137
  console.log(
149
- ` ${chalk.cyan(r.username.padEnd(width))} ${statusCell(r.status)} ${r.userId.padEnd(36)} ${chalk.dim(r.groups)}`,
138
+ ` ${chalk.cyan(r.username.padEnd(width))} ${statusCell(r.status)} ${r.userId.padEnd(36)} ${r.domains} ${chalk.dim(r.groups)}`,
150
139
  );
151
140
  }
152
141
  if (rows.some((r) => r.userId === '—')) {