@lamalibre/create-lamaste 4.0.0 → 4.2.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/lamaste-priv +21 -2
- package/src/lib/oidc-issuer-key.js +124 -0
- package/src/lib/relay-services.js +4 -0
- package/src/lib/service-config.js +16 -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 +45 -1
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +1 -1
- 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 +3 -3
- 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/onboarding.md +2 -2
- 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 +6 -5
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- 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 +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +111 -105
- package/vendor/docs/04-api-reference/registry-and-oidc.md +207 -0
- 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 -5
- package/vendor/docs/06-reference/config-files.md +103 -45
- 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 +5 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +443 -0
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/package.json +1 -1
- package/vendor/server/bin/lamaste-server.js +10 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/plugins.js +200 -158
- package/vendor/server/src/commands/registry-credential.js +142 -0
- package/vendor/server/src/panel-api.js +86 -39
- package/vendor/server-ui/dist/assets/{index-B0nWuUyL.js → index-B5Wp8lu-.js} +26 -26
- package/vendor/server-ui/dist/index.html +1 -1
- package/vendor/serverd/package.json +5 -2
- package/vendor/serverd/src/index.js +11 -0
- package/vendor/serverd/src/lib/authelia.js +11 -0
- package/vendor/serverd/src/lib/migrations/state/0005_plugin_hosting.sql +3 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +233 -0
- package/vendor/serverd/src/lib/plugins.js +37 -5
- package/vendor/serverd/src/lib/registry-credential.js +145 -0
- package/vendor/serverd/src/routes/management/agents.js +81 -0
- package/vendor/serverd/src/routes/management/plugins.js +65 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +170 -0
- package/vendor/serverd/src/routes/management/storage.js +10 -2
- package/vendor/serverd/src/routes/management/users.js +31 -0
- package/vendor/serverd/src/routes/management.js +2 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Lamaste</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-B5Wp8lu-.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-BqWWFlTU.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body style="background-color: #09090b">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lamalibre/lamaste-serverd",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Lamaste server daemon — REST API and plugin host",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"publishConfig": {
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"main": "src/index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
11
14
|
"scripts": {
|
|
12
15
|
"dev": "node --watch src/index.js | pino-pretty",
|
|
13
16
|
"build": "echo 'No build step for lamaste-serverd'",
|
|
@@ -26,7 +29,7 @@
|
|
|
26
29
|
"@fastify/rate-limit": "^10.3.0",
|
|
27
30
|
"@fastify/static": "^9.1.1",
|
|
28
31
|
"@fastify/websocket": "^11.0.0",
|
|
29
|
-
"@lamalibre/lamaste": "^4.
|
|
32
|
+
"@lamalibre/lamaste": "^4.2.0",
|
|
30
33
|
"bcryptjs": "^3.0.3",
|
|
31
34
|
"execa": "^9.6.1",
|
|
32
35
|
"fastify": "^5.8.4",
|
|
@@ -28,6 +28,7 @@ import userAccessSessionMiddleware from './middleware/user-access-session.js';
|
|
|
28
28
|
import { getPluginCapabilities } from './lib/plugins.js';
|
|
29
29
|
import { setPluginCapabilities } from './lib/mtls.js';
|
|
30
30
|
import { startChiselReconciler } from './lib/chisel-reconcile.js';
|
|
31
|
+
import { syncOidcClients } from './lib/plugin-oidc.js';
|
|
31
32
|
import {
|
|
32
33
|
loadTicketScopeCapabilities,
|
|
33
34
|
checkInstanceLiveness,
|
|
@@ -76,6 +77,16 @@ async function start() {
|
|
|
76
77
|
// it can.
|
|
77
78
|
startChiselReconciler(server.log);
|
|
78
79
|
|
|
80
|
+
// Bring Authelia's OIDC clients (plugin manifests, the panel's own
|
|
81
|
+
// registry client) and the client-secret store in line with persisted
|
|
82
|
+
// state. Best effort: a failure is logged and the next lifecycle change
|
|
83
|
+
// retries. Only once onboarding has written Authelia's configuration.
|
|
84
|
+
if (config.onboarding.status === 'COMPLETED') {
|
|
85
|
+
syncOidcClients(server.log).catch((err) => {
|
|
86
|
+
server.log.warn({ err: err.message }, 'Startup OIDC client sync failed');
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
// --- Plugins ---
|
|
80
91
|
const ipOrigin = `https://${config.ip}:9292`;
|
|
81
92
|
await server.register(cors, {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
reloadAuthelia as reloadAutheliaCore,
|
|
19
19
|
isAutheliaRunning as isAutheliaRunningCore,
|
|
20
20
|
updateAutheliaAccessControl,
|
|
21
|
+
updateAutheliaOidcProvider,
|
|
21
22
|
createUserFromInvitation as createUserFromInvitationCore,
|
|
22
23
|
base32Encode,
|
|
23
24
|
base32Decode,
|
|
@@ -73,6 +74,16 @@ export function updateAccessControl(sites) {
|
|
|
73
74
|
return updateAutheliaAccessControl(sites, execa);
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Make Authelia's OIDC provider serve exactly these clients.
|
|
79
|
+
* @param {string} domain
|
|
80
|
+
* @param {import('@lamalibre/lamaste/server').AutheliaOidcClient[]} clients
|
|
81
|
+
* @param {import('@lamalibre/lamaste/server').OidcProviderOptions} [options]
|
|
82
|
+
*/
|
|
83
|
+
export function updateOidcProvider(domain, clients, options = {}) {
|
|
84
|
+
return updateAutheliaOidcProvider(domain, clients, execa, options);
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
export function createUserFromInvitation(username, email, groups, hashedPassword) {
|
|
77
88
|
return createUserFromInvitationCore(username, email, groups, hashedPassword, execa);
|
|
78
89
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC clients → Authelia.
|
|
3
|
+
*
|
|
4
|
+
* Two sources declare clients on the relay's Authelia:
|
|
5
|
+
*
|
|
6
|
+
* - every installed plugin may declare them in its manifest (`oidcClients`,
|
|
7
|
+
* see the core `ManifestSchema`); only the clients of enabled plugins are
|
|
8
|
+
* served. Hosting does not matter: an agent-hosted plugin (`hosting:
|
|
9
|
+
* "agent"`, never imported by the panel) registers its clients exactly as
|
|
10
|
+
* a server plugin does — the manifest is all this reads;
|
|
11
|
+
* - the panel itself declares the confidential `lamaste-server` client
|
|
12
|
+
* when the registry credential uses `auth.kind: 'oidc'`.
|
|
13
|
+
*
|
|
14
|
+
* After any plugin lifecycle change, or a registry-credential change, the
|
|
15
|
+
* set is recomputed, the secrets of confidential clients are reconciled
|
|
16
|
+
* (`oidc-clients.json`: minted on first appearance, deleted when a client is
|
|
17
|
+
* gone — a secret belongs to an installed plugin, enabled or not, so
|
|
18
|
+
* disabling a plugin does not rotate it) and Authelia's
|
|
19
|
+
* `identity_providers.oidc` section is written; Authelia is restarted only
|
|
20
|
+
* when the section actually changed.
|
|
21
|
+
*
|
|
22
|
+
* Best effort by design where a plugin lifecycle calls it: a failure here
|
|
23
|
+
* must not fail the plugin operation — the caller logs it and the next
|
|
24
|
+
* lifecycle change retries.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFile } from 'node:fs/promises';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { validateManifest } from '@lamalibre/lamaste';
|
|
30
|
+
import {
|
|
31
|
+
LAMASTE_REGISTRY_OIDC_SCOPES,
|
|
32
|
+
lamasteServerOidcClient,
|
|
33
|
+
listOidcClientSecrets,
|
|
34
|
+
reconcileOidcClientSecrets,
|
|
35
|
+
readOidcClientSecretDigests,
|
|
36
|
+
rotateOidcClientSecret,
|
|
37
|
+
} from '@lamalibre/lamaste/server';
|
|
38
|
+
|
|
39
|
+
import { getConfig } from './config.js';
|
|
40
|
+
import { updateOidcProvider } from './authelia.js';
|
|
41
|
+
import { readPlugins } from './plugins.js';
|
|
42
|
+
import { readRegistryCredentialConfig } from './registry-credential.js';
|
|
43
|
+
|
|
44
|
+
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The OIDC clients every installed plugin declares, tagged with the plugin
|
|
48
|
+
* name and whether the plugin is enabled. Manifests are re-read from the
|
|
49
|
+
* installed packages — the plugin registry does not store them.
|
|
50
|
+
* @returns {Promise<Array<import('@lamalibre/lamaste/server').AutheliaOidcClient & { enabled: boolean }>>}
|
|
51
|
+
*/
|
|
52
|
+
export async function collectPluginOidcClients() {
|
|
53
|
+
const require = createRequire(`${STATE_DIR}/`);
|
|
54
|
+
const clients = [];
|
|
55
|
+
for (const plugin of (await readPlugins()).plugins) {
|
|
56
|
+
let manifest;
|
|
57
|
+
try {
|
|
58
|
+
const manifestPath = require.resolve(`${plugin.packageName}/lamaste-plugin.json`);
|
|
59
|
+
manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
|
|
60
|
+
} catch {
|
|
61
|
+
continue; // an unreadable manifest contributes no clients
|
|
62
|
+
}
|
|
63
|
+
for (const client of manifest.oidcClients) {
|
|
64
|
+
clients.push({ ...client, plugin: manifest.name, enabled: plugin.status === 'enabled' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return clients;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The panel's own client, when the registry credential (stored, or the
|
|
72
|
+
* candidate being verified) uses it.
|
|
73
|
+
* @param {{ registryUrl: string } | null | undefined} registryAuth
|
|
74
|
+
*/
|
|
75
|
+
function lamasteOwnClient(registryAuth) {
|
|
76
|
+
if (!registryAuth) return [];
|
|
77
|
+
return [{ ...lamasteServerOidcClient(registryAuth.registryUrl), enabled: true }];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Everything Authelia should know, from the stored state.
|
|
82
|
+
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides]
|
|
83
|
+
* `registryAuth` replaces the stored registry credential's say in the
|
|
84
|
+
* matter: `{ registryUrl }` while a candidate oidc credential is being
|
|
85
|
+
* verified, `null` to leave the panel's client out.
|
|
86
|
+
*/
|
|
87
|
+
async function collectAllClients(overrides = {}) {
|
|
88
|
+
let registryAuth;
|
|
89
|
+
if (overrides.registryAuth !== undefined) {
|
|
90
|
+
registryAuth = overrides.registryAuth;
|
|
91
|
+
} else {
|
|
92
|
+
const stored = await readRegistryCredentialConfig().catch(() => null);
|
|
93
|
+
registryAuth =
|
|
94
|
+
stored && stored.auth.kind === 'oidc' ? { registryUrl: stored.registryUrl } : null;
|
|
95
|
+
}
|
|
96
|
+
return [...(await collectPluginOidcClients()), ...lamasteOwnClient(registryAuth)];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What Authelia should serve and which secrets the store should hold, from
|
|
101
|
+
* the stored state: every installed plugin's clients (an installed plugin
|
|
102
|
+
* keeps its confidential-client secret while disabled), the clients of
|
|
103
|
+
* enabled plugins served, and the panel's own scopes when its client is.
|
|
104
|
+
* Pure with respect to Authelia — nothing is written.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
|
|
107
|
+
* @returns {Promise<{
|
|
108
|
+
* confidential: Array<{ id: string, plugin: string }>,
|
|
109
|
+
* served: Array<import('@lamalibre/lamaste/server').AutheliaOidcClient>,
|
|
110
|
+
* scopes: Record<string, { claims: readonly string[] }>,
|
|
111
|
+
* }>}
|
|
112
|
+
*/
|
|
113
|
+
export async function planOidcClients(overrides = {}) {
|
|
114
|
+
const all = await collectAllClients(overrides);
|
|
115
|
+
const served = all.filter((c) => c.enabled).map(({ enabled: _enabled, ...client }) => client);
|
|
116
|
+
return {
|
|
117
|
+
confidential: all.filter((c) => c.confidential).map((c) => ({ id: c.id, plugin: c.plugin })),
|
|
118
|
+
served,
|
|
119
|
+
scopes: served.some((c) => c.plugin === 'lamaste') ? LAMASTE_REGISTRY_OIDC_SCOPES : {},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Sync Authelia's OIDC clients and the secret store with the plugins and
|
|
125
|
+
* the registry credential. Returns what was done, or `null` when the panel
|
|
126
|
+
* has no domain yet (onboarding).
|
|
127
|
+
*
|
|
128
|
+
* @param {import('pino').Logger} logger
|
|
129
|
+
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
|
|
130
|
+
* @returns {Promise<{ changed: boolean, clients: string[], createdSecrets: Array<{ id: string, plugin: string, secret: string }> } | null>}
|
|
131
|
+
*/
|
|
132
|
+
export async function syncOidcClients(logger, overrides = {}) {
|
|
133
|
+
let domain = null;
|
|
134
|
+
try {
|
|
135
|
+
domain = getConfig().domain;
|
|
136
|
+
} catch {
|
|
137
|
+
domain = null;
|
|
138
|
+
}
|
|
139
|
+
if (!domain) {
|
|
140
|
+
logger.debug('OIDC client sync skipped — no domain configured yet');
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { confidential, served, scopes } = await planOidcClients(overrides);
|
|
145
|
+
const reconciled = await reconcileOidcClientSecrets(STATE_DIR, confidential);
|
|
146
|
+
if (reconciled.created.length > 0 || reconciled.removed.length > 0) {
|
|
147
|
+
logger.info(
|
|
148
|
+
{ created: reconciled.created.map((c) => c.id), removed: reconciled.removed },
|
|
149
|
+
'OIDC client secrets reconciled',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = await updateOidcProvider(domain, served, {
|
|
154
|
+
secretDigests: reconciled.digests,
|
|
155
|
+
scopes,
|
|
156
|
+
});
|
|
157
|
+
if (result.changed) {
|
|
158
|
+
logger.info({ clients: served.map((c) => c.id) }, 'Authelia OIDC provider updated');
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
changed: result.changed,
|
|
162
|
+
clients: served.map((c) => c.id),
|
|
163
|
+
createdSecrets: reconciled.created,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Metadata of a plugin's OIDC clients: what the manifest declares, whether
|
|
169
|
+
* it is served, and — for confidential clients — when the secret was
|
|
170
|
+
* created and last rotated. Never the secret.
|
|
171
|
+
* @param {string} pluginName
|
|
172
|
+
*/
|
|
173
|
+
export async function listPluginOidcClients(pluginName) {
|
|
174
|
+
const declared = (await collectPluginOidcClients()).filter((c) => c.plugin === pluginName);
|
|
175
|
+
const secrets = new Map((await listOidcClientSecrets(STATE_DIR)).map((m) => [m.id, m]));
|
|
176
|
+
return declared.map((client) => {
|
|
177
|
+
const secret = client.confidential ? (secrets.get(client.id) ?? null) : null;
|
|
178
|
+
return {
|
|
179
|
+
id: client.id,
|
|
180
|
+
name: client.name ?? null,
|
|
181
|
+
confidential: client.confidential,
|
|
182
|
+
grantTypes: client.grantTypes,
|
|
183
|
+
scopes: client.scopes,
|
|
184
|
+
audience: client.audience,
|
|
185
|
+
accessTokenClaims: client.accessTokenClaims,
|
|
186
|
+
enabled: client.enabled,
|
|
187
|
+
secretCreatedAt: secret?.createdAt ?? null,
|
|
188
|
+
secretRotatedAt: secret?.rotatedAt ?? null,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Replace the secret of one of a plugin's confidential clients and write
|
|
195
|
+
* the new digest to Authelia. Returns the new secret — the only time it is
|
|
196
|
+
* shown. Throws with `statusCode` 404 for an unknown client and 400 for a
|
|
197
|
+
* public one.
|
|
198
|
+
* @param {string} pluginName
|
|
199
|
+
* @param {string} clientId
|
|
200
|
+
* @param {import('pino').Logger} logger
|
|
201
|
+
*/
|
|
202
|
+
export async function rotatePluginOidcClientSecret(pluginName, clientId, logger) {
|
|
203
|
+
const client = (await collectPluginOidcClients()).find(
|
|
204
|
+
(c) => c.plugin === pluginName && c.id === clientId,
|
|
205
|
+
);
|
|
206
|
+
if (!client) {
|
|
207
|
+
throw Object.assign(new Error(`Plugin "${pluginName}" declares no OIDC client "${clientId}"`), {
|
|
208
|
+
statusCode: 404,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (!client.confidential) {
|
|
212
|
+
throw Object.assign(new Error(`OIDC client "${clientId}" is public and has no secret`), {
|
|
213
|
+
statusCode: 400,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// The store is reconciled first so a client that was never synced (an
|
|
217
|
+
// earlier Authelia failure) gets its record now.
|
|
218
|
+
await syncOidcClients(logger);
|
|
219
|
+
const rotated = await rotateOidcClientSecret(STATE_DIR, clientId);
|
|
220
|
+
const digests = await readOidcClientSecretDigests(STATE_DIR);
|
|
221
|
+
if (client.enabled) {
|
|
222
|
+
const domain = getConfig().domain;
|
|
223
|
+
const { served, scopes } = await planOidcClients();
|
|
224
|
+
await updateOidcProvider(domain, served, { secretDigests: digests, scopes });
|
|
225
|
+
}
|
|
226
|
+
logger.info({ plugin: pluginName, clientId }, 'OIDC client secret rotated');
|
|
227
|
+
return {
|
|
228
|
+
id: rotated.id,
|
|
229
|
+
plugin: rotated.plugin,
|
|
230
|
+
secret: rotated.secret,
|
|
231
|
+
rotatedAt: rotated.rotatedAt,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -14,6 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import { execa } from 'execa';
|
|
16
16
|
import {
|
|
17
|
+
DEFAULT_PLUGIN_HOSTING,
|
|
17
18
|
RESERVED_API_PREFIXES,
|
|
18
19
|
RESERVED_NAV_LABELS,
|
|
19
20
|
derivePluginRoute,
|
|
@@ -60,8 +61,8 @@ async function getStmts() {
|
|
|
60
61
|
insert: db.prepare(`
|
|
61
62
|
INSERT INTO plugins
|
|
62
63
|
(name, display_name, package_name, version, description, capabilities,
|
|
63
|
-
packages, panel, config, modes, status, installed_at, enabled_at)
|
|
64
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
64
|
+
packages, panel, config, modes, hosting, status, installed_at, enabled_at)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
65
66
|
`),
|
|
66
67
|
deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
|
|
67
68
|
deleteAll: db.prepare('DELETE FROM plugins'),
|
|
@@ -86,7 +87,10 @@ async function getStmts() {
|
|
|
86
87
|
// JSON-encoded TEXT in storage. They round-trip through JSON.parse/stringify
|
|
87
88
|
// so callers see the same shape the JSON-backed module returned — including
|
|
88
89
|
// `undefined` for optional fields (mirroring the JSON object layout where
|
|
89
|
-
// missing keys are absent rather than null).
|
|
90
|
+
// missing keys are absent rather than null). `hosting` is plain TEXT
|
|
91
|
+
// ('server' | 'agent'; rows from before 4.2 default to 'server'): an
|
|
92
|
+
// agent-hosted plugin is installed (npm, manifest) like any other, but the
|
|
93
|
+
// plugin host never imports it — see `pluginHostPlugin`.
|
|
90
94
|
|
|
91
95
|
function rowToPlugin(row) {
|
|
92
96
|
const entry = {
|
|
@@ -94,6 +98,7 @@ function rowToPlugin(row) {
|
|
|
94
98
|
packageName: row.package_name,
|
|
95
99
|
version: row.version,
|
|
96
100
|
capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
|
|
101
|
+
hosting: row.hosting,
|
|
97
102
|
status: row.status,
|
|
98
103
|
installedAt: row.installed_at,
|
|
99
104
|
};
|
|
@@ -155,6 +160,7 @@ export async function writePlugins(data) {
|
|
|
155
160
|
nullableJson(p.panel),
|
|
156
161
|
nullableJson(p.config),
|
|
157
162
|
nullableJson(p.modes),
|
|
163
|
+
p.hosting ?? DEFAULT_PLUGIN_HOSTING,
|
|
158
164
|
p.status,
|
|
159
165
|
p.installedAt,
|
|
160
166
|
nullableString(p.enabledAt),
|
|
@@ -210,6 +216,26 @@ function assertSafeLamalibrePackage(packageName) {
|
|
|
210
216
|
}
|
|
211
217
|
}
|
|
212
218
|
|
|
219
|
+
/**
|
|
220
|
+
* After a lifecycle change, bring Authelia's OIDC clients and the secret
|
|
221
|
+
* store in line with the installed plugins. Best effort — logged, never
|
|
222
|
+
* thrown — so a plugin operation does not fail because Authelia could not
|
|
223
|
+
* be restarted. Returns the secrets of confidential clients this change
|
|
224
|
+
* created (`[{ id, secret }]`), shown to the admin exactly once.
|
|
225
|
+
* @param {import('pino').Logger} logger
|
|
226
|
+
* @returns {Promise<Array<{ id: string, secret: string }>>}
|
|
227
|
+
*/
|
|
228
|
+
async function syncOidcClientsBestEffort(logger) {
|
|
229
|
+
try {
|
|
230
|
+
const { syncOidcClients } = await import('./plugin-oidc.js');
|
|
231
|
+
const result = await syncOidcClients(logger);
|
|
232
|
+
return (result?.createdSecrets ?? []).map(({ id, secret }) => ({ id, secret }));
|
|
233
|
+
} catch (err) {
|
|
234
|
+
logger.warn({ err: err.message }, 'Failed to sync plugin OIDC clients to Authelia');
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
213
239
|
export function installPlugin(packageName, logger) {
|
|
214
240
|
return withPluginLock(async () => {
|
|
215
241
|
// Validate package scope
|
|
@@ -339,6 +365,7 @@ export function installPlugin(packageName, logger) {
|
|
|
339
365
|
panel: manifest.panel,
|
|
340
366
|
config: manifest.config,
|
|
341
367
|
modes: manifest.modes,
|
|
368
|
+
hosting: manifest.hosting,
|
|
342
369
|
status: 'disabled',
|
|
343
370
|
installedAt: new Date().toISOString(),
|
|
344
371
|
};
|
|
@@ -356,6 +383,7 @@ export function installPlugin(packageName, logger) {
|
|
|
356
383
|
nullableJson(entry.panel),
|
|
357
384
|
nullableJson(entry.config),
|
|
358
385
|
nullableJson(entry.modes),
|
|
386
|
+
entry.hosting,
|
|
359
387
|
entry.status,
|
|
360
388
|
entry.installedAt,
|
|
361
389
|
null,
|
|
@@ -367,7 +395,8 @@ export function installPlugin(packageName, logger) {
|
|
|
367
395
|
}
|
|
368
396
|
|
|
369
397
|
logger.info({ name: manifest.name, packageName }, 'Plugin installed');
|
|
370
|
-
|
|
398
|
+
const oidcClients = await syncOidcClientsBestEffort(logger);
|
|
399
|
+
return { ...entry, oidcClients };
|
|
371
400
|
});
|
|
372
401
|
}
|
|
373
402
|
|
|
@@ -436,6 +465,7 @@ export function uninstallPlugin(name, logger) {
|
|
|
436
465
|
}
|
|
437
466
|
|
|
438
467
|
logger.info({ name }, 'Plugin uninstalled');
|
|
468
|
+
await syncOidcClientsBestEffort(logger);
|
|
439
469
|
return { ok: true, name };
|
|
440
470
|
});
|
|
441
471
|
}
|
|
@@ -470,7 +500,8 @@ export function enablePlugin(name, logger) {
|
|
|
470
500
|
}
|
|
471
501
|
|
|
472
502
|
logger?.info({ name }, 'Plugin enabled');
|
|
473
|
-
|
|
503
|
+
const oidcClients = await syncOidcClientsBestEffort(logger);
|
|
504
|
+
return { ok: true, name, status: 'enabled', oidcClients };
|
|
474
505
|
});
|
|
475
506
|
}
|
|
476
507
|
|
|
@@ -504,6 +535,7 @@ export function disablePlugin(name, logger) {
|
|
|
504
535
|
}
|
|
505
536
|
|
|
506
537
|
logger?.info({ name }, 'Plugin disabled');
|
|
538
|
+
await syncOidcClientsBestEffort(logger);
|
|
507
539
|
return { ok: true, name, status: 'disabled' };
|
|
508
540
|
});
|
|
509
541
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry credential (Feria) — daemon shim over `@lamalibre/lamaste/server`.
|
|
3
|
+
*
|
|
4
|
+
* Binds the core module to this daemon's state directory and holds the
|
|
5
|
+
* per-process state the core keeps pure:
|
|
6
|
+
*
|
|
7
|
+
* - the panel's own registry token (`RegistryServiceTokenCache`): a pasted
|
|
8
|
+
* token as is, or — with `auth.kind: 'oidc'` — a Feria token obtained
|
|
9
|
+
* through Authelia's client-credentials grant and reused until five
|
|
10
|
+
* minutes before it expires;
|
|
11
|
+
* - one brokered token per agent (`AgentTokenBroker`), reused for every
|
|
12
|
+
* install within the hour and limited to ten new tokens per agent per
|
|
13
|
+
* hour.
|
|
14
|
+
*
|
|
15
|
+
* Also the one piece of policy that belongs here: when a user loses a
|
|
16
|
+
* `feria-*` group or is deleted, their registry tokens are revoked (best
|
|
17
|
+
* effort, never blocks the user operation).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
readRegistryCredentialConfig as readCore,
|
|
22
|
+
writeRegistryCredentialConfig as writeCore,
|
|
23
|
+
clearRegistryCredentialConfig as clearCore,
|
|
24
|
+
verifyRegistryCredential as verifyCore,
|
|
25
|
+
brokerAgentRegistryToken,
|
|
26
|
+
revokeRegistryTokensForOwner,
|
|
27
|
+
lostFeriaGroups,
|
|
28
|
+
readOidcClientSecret,
|
|
29
|
+
RegistryServiceTokenCache,
|
|
30
|
+
AgentTokenBroker,
|
|
31
|
+
RegistryCredentialError,
|
|
32
|
+
LAMASTE_SERVER_OIDC_CLIENT_ID,
|
|
33
|
+
} from '@lamalibre/lamaste/server';
|
|
34
|
+
|
|
35
|
+
import { getConfig } from './config.js';
|
|
36
|
+
|
|
37
|
+
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
38
|
+
|
|
39
|
+
const serviceTokens = new RegistryServiceTokenCache();
|
|
40
|
+
const agentTokens = new AgentTokenBroker();
|
|
41
|
+
|
|
42
|
+
export function readRegistryCredentialConfig() {
|
|
43
|
+
return readCore(STATE_DIR);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Store a credential and forget every token obtained with the previous one. */
|
|
47
|
+
export async function writeRegistryCredentialConfig(config) {
|
|
48
|
+
await writeCore(STATE_DIR, config);
|
|
49
|
+
resetRegistryTokenState();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function clearRegistryCredentialConfig() {
|
|
53
|
+
const removed = await clearCore(STATE_DIR);
|
|
54
|
+
resetRegistryTokenState();
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop the cached service token and every agent's cached token and issuance record. */
|
|
59
|
+
export function resetRegistryTokenState() {
|
|
60
|
+
serviceTokens.invalidate();
|
|
61
|
+
agentTokens.reset();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What the core needs to run the OIDC chain for a credential: the relay
|
|
66
|
+
* domain and the `lamaste-server` client secret. Pasted-token credentials
|
|
67
|
+
* need nothing.
|
|
68
|
+
* @param {{ auth: { kind: string } }} config
|
|
69
|
+
*/
|
|
70
|
+
async function oidcDeps(config) {
|
|
71
|
+
if (config.auth.kind !== 'oidc') return {};
|
|
72
|
+
const domain = getConfig().domain;
|
|
73
|
+
if (!domain) {
|
|
74
|
+
throw new RegistryCredentialError(
|
|
75
|
+
'the panel has no domain yet — complete onboarding first',
|
|
76
|
+
503,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const clientSecret = await readOidcClientSecret(STATE_DIR, LAMASTE_SERVER_OIDC_CLIENT_ID);
|
|
80
|
+
if (!clientSecret) {
|
|
81
|
+
throw new RegistryCredentialError(
|
|
82
|
+
`the ${LAMASTE_SERVER_OIDC_CLIENT_ID} OIDC client has no secret yet — set the registry credential again`,
|
|
83
|
+
500,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { oidc: { domain, clientSecret } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Verify a candidate credential end to end (for `oidc`: Authelia token
|
|
91
|
+
* endpoint → Feria login → whoami with the admin scope). The
|
|
92
|
+
* `lamaste-server` client must already be in Authelia — the route syncs it
|
|
93
|
+
* first.
|
|
94
|
+
* @param {{ registryUrl: string, auth: { kind: 'token', token: string } | { kind: 'oidc', clientId: string } }} candidate
|
|
95
|
+
*/
|
|
96
|
+
export async function verifyRegistryCredential(candidate) {
|
|
97
|
+
return verifyCore(candidate, await oidcDeps(candidate));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The panel's registry token for the stored credential (cached per process). */
|
|
101
|
+
async function serviceToken(config) {
|
|
102
|
+
return (await serviceTokens.resolve(config, await oidcDeps(config))).token;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The calling agent's registry token: the cached one when it is still good
|
|
107
|
+
* for an install, otherwise a freshly brokered one-hour token — unless the
|
|
108
|
+
* agent already had ten new tokens this hour. `null` when no credential is
|
|
109
|
+
* configured. Throws RegistryCredentialError (with `statusCode`) when the
|
|
110
|
+
* registry refuses.
|
|
111
|
+
* @param {string} agentLabel
|
|
112
|
+
* @returns {Promise<import('@lamalibre/lamaste/server').AgentTokenAcquisition | null>}
|
|
113
|
+
*/
|
|
114
|
+
export async function acquireAgentToken(agentLabel) {
|
|
115
|
+
const config = await readRegistryCredentialConfig();
|
|
116
|
+
if (!config) return null;
|
|
117
|
+
return agentTokens.acquire(agentLabel, async () =>
|
|
118
|
+
brokerAgentRegistryToken(config, agentLabel, { serviceToken: await serviceToken(config) }),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Revoke a user's registry tokens after their `feria-*` groups changed or
|
|
124
|
+
* they were deleted. Returns the number revoked, or `null` when nothing
|
|
125
|
+
* needed doing (no credential configured, or no feria group lost).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} username
|
|
128
|
+
* @param {{ before: string[], after: string[] } | { deleted: true }} change
|
|
129
|
+
* @param {import('pino').Logger} log
|
|
130
|
+
*/
|
|
131
|
+
export async function revokeUserRegistryTokens(username, change, log) {
|
|
132
|
+
const config = await readRegistryCredentialConfig();
|
|
133
|
+
if (!config) return null;
|
|
134
|
+
|
|
135
|
+
const lost =
|
|
136
|
+
'deleted' in change ? ['(deleted)'] : lostFeriaGroups(change.before ?? [], change.after ?? []);
|
|
137
|
+
if (lost.length === 0) return null;
|
|
138
|
+
|
|
139
|
+
const owner = `user:${username}`;
|
|
140
|
+
const revoked = await revokeRegistryTokensForOwner(config, owner, {
|
|
141
|
+
serviceToken: await serviceToken(config),
|
|
142
|
+
});
|
|
143
|
+
log.info({ username, lost, revoked }, 'Revoked registry tokens after group change');
|
|
144
|
+
return revoked;
|
|
145
|
+
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from '../../lib/mtls.js';
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
|
|
17
|
+
import { RegistryCredentialError } from '@lamalibre/lamaste/server';
|
|
18
|
+
import { acquireAgentToken } from '../../lib/registry-credential.js';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* An agent may rotate its own chisel credential at most this often. Every
|
|
@@ -113,6 +115,85 @@ export default async function agentsRoutes(fastify, _opts) {
|
|
|
113
115
|
},
|
|
114
116
|
);
|
|
115
117
|
|
|
118
|
+
// ------------------------------------------------------------------
|
|
119
|
+
// GET /agents/me/registry-credential
|
|
120
|
+
//
|
|
121
|
+
// Brokers a one-hour, read-only npm registry token for the calling agent
|
|
122
|
+
// so it can `npm install` a plugin from the Lamalibre registry (Feria)
|
|
123
|
+
// without ever holding a long-lived credential. Identity comes strictly
|
|
124
|
+
// from the mTLS cert; the token is owned by `agent:<label>` on the
|
|
125
|
+
// registry, so its audit log says which agent installed what.
|
|
126
|
+
//
|
|
127
|
+
// The token is cached per agent until five minutes before it expires, so
|
|
128
|
+
// repeated installs within the hour get the same one; an agent may be
|
|
129
|
+
// issued at most ten new tokens per hour (429 + Retry-After beyond that).
|
|
130
|
+
// The per-IP limit stays as a backstop.
|
|
131
|
+
//
|
|
132
|
+
// 404 when the panel has no registry credential configured — the agent
|
|
133
|
+
// then installs from the public registry as before.
|
|
134
|
+
// ------------------------------------------------------------------
|
|
135
|
+
fastify.get(
|
|
136
|
+
'/agents/me/registry-credential',
|
|
137
|
+
{
|
|
138
|
+
preHandler: fastify.requireRole(['agent']),
|
|
139
|
+
// One token per install is the expected rate; this only stops a loop.
|
|
140
|
+
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
|
141
|
+
},
|
|
142
|
+
async (request, reply) => {
|
|
143
|
+
// requireRole lets the admin master role through every guard; an admin
|
|
144
|
+
// certificate has no agent identity to broker for. Plugin-agents pass
|
|
145
|
+
// here and are answered 400 below.
|
|
146
|
+
if (request.certRole !== 'agent' && request.certRole !== 'plugin-agent') {
|
|
147
|
+
return reply
|
|
148
|
+
.code(403)
|
|
149
|
+
.send({ error: 'Only an agent certificate can obtain a registry credential' });
|
|
150
|
+
}
|
|
151
|
+
const label = request.certLabel;
|
|
152
|
+
if (!label) {
|
|
153
|
+
return reply.code(400).send({
|
|
154
|
+
error: 'Agent label could not be determined from client certificate',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (!AGENT_LABEL_REGEX.test(label)) {
|
|
158
|
+
return reply.code(400).send({ error: 'Plugin-agents do not install plugins' });
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const acquired = await acquireAgentToken(label);
|
|
162
|
+
if (!acquired) {
|
|
163
|
+
return reply.code(404).send({
|
|
164
|
+
error: 'No registry credential is configured on this panel',
|
|
165
|
+
hint: 'lamaste-server registry-credential set --url <registry> --oidc',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (!acquired.ok) {
|
|
169
|
+
request.log.warn(
|
|
170
|
+
{ label, retryAfterSeconds: acquired.retryAfterSeconds },
|
|
171
|
+
'Agent exceeded its registry token issuance limit',
|
|
172
|
+
);
|
|
173
|
+
return reply.code(429).header('Retry-After', String(acquired.retryAfterSeconds)).send({
|
|
174
|
+
error: 'This agent has been issued its hourly share of registry tokens',
|
|
175
|
+
retryAfterSeconds: acquired.retryAfterSeconds,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const { credential } = acquired;
|
|
179
|
+
request.log.info(
|
|
180
|
+
{
|
|
181
|
+
label,
|
|
182
|
+
tokenId: credential.tokenId,
|
|
183
|
+
expiresAt: credential.expiresAt,
|
|
184
|
+
cached: acquired.cached,
|
|
185
|
+
},
|
|
186
|
+
acquired.cached ? 'Reused registry token for agent' : 'Brokered registry token for agent',
|
|
187
|
+
);
|
|
188
|
+
return credential;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
|
|
191
|
+
request.log.warn({ label, err: err.message }, 'Failed to broker registry token');
|
|
192
|
+
return reply.code(status).send({ error: err.message || 'Failed to broker registry token' });
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
116
197
|
// ------------------------------------------------------------------
|
|
117
198
|
// GET /agents/me/capabilities
|
|
118
199
|
//
|