@lamalibre/create-lamaste 4.2.0 → 4.3.1
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 +13 -8
- package/scripts/lamaste-priv +205 -2
- package/src/index.js +8 -4
- package/src/lib/node-version.js +114 -0
- package/src/lib/panel-pin.js +41 -0
- package/src/lib/relay-services.js +15 -1
- package/src/lib/service-config.js +113 -0
- package/src/tasks/gatekeeper.js +8 -3
- package/src/tasks/node.js +34 -75
- package/src/tasks/redeploy.js +36 -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 -3
- package/vendor/docs/01-concepts/authorization.md +29 -17
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
- package/vendor/docs/01-concepts/security-model.md +110 -35
- 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 +34 -16
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
- package/vendor/docs/02-guides/desktop-app-setup.md +11 -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 +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
- package/vendor/docs/03-architecture/overview.md +8 -8
- 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/agent-hosted-plugins.md +407 -0
- package/vendor/docs/04-api-reference/app-host.md +360 -0
- package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
- package/vendor/docs/04-api-reference/certificates.md +95 -38
- package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -111
- package/vendor/docs/04-api-reference/registry-and-oidc.md +8 -4
- package/vendor/docs/04-api-reference/tunnels.md +15 -13
- package/vendor/docs/05-operations/backup-and-restore.md +3 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +25 -20
- package/vendor/docs/06-reference/config-files.md +195 -97
- 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 +33 -8
- package/vendor/docs/_index.json +20 -0
- package/vendor/docs/gatekeeper-design.md +22 -19
- package/vendor/docs/identity-directory-design.md +57 -56
- 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/types.d.ts +12 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +47 -8
- 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 +233 -141
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +4 -3
- package/vendor/server/bin/lamaste-server.js +25 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/audit.js +60 -0
- package/vendor/server/src/commands/panel-pin.js +40 -0
- package/vendor/server/src/commands/plugins.js +963 -12
- package/vendor/server/src/commands/push-install.js +317 -0
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/uninstall.js +28 -1
- package/vendor/server/src/ndjson.js +4 -2
- package/vendor/server/src/panel-api.js +277 -34
- package/vendor/server/src/panel-identity.js +165 -0
- package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +84 -0
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +56 -4
- package/vendor/serverd/src/lib/actor.js +24 -0
- package/vendor/serverd/src/lib/agent-jobs.js +330 -0
- package/vendor/serverd/src/lib/app-error.js +10 -0
- package/vendor/serverd/src/lib/audit.js +145 -0
- package/vendor/serverd/src/lib/hook-effects.js +123 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
- package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
- package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
- package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
- package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +89 -8
- package/vendor/serverd/src/lib/nginx.js +298 -44
- package/vendor/serverd/src/lib/notifications.js +107 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
- package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
- package/vendor/serverd/src/lib/plugin-operations.js +295 -0
- package/vendor/serverd/src/lib/plugin-packages.js +249 -0
- package/vendor/serverd/src/lib/plugins.js +298 -68
- package/vendor/serverd/src/lib/push-install.js +88 -2
- package/vendor/serverd/src/lib/relay-hooks.js +100 -0
- package/vendor/serverd/src/lib/relay-signing.js +43 -0
- package/vendor/serverd/src/lib/request-hooks.js +79 -0
- package/vendor/serverd/src/lib/retained-data.js +99 -0
- package/vendor/serverd/src/lib/trust-proxy.js +34 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
- package/vendor/serverd/src/middleware/twofa-session.js +11 -1
- package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
- package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
- package/vendor/serverd/src/routes/management/agents.js +262 -1
- package/vendor/serverd/src/routes/management/audit.js +81 -0
- package/vendor/serverd/src/routes/management/certs.js +1 -15
- package/vendor/serverd/src/routes/management/plugins.js +569 -54
- package/vendor/serverd/src/routes/management/tunnels.js +26 -0
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup: bring installed vhosts in line with what the generators in
|
|
3
|
+
* nginx.js produce today.
|
|
4
|
+
*
|
|
5
|
+
* A vhost is written when its tunnel or site is created or reconfigured, so a
|
|
6
|
+
* generator change never reached the ones that already existed. Two changes
|
|
7
|
+
* must:
|
|
8
|
+
*
|
|
9
|
+
* - from 4.3 lamaste-agentd accepts a request from the relay only with a
|
|
10
|
+
* relay assertion, which only the current agent-panel vhost (signed by the
|
|
11
|
+
* panel) and plugin vhosts (signed by Gatekeeper) forward;
|
|
12
|
+
* - the authorization subrequest of a Gatekeeper tunnel, of an
|
|
13
|
+
* Authelia-protected static site and of the auth portal named the client's
|
|
14
|
+
* `Host` header rather than the vhost's own hostname, so a request line for
|
|
15
|
+
* one hostname sent with the `Host` of another was authorized for the other
|
|
16
|
+
* (see `originalUrl` in nginx.js).
|
|
17
|
+
*
|
|
18
|
+
* Each installed vhost is rendered again from state, on the certificate
|
|
19
|
+
* directory the installed file names (a wildcard certificate stays a wildcard
|
|
20
|
+
* certificate), and rewritten only when the text differs — through the same
|
|
21
|
+
* backup / test / rollback path as any vhost write. A rewrite never links a
|
|
22
|
+
* vhost that is not linked now.
|
|
23
|
+
* A rewrite never adds or removes authentication: a vhost that disagrees with
|
|
24
|
+
* its state on that is reported and left alone. The one exception is the
|
|
25
|
+
* agent-panel vhost, whose mTLS check gains the panel's `auth_request`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { withTunnelLock } from '@lamalibre/lamaste/server';
|
|
29
|
+
import {
|
|
30
|
+
installVhost,
|
|
31
|
+
isSiteEnabled,
|
|
32
|
+
readInstalledVhost,
|
|
33
|
+
renderAgentPanelVhost,
|
|
34
|
+
renderAuthVhost,
|
|
35
|
+
renderStaticSiteVhost,
|
|
36
|
+
renderTunnelVhost,
|
|
37
|
+
vhostCertDir,
|
|
38
|
+
} from './nginx.js';
|
|
39
|
+
import { readSites, readTunnels } from './state.js';
|
|
40
|
+
|
|
41
|
+
/** A subdomain or site id as it may appear in a site name. */
|
|
42
|
+
const SITE_NAME_PART_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/;
|
|
43
|
+
|
|
44
|
+
const AUTH_VHOST = 'lamalibre-lamaste-auth';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The vhost a tunnel should have, rendered on `certDir`; null for tunnels
|
|
48
|
+
* without one (internal plugin tunnels) or whose state cannot be rendered.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} tunnel
|
|
51
|
+
* @param {string} certDir
|
|
52
|
+
* @returns {{ name: string, fqdn: string, config: string } | null}
|
|
53
|
+
*/
|
|
54
|
+
export function expectedTunnelVhost(tunnel, certDir) {
|
|
55
|
+
if (!managedTunnelVhostName(tunnel) || typeof tunnel.fqdn !== 'string') return null;
|
|
56
|
+
if (!tunnel.fqdn.startsWith(`${tunnel.subdomain}.`)) return null;
|
|
57
|
+
const domain = tunnel.fqdn.slice(tunnel.subdomain.length + 1);
|
|
58
|
+
if (tunnel.type === 'panel') {
|
|
59
|
+
return renderAgentPanelVhost(tunnel.subdomain, domain, tunnel.port, certDir);
|
|
60
|
+
}
|
|
61
|
+
// As createTunnel and updateTunnel write it: the stored access mode
|
|
62
|
+
// (restricted when absent, as Gatekeeper reads it), the plugin route of a
|
|
63
|
+
// plugin tunnel, and a body limit only when the tunnel has one.
|
|
64
|
+
return renderTunnelVhost(
|
|
65
|
+
tunnel.accessMode ?? 'restricted',
|
|
66
|
+
tunnel.subdomain,
|
|
67
|
+
domain,
|
|
68
|
+
tunnel.port,
|
|
69
|
+
certDir,
|
|
70
|
+
{
|
|
71
|
+
...(tunnel.type === 'plugin' && tunnel.pluginRoute ? { pathPrefix: tunnel.pluginRoute } : {}),
|
|
72
|
+
...(tunnel.maxBodySizeMb !== undefined ? { maxBodySizeMb: tunnel.maxBodySizeMb } : {}),
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Site name of a tunnel's vhost, or null for a tunnel without one. */
|
|
78
|
+
function managedTunnelVhostName(tunnel) {
|
|
79
|
+
if (tunnel.exposure === 'internal' || typeof tunnel.subdomain !== 'string') return null;
|
|
80
|
+
if (!SITE_NAME_PART_RE.test(tunnel.subdomain)) return null;
|
|
81
|
+
if (tunnel.type === 'panel') return `lamalibre-lamaste-agent-panel-${tunnel.subdomain}`;
|
|
82
|
+
// Tunnels persisted before `type` existed are app tunnels.
|
|
83
|
+
if (tunnel.type === 'app' || tunnel.type === 'plugin' || tunnel.type === undefined) {
|
|
84
|
+
return `lamalibre-lamaste-app-${tunnel.subdomain}`;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when a vhost asks for authorization before proxying or serving. */
|
|
90
|
+
function authenticates(config) {
|
|
91
|
+
return /^\s*auth_request\s/m.test(config);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function newResult() {
|
|
95
|
+
return { rewritten: [], unchanged: 0, skipped: [], failed: [] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compare one installed vhost with what it should be and rewrite it when it
|
|
100
|
+
* differs; record the outcome in `result` under `label`.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} p
|
|
103
|
+
* @param {string} p.name - site name in sites-available
|
|
104
|
+
* @param {string} p.label - how the result names it: the vhost's hostname
|
|
105
|
+
* @param {(certDir: string) => ({ name: string, fqdn: string, config: string } | null)} p.render
|
|
106
|
+
* @param {() => Promise<boolean>} p.enabled - whether the rewritten site is linked
|
|
107
|
+
* @param {boolean} [p.keepAuthentication] - refuse a rewrite that adds or removes `auth_request` (default true)
|
|
108
|
+
* @param {boolean} [p.reportMissing] - report a vhost that is not installed (default true)
|
|
109
|
+
* @param {ReturnType<typeof newResult>} result
|
|
110
|
+
* @param {{ readInstalledVhost: typeof readInstalledVhost, installVhost: typeof installVhost }} deps
|
|
111
|
+
*/
|
|
112
|
+
async function reconcileOne(
|
|
113
|
+
{ name, label, render, enabled, keepAuthentication = true, reportMissing = true },
|
|
114
|
+
result,
|
|
115
|
+
deps,
|
|
116
|
+
) {
|
|
117
|
+
try {
|
|
118
|
+
const installed = await deps.readInstalledVhost(name);
|
|
119
|
+
if (installed === null) {
|
|
120
|
+
if (reportMissing) result.skipped.push({ vhost: label, reason: 'no vhost installed' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const certDir = vhostCertDir(installed);
|
|
124
|
+
if (!certDir) {
|
|
125
|
+
result.skipped.push({ vhost: label, reason: 'installed vhost names no certificate' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const expected = render(certDir);
|
|
129
|
+
if (!expected) {
|
|
130
|
+
result.skipped.push({ vhost: label, reason: 'state cannot be rendered' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (expected.config === installed) {
|
|
134
|
+
result.unchanged += 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (keepAuthentication && authenticates(expected.config) !== authenticates(installed)) {
|
|
138
|
+
result.skipped.push({
|
|
139
|
+
vhost: label,
|
|
140
|
+
reason:
|
|
141
|
+
'installed vhost and state disagree on whether it authenticates; reconfigure it to rewrite the vhost',
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await deps.installVhost(expected.name, expected.config, expected.fqdn, {
|
|
146
|
+
enabled: await enabled(),
|
|
147
|
+
});
|
|
148
|
+
result.rewritten.push(label);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
result.failed.push({ vhost: label, error: err instanceof Error ? err.message : String(err) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {{ info(o: object, m?: string): void, warn(o: object, m?: string): void, error(o: object, m?: string): void }} log
|
|
156
|
+
* @param {'tunnel' | 'site'} what - the vhosts the result is about, for the log messages
|
|
157
|
+
* @param {ReturnType<typeof newResult>} result
|
|
158
|
+
*/
|
|
159
|
+
function logResult(log, what, result) {
|
|
160
|
+
if (result.rewritten.length > 0) {
|
|
161
|
+
log.info({ rewritten: result.rewritten }, `Rewrote ${what} vhosts`);
|
|
162
|
+
}
|
|
163
|
+
if (result.skipped.length > 0) {
|
|
164
|
+
log.warn({ skipped: result.skipped }, `Left ${what} vhosts as they are`);
|
|
165
|
+
}
|
|
166
|
+
for (const failure of result.failed) {
|
|
167
|
+
log.error(failure, `Failed to rewrite ${what} vhost; the previous vhost was restored`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const defaultTunnelDeps = { readTunnels, readInstalledVhost, installVhost, isSiteEnabled };
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Reconcile the vhost of every tunnel that has one: agent-panel, app and
|
|
175
|
+
* plugin tunnels. Runs under the tunnel lock, so no tunnel change
|
|
176
|
+
* interleaves. Call it once the panel listens: a rewritten agent-panel vhost
|
|
177
|
+
* asks the panel about every request.
|
|
178
|
+
*
|
|
179
|
+
* @param {Parameters<typeof logResult>[0]} log
|
|
180
|
+
* @param {typeof defaultTunnelDeps} [deps]
|
|
181
|
+
* @returns {Promise<ReturnType<typeof newResult>>}
|
|
182
|
+
*/
|
|
183
|
+
export async function reconcileTunnelVhosts(log, deps = defaultTunnelDeps) {
|
|
184
|
+
return withTunnelLock(async () => {
|
|
185
|
+
const result = newResult();
|
|
186
|
+
for (const tunnel of await deps.readTunnels()) {
|
|
187
|
+
const name = managedTunnelVhostName(tunnel);
|
|
188
|
+
if (!name) continue;
|
|
189
|
+
await reconcileOne(
|
|
190
|
+
{
|
|
191
|
+
name,
|
|
192
|
+
label: tunnel.fqdn ?? tunnel.subdomain,
|
|
193
|
+
render: (certDir) => expectedTunnelVhost(tunnel, certDir),
|
|
194
|
+
// Linked only when it is now and the tunnel is enabled: a rewrite of
|
|
195
|
+
// the text never publishes a vhost someone unlinked, and unlinks a
|
|
196
|
+
// disabled tunnel's.
|
|
197
|
+
enabled: async () => tunnel.enabled !== false && (await deps.isSiteEnabled(name)),
|
|
198
|
+
keepAuthentication: tunnel.type !== 'panel',
|
|
199
|
+
},
|
|
200
|
+
result,
|
|
201
|
+
deps,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
logResult(log, 'tunnel', result);
|
|
205
|
+
return result;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const defaultSiteDeps = { readSites, readInstalledVhost, installVhost, isSiteEnabled };
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Reconcile the vhosts that ask Authelia directly: the auth portal
|
|
213
|
+
* (`auth.<domain>`) and every static site. Site changes take no lock, so call
|
|
214
|
+
* it before the panel listens — then nothing can change a site meanwhile.
|
|
215
|
+
*
|
|
216
|
+
* @param {Parameters<typeof logResult>[0]} log
|
|
217
|
+
* @param {string} domain
|
|
218
|
+
* @param {typeof defaultSiteDeps} [deps]
|
|
219
|
+
* @returns {Promise<ReturnType<typeof newResult>>}
|
|
220
|
+
*/
|
|
221
|
+
export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
|
|
222
|
+
const result = newResult();
|
|
223
|
+
|
|
224
|
+
await reconcileOne(
|
|
225
|
+
{
|
|
226
|
+
name: AUTH_VHOST,
|
|
227
|
+
label: `auth.${domain}`,
|
|
228
|
+
render: (certDir) => renderAuthVhost(domain, certDir),
|
|
229
|
+
enabled: () => deps.isSiteEnabled(AUTH_VHOST),
|
|
230
|
+
},
|
|
231
|
+
result,
|
|
232
|
+
deps,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
let sites = [];
|
|
236
|
+
try {
|
|
237
|
+
sites = await deps.readSites();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
result.failed.push({ vhost: 'sites', error: err instanceof Error ? err.message : String(err) });
|
|
240
|
+
}
|
|
241
|
+
for (const site of sites) {
|
|
242
|
+
if (typeof site.id !== 'string' || !SITE_NAME_PART_RE.test(site.id)) continue;
|
|
243
|
+
const name = `lamalibre-lamaste-site-${site.id}`;
|
|
244
|
+
await reconcileOne(
|
|
245
|
+
{
|
|
246
|
+
name,
|
|
247
|
+
label: site.fqdn ?? site.id,
|
|
248
|
+
render: (certDir) => renderStaticSiteVhost(site, certDir, domain),
|
|
249
|
+
enabled: () => deps.isSiteEnabled(name),
|
|
250
|
+
// A site still waiting for its DNS or certificate has no vhost yet.
|
|
251
|
+
reportMissing: site.certIssued === true,
|
|
252
|
+
},
|
|
253
|
+
result,
|
|
254
|
+
deps,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
logResult(log, 'site', result);
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
@@ -14,6 +14,15 @@ import {
|
|
|
14
14
|
*/
|
|
15
15
|
const EXEMPT_PATHS = new Set(['/api/health', '/api/settings/2fa', '/api/settings/2fa/verify']);
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The agent-panel vhost's authorization subrequest
|
|
19
|
+
* (`/api/agent-panel/authz/<label>`). It is answered for a browser on
|
|
20
|
+
* `agent-<label>.<domain>`, which never carries the panel's host-only 2FA
|
|
21
|
+
* cookie; agent panels are guarded by the client certificate alone, as they
|
|
22
|
+
* were before the panel took part in their authorization.
|
|
23
|
+
*/
|
|
24
|
+
const AGENT_PANEL_AUTHZ_RE = /^\/api\/agent-panel\/authz\/[a-z0-9-]{1,63}$/;
|
|
25
|
+
|
|
17
26
|
async function twofaSessionPlugin(fastify, _opts) {
|
|
18
27
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
19
28
|
const config = getConfig();
|
|
@@ -30,7 +39,8 @@ async function twofaSessionPlugin(fastify, _opts) {
|
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
// Exempt paths
|
|
33
|
-
|
|
42
|
+
const urlPath = request.url.split('?')[0];
|
|
43
|
+
if (EXEMPT_PATHS.has(urlPath) || AGENT_PANEL_AUTHZ_RE.test(urlPath)) {
|
|
34
44
|
return;
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/<route>/*` for agent-hosted plugins with an agent half — forwarded
|
|
3
|
+
* by the panel to the plugin's agent half over its internal tunnel, through
|
|
4
|
+
* the plugin's approved request hooks (`request:pre-forward` /
|
|
5
|
+
* `request:post-forward`) and, while it is down, its `tunnel:down` fallback.
|
|
6
|
+
* See `createHookedForwarder` in `@lamalibre/lamaste/server`.
|
|
7
|
+
*
|
|
8
|
+
* Registered after every other `/api` route: static routes win over its
|
|
9
|
+
* `/:route/*`, so only what nothing else answers arrives here, and a route
|
|
10
|
+
* that is no hosted plugin gets the panel's usual 404.
|
|
11
|
+
*/
|
|
12
|
+
import { createHookedForwarder } from '@lamalibre/lamaste/server';
|
|
13
|
+
import { getConfig } from '../lib/config.js';
|
|
14
|
+
import { resolveForwardTarget, authorizeForwardedCaller } from '../lib/plugin-hosting.js';
|
|
15
|
+
import { requestHookRuntime } from '../lib/request-hooks.js';
|
|
16
|
+
|
|
17
|
+
export default async function agentForwarderRoutes(fastify) {
|
|
18
|
+
const forwarder = createHookedForwarder({
|
|
19
|
+
resolveTarget: resolveForwardTarget,
|
|
20
|
+
authorize: authorizeForwardedCaller,
|
|
21
|
+
// Management routes answer 503 before onboarding completes; so does a
|
|
22
|
+
// hosted plugin's route (but a non-route stays a 404, as before).
|
|
23
|
+
guard: () => {
|
|
24
|
+
const config = getConfig();
|
|
25
|
+
if (config.onboarding.status !== 'COMPLETED') {
|
|
26
|
+
return {
|
|
27
|
+
status: 503,
|
|
28
|
+
body: { error: 'Onboarding not complete', onboardingStatus: config.onboarding.status },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
},
|
|
33
|
+
logger: fastify.log,
|
|
34
|
+
hooks: requestHookRuntime,
|
|
35
|
+
});
|
|
36
|
+
await fastify.register(forwarder);
|
|
37
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/agent-panel/authz/:label — the `auth_request` target of an
|
|
3
|
+
* agent-panel vhost (`agent-<label>.<domain>`).
|
|
4
|
+
*
|
|
5
|
+
* nginx calls it for every request on that vhost, with the client
|
|
6
|
+
* certificate's verification result in the `X-SSL-Client-*` headers (so the
|
|
7
|
+
* mTLS middleware refuses a revoked or unknown certificate, as for any panel
|
|
8
|
+
* route) and the request's method and target in `X-Original-Method` /
|
|
9
|
+
* `X-Original-URI`. On success it answers 200 with a relay assertion in
|
|
10
|
+
* `X-Lamalibre-Relay-Assertion`, which nginx forwards to the agent's
|
|
11
|
+
* lamaste-agentd — the only proof of the caller the daemon accepts from the
|
|
12
|
+
* relay. A 401/403 from here is what nginx answers the browser.
|
|
13
|
+
*
|
|
14
|
+
* Through the panel's own vhosts this route is reachable too, with
|
|
15
|
+
* client-chosen `X-Original-*`. That gains nothing: the assertion only ever
|
|
16
|
+
* names the caller's own, nginx-verified certificate, for a panel that
|
|
17
|
+
* certificate may use anyway.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { signRelayAssertion, RELAY_ASSERTION_HEADER } from '@lamalibre/lamaste';
|
|
22
|
+
import { authorizeAgentPanelRequest } from '@lamalibre/lamaste/server';
|
|
23
|
+
import { readTunnels } from '../../lib/state.js';
|
|
24
|
+
import { relaySigner } from '../../lib/relay-signing.js';
|
|
25
|
+
|
|
26
|
+
const LabelParamSchema = z.object({
|
|
27
|
+
label: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(63)
|
|
31
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Invalid agent label'),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** @param {string | string[] | undefined} value */
|
|
35
|
+
function single(value) {
|
|
36
|
+
return typeof value === 'string' ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default async function agentPanelAuthzRoutes(fastify, _opts) {
|
|
40
|
+
fastify.get(
|
|
41
|
+
'/agent-panel/authz/:label',
|
|
42
|
+
{
|
|
43
|
+
preHandler: fastify.requireRole(['admin', 'agent']),
|
|
44
|
+
// One call per request on an agent panel — its SPA's assets and API
|
|
45
|
+
// polling included — keyed by the browser's address (nginx sets
|
|
46
|
+
// X-Forwarded-For to $remote_addr on the subrequest). Well above what
|
|
47
|
+
// the daemon behind it serves (100/min), so it never is the limit.
|
|
48
|
+
config: { rateLimit: { max: 1200, timeWindow: '1 minute' } },
|
|
49
|
+
},
|
|
50
|
+
async (request, reply) => {
|
|
51
|
+
const { label } = LabelParamSchema.parse(request.params);
|
|
52
|
+
reply.header('Cache-Control', 'no-store');
|
|
53
|
+
|
|
54
|
+
const decision = authorizeAgentPanelRequest(
|
|
55
|
+
{
|
|
56
|
+
label,
|
|
57
|
+
certRole: request.certRole,
|
|
58
|
+
certLabel: request.certLabel,
|
|
59
|
+
method: single(request.headers['x-original-method']),
|
|
60
|
+
uri: single(request.headers['x-original-uri']),
|
|
61
|
+
},
|
|
62
|
+
await readTunnels(),
|
|
63
|
+
);
|
|
64
|
+
if (!decision.ok) {
|
|
65
|
+
request.log.info(
|
|
66
|
+
{ label, certRole: request.certRole, reason: decision.reason },
|
|
67
|
+
'Agent panel request refused',
|
|
68
|
+
);
|
|
69
|
+
return reply.code(decision.status).send();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let key;
|
|
73
|
+
try {
|
|
74
|
+
key = await relaySigner.current();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
request.log.error(
|
|
77
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
78
|
+
'Relay signing key unusable',
|
|
79
|
+
);
|
|
80
|
+
key = null;
|
|
81
|
+
}
|
|
82
|
+
if (!key) return reply.code(503).send();
|
|
83
|
+
|
|
84
|
+
const assertion = signRelayAssertion(
|
|
85
|
+
{
|
|
86
|
+
aud: decision.aud,
|
|
87
|
+
port: decision.port,
|
|
88
|
+
caller: decision.caller,
|
|
89
|
+
method: decision.method,
|
|
90
|
+
target: { path: decision.path },
|
|
91
|
+
},
|
|
92
|
+
key,
|
|
93
|
+
);
|
|
94
|
+
return reply.header(RELAY_ASSERTION_HEADER, assertion).code(200).send();
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}
|