@lamalibre/create-lamaste 4.4.1 → 4.6.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 +234 -18
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- 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 +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- 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 +70 -33
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/02-guides/static-sites.md +4 -0
- 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 +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
- package/vendor/docs/04-api-reference/sites.md +4 -0
- package/vendor/docs/04-api-reference/tunnels.md +87 -47
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +24 -6
- package/vendor/docs/06-reference/config-files.md +100 -202
- 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 +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- 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/index.d.ts +4 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +4 -2
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.js +31 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.js +5 -2
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +49 -56
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +156 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/sites.js +15 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +186 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +34 -5
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +546 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +95 -7
- package/vendor/serverd/src/lib/registry-credential.js +9 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +36 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +754 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
- package/vendor/serverd/src/lib/web-surface.js +766 -0
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +2 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/sites.js +143 -65
- package/vendor/serverd/src/routes/management/tunnels.js +109 -22
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +6 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
2
|
import { access, lstat, readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
BACKEND_COOKIE_VARIABLE,
|
|
6
|
+
RELAY_PANEL_PORT,
|
|
7
|
+
USER_SURFACE_MAX_RESPONSE_SECONDS,
|
|
8
|
+
isUserSurfaceRoute,
|
|
9
|
+
userSurfaceApiPath,
|
|
10
|
+
WEB_TREE_CONTENT_TYPES,
|
|
11
|
+
} from '@lamalibre/lamaste';
|
|
4
12
|
|
|
5
13
|
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
6
14
|
const SITES_ENABLED = '/etc/nginx/sites-enabled';
|
|
@@ -58,11 +66,41 @@ async function writeVhostFile(name, content) {
|
|
|
58
66
|
await siteAction('write', name, content);
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
/** Site name of the panel's domain vhost (`panel.<domain>`). */
|
|
70
|
+
export const PANEL_DOMAIN_VHOST = 'lamalibre-lamaste-panel-domain';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cleared in every location that proxies a browser's request to the panel
|
|
74
|
+
* itself: a relay assertion reaches the panel only from a user-surface
|
|
75
|
+
* location of a tunnel vhost, which sets it from Gatekeeper's answer — never
|
|
76
|
+
* one a client sent.
|
|
77
|
+
*/
|
|
78
|
+
const PANEL_ASSERTION_CLEARED = `
|
|
79
|
+
# A relay assertion comes only from Gatekeeper, on a plugin's user surface
|
|
80
|
+
proxy_set_header X-Lamalibre-Relay-Assertion "";`;
|
|
81
|
+
|
|
61
82
|
/**
|
|
62
83
|
* Write the panel domain vhost with mTLS and Let's Encrypt certs.
|
|
63
84
|
*/
|
|
64
85
|
export async function writePanelVhost(domain) {
|
|
86
|
+
const { name, config } = renderPanelVhost(domain);
|
|
87
|
+
await writeVhostFile(name, config);
|
|
88
|
+
return path.join(SITES_AVAILABLE, name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Render the panel domain vhost. Pure; the startup reconcile renders it on
|
|
93
|
+
* the certificate directory the installed vhost names.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} domain
|
|
96
|
+
* @param {string} [certPath] - certificate directory (default: the panel's own lineage)
|
|
97
|
+
* @returns {{ name: string, fqdn: string, config: string }}
|
|
98
|
+
*/
|
|
99
|
+
export function renderPanelVhost(domain, certPath) {
|
|
65
100
|
const fqdn = `panel.${domain}`;
|
|
101
|
+
assertLiteralHostname(fqdn);
|
|
102
|
+
let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
103
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
66
104
|
const config = `# Rate limit zone for public enrollment endpoint (5 requests/minute per IP)
|
|
67
105
|
limit_req_zone $binary_remote_addr zone=enroll_domain:1m rate=5r/m;
|
|
68
106
|
|
|
@@ -76,8 +114,8 @@ server {
|
|
|
76
114
|
listen 443 ssl;
|
|
77
115
|
server_name ${fqdn};
|
|
78
116
|
|
|
79
|
-
ssl_certificate
|
|
80
|
-
ssl_certificate_key
|
|
117
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
118
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
81
119
|
|
|
82
120
|
# mTLS — same as IP-based access
|
|
83
121
|
include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
|
|
@@ -103,7 +141,7 @@ server {
|
|
|
103
141
|
proxy_set_header Remote-User "";
|
|
104
142
|
proxy_set_header Remote-Groups "";
|
|
105
143
|
proxy_set_header Remote-Name "";
|
|
106
|
-
proxy_set_header Remote-Email ""
|
|
144
|
+
proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
|
|
107
145
|
|
|
108
146
|
# Standard proxy headers
|
|
109
147
|
proxy_set_header Host $host;
|
|
@@ -126,7 +164,7 @@ server {
|
|
|
126
164
|
proxy_set_header Remote-User "";
|
|
127
165
|
proxy_set_header Remote-Groups "";
|
|
128
166
|
proxy_set_header Remote-Name "";
|
|
129
|
-
proxy_set_header Remote-Email ""
|
|
167
|
+
proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
|
|
130
168
|
|
|
131
169
|
proxy_set_header Host $host;
|
|
132
170
|
proxy_set_header X-Real-IP $remote_addr;
|
|
@@ -146,7 +184,7 @@ server {
|
|
|
146
184
|
proxy_set_header Remote-User "";
|
|
147
185
|
proxy_set_header Remote-Groups "";
|
|
148
186
|
proxy_set_header Remote-Name "";
|
|
149
|
-
proxy_set_header Remote-Email ""
|
|
187
|
+
proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
|
|
150
188
|
|
|
151
189
|
proxy_set_header Host $host;
|
|
152
190
|
proxy_set_header X-Real-IP $remote_addr;
|
|
@@ -167,7 +205,7 @@ server {
|
|
|
167
205
|
proxy_set_header Remote-User "";
|
|
168
206
|
proxy_set_header Remote-Groups "";
|
|
169
207
|
proxy_set_header Remote-Name "";
|
|
170
|
-
proxy_set_header Remote-Email ""
|
|
208
|
+
proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
|
|
171
209
|
|
|
172
210
|
proxy_set_header Host $host;
|
|
173
211
|
proxy_set_header X-Real-IP $remote_addr;
|
|
@@ -187,7 +225,7 @@ server {
|
|
|
187
225
|
proxy_set_header Remote-User "";
|
|
188
226
|
proxy_set_header Remote-Groups "";
|
|
189
227
|
proxy_set_header Remote-Name "";
|
|
190
|
-
proxy_set_header Remote-Email ""
|
|
228
|
+
proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
|
|
191
229
|
|
|
192
230
|
proxy_set_header Host $host;
|
|
193
231
|
proxy_set_header X-Real-IP $remote_addr;
|
|
@@ -207,7 +245,7 @@ server {
|
|
|
207
245
|
proxy_set_header Remote-User "";
|
|
208
246
|
proxy_set_header Remote-Groups "";
|
|
209
247
|
proxy_set_header Remote-Name "";
|
|
210
|
-
proxy_set_header Remote-Email ""
|
|
248
|
+
proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
|
|
211
249
|
|
|
212
250
|
proxy_set_header Host $host;
|
|
213
251
|
proxy_set_header X-Real-IP $remote_addr;
|
|
@@ -232,7 +270,7 @@ server {
|
|
|
232
270
|
proxy_set_header Remote-User "";
|
|
233
271
|
proxy_set_header Remote-Groups "";
|
|
234
272
|
proxy_set_header Remote-Name "";
|
|
235
|
-
proxy_set_header Remote-Email ""
|
|
273
|
+
proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
|
|
236
274
|
|
|
237
275
|
# Standard proxy headers
|
|
238
276
|
proxy_set_header Host $host;
|
|
@@ -247,8 +285,7 @@ server {
|
|
|
247
285
|
}
|
|
248
286
|
`;
|
|
249
287
|
|
|
250
|
-
|
|
251
|
-
return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-panel-domain');
|
|
288
|
+
return { name: PANEL_DOMAIN_VHOST, fqdn, config };
|
|
252
289
|
}
|
|
253
290
|
|
|
254
291
|
/**
|
|
@@ -351,7 +388,7 @@ server {
|
|
|
351
388
|
proxy_set_header Host $host;
|
|
352
389
|
proxy_set_header X-Real-IP $remote_addr;
|
|
353
390
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
354
|
-
proxy_set_header X-Forwarded-Proto $scheme
|
|
391
|
+
proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
|
|
355
392
|
}
|
|
356
393
|
|
|
357
394
|
# User access authorization — Authelia forward auth protects this endpoint
|
|
@@ -393,7 +430,7 @@ server {
|
|
|
393
430
|
proxy_set_header Host $host;
|
|
394
431
|
proxy_set_header X-Real-IP $remote_addr;
|
|
395
432
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
396
|
-
proxy_set_header X-Forwarded-Proto $scheme
|
|
433
|
+
proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
|
|
397
434
|
|
|
398
435
|
error_page 401 =302 $redirection_url;
|
|
399
436
|
}
|
|
@@ -446,6 +483,23 @@ export async function writeTunnelVhost(domain) {
|
|
|
446
483
|
return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-tunnel');
|
|
447
484
|
}
|
|
448
485
|
|
|
486
|
+
/**
|
|
487
|
+
* The `Cookie` a tunnel or agent backend receives: the browser's cookies
|
|
488
|
+
* without Authelia's session cookie. Authelia scopes that cookie to the base
|
|
489
|
+
* domain, so the browser sends it to every tunnel host; a backend that got
|
|
490
|
+
* it — any application on any agent, or whatever holds a tunnel port on the
|
|
491
|
+
* relay — could replay it as the visitor on every authenticated host and
|
|
492
|
+
* every plugin's user surface. `$lamaste_backend_cookie` is defined by the
|
|
493
|
+
* http-level map create-lamaste installs (core `renderBackendCookieMap`,
|
|
494
|
+
* `/etc/nginx/conf.d/lamalibre-lamaste-backend-cookie.conf`): every
|
|
495
|
+
* `authelia_session` pair removed, or no `Cookie` at all when one could not
|
|
496
|
+
* be. A vhost written before the map exists fails `nginx -t` and is rolled
|
|
497
|
+
* back — never served with the cookie.
|
|
498
|
+
*/
|
|
499
|
+
const BACKEND_COOKIE_HEADER = `
|
|
500
|
+
# Never Authelia's session cookie: the browser's other cookies only
|
|
501
|
+
proxy_set_header Cookie ${BACKEND_COOKIE_VARIABLE};`;
|
|
502
|
+
|
|
449
503
|
/**
|
|
450
504
|
* Request headers every tunnel location sends upstream.
|
|
451
505
|
*
|
|
@@ -457,7 +511,8 @@ export async function writeTunnelVhost(domain) {
|
|
|
457
511
|
* The client-certificate headers are cleared. The panel trusts
|
|
458
512
|
* `X-SSL-Client-*` from nginx as proof of an mTLS client certificate; a
|
|
459
513
|
* tunnel vhost must never pass client-supplied values through, whatever
|
|
460
|
-
* listens on its port.
|
|
514
|
+
* listens on its port. Authelia's session cookie never reaches the backend
|
|
515
|
+
* ({@link BACKEND_COOKIE_HEADER}).
|
|
461
516
|
*/
|
|
462
517
|
const TUNNEL_PROXY_HEADERS = `
|
|
463
518
|
proxy_set_header Host $host;
|
|
@@ -469,6 +524,7 @@ const TUNNEL_PROXY_HEADERS = `
|
|
|
469
524
|
proxy_set_header X-SSL-Client-Verify "";
|
|
470
525
|
proxy_set_header X-SSL-Client-DN "";
|
|
471
526
|
proxy_set_header X-SSL-Client-Serial "";
|
|
527
|
+
${BACKEND_COOKIE_HEADER}
|
|
472
528
|
|
|
473
529
|
# WebSocket support
|
|
474
530
|
proxy_set_header Upgrade $http_upgrade;
|
|
@@ -485,10 +541,167 @@ const TUNNEL_PROXY_HEADERS = `
|
|
|
485
541
|
*/
|
|
486
542
|
function bodySizeDirective(maxBodySizeMb) {
|
|
487
543
|
if (maxBodySizeMb === undefined) return '';
|
|
544
|
+
assertBodySizeMb(maxBodySizeMb);
|
|
545
|
+
return `\n client_max_body_size ${maxBodySizeMb}m;\n`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** A body limit in MiB, as the tunnel workflow allows it — interpolated into nginx configuration. */
|
|
549
|
+
function assertBodySizeMb(maxBodySizeMb) {
|
|
488
550
|
if (!Number.isInteger(maxBodySizeMb) || maxBodySizeMb < 1 || maxBodySizeMb > 10240) {
|
|
489
551
|
throw new Error(`Invalid maxBodySizeMb for nginx vhost: ${maxBodySizeMb}`);
|
|
490
552
|
}
|
|
491
|
-
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Request headers a user-surface location sends the panel. The panel's
|
|
557
|
+
* user-surface context trusts nothing but the relay assertion — but the
|
|
558
|
+
* panel serves other contexts on the same listener, so everything a browser
|
|
559
|
+
* could send that any of them trusts is cleared: no user request can pass
|
|
560
|
+
* for a client-certificate holder (`X-SSL-Client-*`), an Authelia user
|
|
561
|
+
* (`Remote-*`), a desktop session (`Authorization`) or an administrator's
|
|
562
|
+
* 2FA session (`Cookie`), nor name another host to the panel
|
|
563
|
+
* (`X-Forwarded-Host`). `X-Forwarded-For` is the client's address alone:
|
|
564
|
+
* the panel trusts one loopback hop (`trust-proxy.js`), so that is its
|
|
565
|
+
* `request.ip` — what the user surface's rate limit keys on. `Connection`
|
|
566
|
+
* cleared: the panel keeps the upstream connection alive (HTTP/1.1), and
|
|
567
|
+
* the user surface carries no WebSocket.
|
|
568
|
+
*/
|
|
569
|
+
const USER_SURFACE_PROXY_HEADERS = `
|
|
570
|
+
# Nothing else that any part of the panel trusts
|
|
571
|
+
proxy_set_header Cookie "";
|
|
572
|
+
proxy_set_header Authorization "";
|
|
573
|
+
proxy_set_header Remote-User "";
|
|
574
|
+
proxy_set_header Remote-Groups "";
|
|
575
|
+
proxy_set_header Remote-Name "";
|
|
576
|
+
proxy_set_header Remote-Email "";
|
|
577
|
+
proxy_set_header X-SSL-Client-Verify "";
|
|
578
|
+
proxy_set_header X-SSL-Client-DN "";
|
|
579
|
+
proxy_set_header X-SSL-Client-Serial "";
|
|
580
|
+
proxy_set_header X-Forwarded-Host "";
|
|
581
|
+
|
|
582
|
+
proxy_set_header Host $host;
|
|
583
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
584
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
585
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
586
|
+
proxy_set_header Connection "";`;
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The body of a tunnel vhost's Gatekeeper subrequest location,
|
|
590
|
+
* `/authz/<check>/<fqdn>`: the tunnel is named in the path, in
|
|
591
|
+
* `X-Original-URL` and in `Host` — never by the client's `Host` header (see
|
|
592
|
+
* {@link originalUrl}) — and the session cookie is passed on.
|
|
593
|
+
*
|
|
594
|
+
* @param {'tunnel'|'user-surface'|'user-surface-shell'} check
|
|
595
|
+
* @param {string} fqdn
|
|
596
|
+
* @returns {string}
|
|
597
|
+
*/
|
|
598
|
+
function gatekeeperSubrequest(check, fqdn) {
|
|
599
|
+
return `internal;
|
|
600
|
+
# A subrequest is never authorized itself (a host may authorize at server level)
|
|
601
|
+
auth_request off;
|
|
602
|
+
|
|
603
|
+
proxy_pass http://127.0.0.1:9294/authz/${check}/${fqdn};
|
|
604
|
+
proxy_pass_request_body off;
|
|
605
|
+
|
|
606
|
+
proxy_set_header Content-Length "";
|
|
607
|
+
proxy_set_header Connection "";
|
|
608
|
+
proxy_set_header X-Original-Method $request_method;
|
|
609
|
+
proxy_set_header X-Original-URL ${originalUrl(fqdn)};
|
|
610
|
+
proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
|
|
611
|
+
proxy_set_header Host ${fqdn};
|
|
612
|
+
proxy_set_header Cookie $http_cookie;
|
|
613
|
+
|
|
614
|
+
proxy_http_version 1.1;`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The user-surface locations of a tunnel vhost — `/api/<route>/app` and
|
|
619
|
+
* everything under it for each plugin it serves (`opts.userSurface`), and
|
|
620
|
+
* the two internal locations they share:
|
|
621
|
+
*
|
|
622
|
+
* - `/internal/lamaste/user-surface-authz` asks Gatekeeper
|
|
623
|
+
* (`/authz/user-surface/<fqdn>`, the tunnel named in the path and in
|
|
624
|
+
* `X-Original-URL` and `Host`, as for the tunnel's own subrequest).
|
|
625
|
+
* Gatekeeper authenticates the Authelia session — and, on a restricted
|
|
626
|
+
* tunnel, checks its grant — then vouches for the user to the panel with
|
|
627
|
+
* a relay assertion bound to the method and the raw request target. Never
|
|
628
|
+
* cached: an assertion is single use.
|
|
629
|
+
* - `/internal/lamaste/user-surface-401` answers a request without a
|
|
630
|
+
* session with Gatekeeper's JSON 401 — an application's request is never
|
|
631
|
+
* redirected to the login page.
|
|
632
|
+
*
|
|
633
|
+
* Each route's locations pass the assertion, and nothing else of the
|
|
634
|
+
* browser's credentials ({@link USER_SURFACE_PROXY_HEADERS}), to the panel
|
|
635
|
+
* with `proxy_pass` naming no URI: the panel receives the request target
|
|
636
|
+
* exactly as the browser sent it — the target the assertion is bound to.
|
|
637
|
+
* The body limit is the plugin's (its internal tunnel's); the timeouts are
|
|
638
|
+
* the longest a user-surface response lives (`USER_SURFACE_MAX_RESPONSE_SECONDS`,
|
|
639
|
+
* one hour — the panel ends one still open then, and ends it at once when
|
|
640
|
+
* its user loses access), long enough for server-sent events, which the
|
|
641
|
+
* panel streams unbuffered.
|
|
642
|
+
*
|
|
643
|
+
* @param {string} fqdn
|
|
644
|
+
* @param {ReadonlyArray<{ route: string, maxBodySizeMb: number }>} userSurface
|
|
645
|
+
* @returns {string}
|
|
646
|
+
*/
|
|
647
|
+
function userSurfaceLocations(fqdn, userSurface) {
|
|
648
|
+
if (userSurface.length === 0) return '';
|
|
649
|
+
const routes = userSurface.map(({ route, maxBodySizeMb }) => {
|
|
650
|
+
if (!isUserSurfaceRoute(route)) {
|
|
651
|
+
throw new Error(`Invalid user-surface route for nginx vhost: ${route}`);
|
|
652
|
+
}
|
|
653
|
+
assertBodySizeMb(maxBodySizeMb);
|
|
654
|
+
const block = `{
|
|
655
|
+
auth_request /internal/lamaste/user-surface-authz;
|
|
656
|
+
auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
|
|
657
|
+
|
|
658
|
+
# The user, vouched for by Gatekeeper to the panel for this one request
|
|
659
|
+
proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
|
|
660
|
+
${USER_SURFACE_PROXY_HEADERS}
|
|
661
|
+
|
|
662
|
+
# No URI: the request target reaches the panel as the browser sent it
|
|
663
|
+
proxy_pass http://127.0.0.1:${RELAY_PANEL_PORT};
|
|
664
|
+
proxy_http_version 1.1;
|
|
665
|
+
|
|
666
|
+
client_max_body_size ${maxBodySizeMb}m;
|
|
667
|
+
# No response lives longer (the panel ends one then)
|
|
668
|
+
proxy_read_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
|
|
669
|
+
proxy_send_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
|
|
670
|
+
|
|
671
|
+
# No session: a JSON 401, never a redirect
|
|
672
|
+
error_page 401 = /internal/lamaste/user-surface-401;
|
|
673
|
+
}`;
|
|
674
|
+
const apiPath = userSurfaceApiPath(route);
|
|
675
|
+
return `
|
|
676
|
+
# The user surface of plugin "${route}"
|
|
677
|
+
location = ${apiPath} ${block}
|
|
678
|
+
|
|
679
|
+
location ^~ ${apiPath}/ ${block}
|
|
680
|
+
`;
|
|
681
|
+
});
|
|
682
|
+
return `
|
|
683
|
+
# The user surface of agent-hosted plugins on this host: Gatekeeper
|
|
684
|
+
# vouches for the session's user to the panel, for one request each.
|
|
685
|
+
location = /internal/lamaste/user-surface-authz {
|
|
686
|
+
${gatekeeperSubrequest('user-surface', fqdn)}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
location = /internal/lamaste/user-surface-401 {
|
|
690
|
+
internal;
|
|
691
|
+
auth_request off;
|
|
692
|
+
|
|
693
|
+
proxy_pass http://127.0.0.1:9294/authz/user-surface-unauthenticated;
|
|
694
|
+
proxy_pass_request_body off;
|
|
695
|
+
|
|
696
|
+
proxy_set_header Content-Length "";
|
|
697
|
+
proxy_set_header Cookie "";
|
|
698
|
+
proxy_set_header Authorization "";
|
|
699
|
+
# Gatekeeper's rate limit counts the client nginx saw, never one a client named
|
|
700
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
701
|
+
|
|
702
|
+
proxy_http_version 1.1;
|
|
703
|
+
}
|
|
704
|
+
${routes.join('')}`;
|
|
492
705
|
}
|
|
493
706
|
|
|
494
707
|
/**
|
|
@@ -500,19 +713,30 @@ export async function writePublicVhost(
|
|
|
500
713
|
domain,
|
|
501
714
|
port,
|
|
502
715
|
certPath,
|
|
503
|
-
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
716
|
+
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
504
717
|
) {
|
|
505
718
|
const { name, fqdn, config } = renderPublicVhost(subdomain, domain, port, certPath, {
|
|
506
719
|
pathPrefix,
|
|
507
720
|
maxBodySizeMb,
|
|
721
|
+
userSurface,
|
|
508
722
|
});
|
|
509
723
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
510
724
|
}
|
|
511
725
|
|
|
512
|
-
function renderPublicVhost(
|
|
726
|
+
function renderPublicVhost(
|
|
727
|
+
subdomain,
|
|
728
|
+
domain,
|
|
729
|
+
port,
|
|
730
|
+
certPath,
|
|
731
|
+
{ pathPrefix, maxBodySizeMb, userSurface } = {},
|
|
732
|
+
) {
|
|
513
733
|
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
514
734
|
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
515
735
|
}
|
|
736
|
+
// A public host has no signed-in user for Gatekeeper to vouch for.
|
|
737
|
+
if (userSurface && userSurface.length > 0) {
|
|
738
|
+
throw new Error("A public tunnel vhost cannot serve a plugin's user surface");
|
|
739
|
+
}
|
|
516
740
|
|
|
517
741
|
const fqdn = `${subdomain}.${domain}`;
|
|
518
742
|
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
@@ -536,14 +760,11 @@ ${bodySizeDirective(maxBodySizeMb)}
|
|
|
536
760
|
proxy_set_header Remote-User "";
|
|
537
761
|
proxy_set_header Remote-Groups "";
|
|
538
762
|
proxy_set_header Remote-Name "";
|
|
539
|
-
proxy_set_header Remote-Email ""
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
#
|
|
543
|
-
|
|
544
|
-
proxy_set_header X-Lamalibre-Relay-Assertion "";`
|
|
545
|
-
: ''
|
|
546
|
-
}
|
|
763
|
+
proxy_set_header Remote-Email "";
|
|
764
|
+
|
|
765
|
+
# Nor a relay assertion: only Gatekeeper's, set on an authenticated
|
|
766
|
+
# vhost, may reach a backend (lamaste-agentd serves plugin routes)
|
|
767
|
+
proxy_set_header X-Lamalibre-Relay-Assertion "";
|
|
547
768
|
${TUNNEL_PROXY_HEADERS}
|
|
548
769
|
|
|
549
770
|
${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
|
|
@@ -568,11 +789,12 @@ export async function writeAuthenticatedVhost(
|
|
|
568
789
|
domain,
|
|
569
790
|
port,
|
|
570
791
|
certPath,
|
|
571
|
-
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
792
|
+
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
572
793
|
) {
|
|
573
794
|
const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
574
795
|
pathPrefix,
|
|
575
796
|
maxBodySizeMb,
|
|
797
|
+
userSurface,
|
|
576
798
|
restricted: false,
|
|
577
799
|
});
|
|
578
800
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
@@ -588,11 +810,12 @@ export async function writeRestrictedVhost(
|
|
|
588
810
|
domain,
|
|
589
811
|
port,
|
|
590
812
|
certPath,
|
|
591
|
-
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
813
|
+
{ pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
|
|
592
814
|
) {
|
|
593
815
|
const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
594
816
|
pathPrefix,
|
|
595
817
|
maxBodySizeMb,
|
|
818
|
+
userSurface,
|
|
596
819
|
restricted: true,
|
|
597
820
|
});
|
|
598
821
|
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
@@ -603,7 +826,7 @@ function renderGatekeeperTunnelVhost(
|
|
|
603
826
|
domain,
|
|
604
827
|
port,
|
|
605
828
|
certPath,
|
|
606
|
-
{ pathPrefix, maxBodySizeMb, restricted },
|
|
829
|
+
{ pathPrefix, maxBodySizeMb, userSurface, restricted },
|
|
607
830
|
) {
|
|
608
831
|
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
609
832
|
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
@@ -617,6 +840,7 @@ function renderGatekeeperTunnelVhost(
|
|
|
617
840
|
const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
|
|
618
841
|
pathPrefix,
|
|
619
842
|
maxBodySizeMb,
|
|
843
|
+
userSurface,
|
|
620
844
|
restricted,
|
|
621
845
|
});
|
|
622
846
|
|
|
@@ -629,16 +853,27 @@ function renderGatekeeperTunnelVhost(
|
|
|
629
853
|
* the generators produce today (see vhost-reconcile.js).
|
|
630
854
|
*
|
|
631
855
|
* @param {'public'|'authenticated'|'restricted'} accessMode
|
|
856
|
+
* @param {string} subdomain
|
|
857
|
+
* @param {string} domain
|
|
858
|
+
* @param {number} port
|
|
859
|
+
* @param {string} [certPath]
|
|
860
|
+
* @param {{ pathPrefix?: string, maxBodySizeMb?: number, userSurface?: ReadonlyArray<{ route: string, maxBodySizeMb: number }> }} [opts]
|
|
861
|
+
* `userSurface`: the plugins' user surfaces the vhost serves (not on a public vhost)
|
|
632
862
|
* @returns {{ name: string, fqdn: string, config: string }}
|
|
633
863
|
*/
|
|
634
864
|
export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath, opts = {}) {
|
|
635
|
-
const { pathPrefix, maxBodySizeMb } = opts;
|
|
865
|
+
const { pathPrefix, maxBodySizeMb, userSurface } = opts;
|
|
636
866
|
if (accessMode === 'public') {
|
|
637
|
-
return renderPublicVhost(subdomain, domain, port, certPath, {
|
|
867
|
+
return renderPublicVhost(subdomain, domain, port, certPath, {
|
|
868
|
+
pathPrefix,
|
|
869
|
+
maxBodySizeMb,
|
|
870
|
+
userSurface,
|
|
871
|
+
});
|
|
638
872
|
}
|
|
639
873
|
return renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
|
|
640
874
|
pathPrefix,
|
|
641
875
|
maxBodySizeMb,
|
|
876
|
+
userSurface,
|
|
642
877
|
restricted: accessMode !== 'authenticated',
|
|
643
878
|
});
|
|
644
879
|
}
|
|
@@ -658,13 +893,18 @@ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath,
|
|
|
658
893
|
* the one Gatekeeper authorized. Gatekeeper now refuses `/authz/check`: a
|
|
659
894
|
* vhost the panel has not rewritten closes its tunnel instead of opening it.
|
|
660
895
|
* The login redirect names this vhost for the same reason.
|
|
896
|
+
*
|
|
897
|
+
* The backend gets Gatekeeper's relay assertion on a plugin tunnel, and no
|
|
898
|
+
* relay assertion at all on any other — a client's is cleared. An app
|
|
899
|
+
* tunnel bound as plugins' user surface also carries their locations
|
|
900
|
+
* ({@link userSurfaceLocations}).
|
|
661
901
|
*/
|
|
662
902
|
function buildGatekeeperVhost(
|
|
663
903
|
fqdn,
|
|
664
904
|
certDirClean,
|
|
665
905
|
port,
|
|
666
906
|
domain,
|
|
667
|
-
{ pathPrefix, maxBodySizeMb, restricted } = {},
|
|
907
|
+
{ pathPrefix, maxBodySizeMb, userSurface = [], restricted } = {},
|
|
668
908
|
) {
|
|
669
909
|
const url = originalUrl(fqdn);
|
|
670
910
|
return `server {
|
|
@@ -681,24 +921,11 @@ ${bodySizeDirective(maxBodySizeMb)}
|
|
|
681
921
|
# Gatekeeper authorization subrequest (handles Authelia validation + grant check).
|
|
682
922
|
# The tunnel is named in the path — never by the client's Host header.
|
|
683
923
|
location /internal/lamaste/authz {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
proxy_pass http://127.0.0.1:9294/authz/tunnel/${fqdn};
|
|
687
|
-
proxy_pass_request_body off;
|
|
688
|
-
|
|
689
|
-
proxy_set_header Content-Length "";
|
|
690
|
-
proxy_set_header Connection "";
|
|
691
|
-
proxy_set_header X-Original-Method $request_method;
|
|
692
|
-
proxy_set_header X-Original-URL ${url};
|
|
693
|
-
proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
|
|
694
|
-
proxy_set_header Host ${fqdn};
|
|
695
|
-
proxy_set_header Cookie $http_cookie;
|
|
696
|
-
|
|
697
|
-
proxy_http_version 1.1;
|
|
924
|
+
${gatekeeperSubrequest('tunnel', fqdn)}
|
|
698
925
|
proxy_buffers 4 32k;
|
|
699
926
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
700
927
|
}
|
|
701
|
-
|
|
928
|
+
${userSurfaceLocations(fqdn, userSurface)}
|
|
702
929
|
location / {
|
|
703
930
|
auth_request /internal/lamaste/authz;
|
|
704
931
|
auth_request_set $user $upstream_http_remote_user;
|
|
@@ -721,7 +948,11 @@ ${bodySizeDirective(maxBodySizeMb)}
|
|
|
721
948
|
# The user, signed by Gatekeeper for this one request — lamaste-agentd,
|
|
722
949
|
# which serves plugin routes, trusts no Remote-* header without it
|
|
723
950
|
proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;`
|
|
724
|
-
:
|
|
951
|
+
: `
|
|
952
|
+
|
|
953
|
+
# No relay assertion reaches an app: Gatekeeper signs one only for a
|
|
954
|
+
# plugin tunnel or the panel, never one a client sent
|
|
955
|
+
proxy_set_header X-Lamalibre-Relay-Assertion "";`
|
|
725
956
|
}
|
|
726
957
|
${TUNNEL_PROXY_HEADERS}
|
|
727
958
|
|
|
@@ -936,6 +1167,269 @@ ${aliasBlock}`;
|
|
|
936
1167
|
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
|
|
937
1168
|
}
|
|
938
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* The Content-Security-Policy of a plugin's web surface: its shell loads
|
|
1172
|
+
* scripts, styles, fonts and images from its own host only and talks to its
|
|
1173
|
+
* own host only (its user surface's API is there). Inline styles are
|
|
1174
|
+
* allowed — frameworks set `style` attributes and insert component styles
|
|
1175
|
+
* at run time — inline scripts never; no framing, no plugins, no foreign
|
|
1176
|
+
* form targets.
|
|
1177
|
+
*/
|
|
1178
|
+
const WEB_SURFACE_CSP =
|
|
1179
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Response headers of the shell of a plugin's web surface, set in its
|
|
1183
|
+
* locations — not at server level, where the API's answers (headed by the
|
|
1184
|
+
* panel) would get a second copy. `no-cache`: a browser revalidates each file —
|
|
1185
|
+
* a 304 while the tree is the same, the new file once an update switched
|
|
1186
|
+
* it — so no page mixes two versions of the shell from its cache.
|
|
1187
|
+
*/
|
|
1188
|
+
const WEB_SURFACE_HEADERS = `
|
|
1189
|
+
add_header Content-Security-Policy "${WEB_SURFACE_CSP}" always;
|
|
1190
|
+
add_header X-Content-Type-Options nosniff always;
|
|
1191
|
+
add_header X-Frame-Options DENY always;
|
|
1192
|
+
add_header Referrer-Policy same-origin always;
|
|
1193
|
+
add_header Cross-Origin-Opener-Policy same-origin always;
|
|
1194
|
+
add_header Cross-Origin-Resource-Policy same-origin always;
|
|
1195
|
+
add_header Cache-Control no-cache always;`;
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* The content types of a web tree's files, by extension
|
|
1199
|
+
* (`WEB_TREE_CONTENT_TYPES` — the only extensions a tree may hold).
|
|
1200
|
+
*/
|
|
1201
|
+
const WEB_SURFACE_TYPES = (() => {
|
|
1202
|
+
const byType = new Map();
|
|
1203
|
+
for (const [ext, type] of Object.entries(WEB_TREE_CONTENT_TYPES)) {
|
|
1204
|
+
byType.set(type, [...(byType.get(type) ?? []), ext.slice(1)]);
|
|
1205
|
+
}
|
|
1206
|
+
const entries = [...byType].map(([type, exts]) => ` ${type} ${exts.join(' ')};`);
|
|
1207
|
+
return `
|
|
1208
|
+
types {
|
|
1209
|
+
${entries.join('\n')}
|
|
1210
|
+
}`;
|
|
1211
|
+
})();
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* The files a page still running the previous shell may ask for after an
|
|
1215
|
+
* update — its scripts, styles, fonts and images — and nothing else: a
|
|
1216
|
+
* document, an SVG or a manifest is never served from the previous tree.
|
|
1217
|
+
*/
|
|
1218
|
+
const WEB_SURFACE_SUBRESOURCE_RE = '[.](?:js|mjs|css|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|ico)$';
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Service workers: never registered from a plugin's site. A worker outlives
|
|
1222
|
+
* the tree that installed it — a revert, the site's deletion, the
|
|
1223
|
+
* hostname's next owner — and answers in its place; asked for its script
|
|
1224
|
+
* (`Service-Worker: script`), the site answers 404, which also
|
|
1225
|
+
* unregisters a worker already installed at its next update check.
|
|
1226
|
+
*/
|
|
1227
|
+
const NO_SERVICE_WORKER = `
|
|
1228
|
+
if ($http_service_worker) {
|
|
1229
|
+
return 404;
|
|
1230
|
+
}`;
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Render the vhost of a plugin's web surface — a site the plugin owns
|
|
1234
|
+
* (`site.owner`). Pure; the startup reconcile renders it again to compare.
|
|
1235
|
+
*
|
|
1236
|
+
* While the plugin's surface is served (`opts.served`: its installed
|
|
1237
|
+
* version approved, declaring identity and a web surface, hosted), the
|
|
1238
|
+
* host serves two things under one Authelia session:
|
|
1239
|
+
*
|
|
1240
|
+
* - **the shell**: every path but the API, from the plugin's current tree
|
|
1241
|
+
* (`<treesDir>/current`), GET and HEAD only, each file under its own
|
|
1242
|
+
* content type (`WEB_SURFACE_TYPES`), never a service worker's script. Every request asks
|
|
1243
|
+
* Gatekeeper first (`/authz/user-surface-shell/<fqdn>`: a session in the
|
|
1244
|
+
* plugin's audience, and the site's grant when it is restricted) — a page
|
|
1245
|
+
* without a session goes to the login, a user without access gets the
|
|
1246
|
+
* access-request page. A script, style, font or image the current tree
|
|
1247
|
+
* lacks is looked for in the previous tree (a page still running the old
|
|
1248
|
+
* shell loads its lazy files after an update), else 404; any other
|
|
1249
|
+
* unknown path gets the current shell's `index.html`.
|
|
1250
|
+
* - **the API**: `/api/<route>/app/*`, exactly as on a bound tunnel
|
|
1251
|
+
* ({@link userSurfaceLocations}).
|
|
1252
|
+
*
|
|
1253
|
+
* While it is not served the host answers every request with 503 — and,
|
|
1254
|
+
* once its plugin is no longer installed (`opts.clearSiteData`), tells
|
|
1255
|
+
* browsers to clear the site's storage.
|
|
1256
|
+
*
|
|
1257
|
+
* @param {{ id: string, fqdn: string }} site
|
|
1258
|
+
* @param {string} certDir
|
|
1259
|
+
* @param {string} domain
|
|
1260
|
+
* @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string, clearSiteData?: boolean }} opts
|
|
1261
|
+
* @returns {{ name: string, fqdn: string, config: string }}
|
|
1262
|
+
*/
|
|
1263
|
+
export function renderWebSurfaceVhost(site, certDir, domain, opts) {
|
|
1264
|
+
const { route, served, maxBodySizeMb, treesDir, clearSiteData = false } = opts;
|
|
1265
|
+
assertLiteralHostname(site.fqdn);
|
|
1266
|
+
if (!isUserSurfaceRoute(route)) throw new Error(`Invalid web surface route: ${route}`);
|
|
1267
|
+
if (!path.isAbsolute(treesDir) || !/^[A-Za-z0-9/._-]+$/.test(treesDir)) {
|
|
1268
|
+
throw new Error(`Invalid web tree directory for nginx vhost: ${treesDir}`);
|
|
1269
|
+
}
|
|
1270
|
+
let certDirClean = certDir;
|
|
1271
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
1272
|
+
|
|
1273
|
+
const head = `server {
|
|
1274
|
+
listen 443 ssl;
|
|
1275
|
+
server_name ${site.fqdn};
|
|
1276
|
+
|
|
1277
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
1278
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
1279
|
+
|
|
1280
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
1281
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
1282
|
+
ssl_prefer_server_ciphers on;
|
|
1283
|
+
`;
|
|
1284
|
+
|
|
1285
|
+
if (!served) {
|
|
1286
|
+
// A plugin no longer installed: whatever comes next on this hostname is
|
|
1287
|
+
// not its shell, so browsers drop what it left — its storage, caches
|
|
1288
|
+
// and service workers. Never while the plugin is only unhosted or
|
|
1289
|
+
// awaiting approval: its users' data stays.
|
|
1290
|
+
const clear = clearSiteData
|
|
1291
|
+
? `
|
|
1292
|
+
add_header Clear-Site-Data '"storage"' always;`
|
|
1293
|
+
: '';
|
|
1294
|
+
return {
|
|
1295
|
+
name: `lamalibre-lamaste-site-${site.id}`,
|
|
1296
|
+
fqdn: site.fqdn,
|
|
1297
|
+
config: `${head}
|
|
1298
|
+
# The web surface of plugin "${route}" is not served now (not installed,
|
|
1299
|
+
# not approved, not hosted, or its version declares no web surface)
|
|
1300
|
+
location / {${NO_SERVICE_WORKER}
|
|
1301
|
+
add_header Cache-Control no-store always;${clear}
|
|
1302
|
+
return 503;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
`,
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const url = originalUrl(site.fqdn);
|
|
1310
|
+
const config = `${head}
|
|
1311
|
+
root ${treesDir}/current;
|
|
1312
|
+
index index.html;
|
|
1313
|
+
|
|
1314
|
+
# Every path of the shell: Gatekeeper first (the API's locations ask on their own)
|
|
1315
|
+
auth_request /internal/lamaste/shell-authz;
|
|
1316
|
+
error_page 401 =302 https://auth.${domain}/?rd=${url};
|
|
1317
|
+
error_page 403 = /internal/lamaste/shell-authz;
|
|
1318
|
+
|
|
1319
|
+
location = /internal/lamaste/shell-authz {
|
|
1320
|
+
${gatekeeperSubrequest('user-surface-shell', site.fqdn)}
|
|
1321
|
+
}
|
|
1322
|
+
${userSurfaceLocations(site.fqdn, [{ route, maxBodySizeMb }])}
|
|
1323
|
+
# A script, style, font or image the current tree lacks: the previous
|
|
1324
|
+
# tree's (a page still running the old shell), else 404
|
|
1325
|
+
location ~* ${WEB_SURFACE_SUBRESOURCE_RE} {
|
|
1326
|
+
if ($request_method !~ ^(GET|HEAD)$) {
|
|
1327
|
+
return 405;
|
|
1328
|
+
}${NO_SERVICE_WORKER}
|
|
1329
|
+
${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
|
|
1330
|
+
try_files $uri @previous;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
# Everything else: the current tree's file, else the shell
|
|
1334
|
+
location / {
|
|
1335
|
+
if ($request_method !~ ^(GET|HEAD)$) {
|
|
1336
|
+
return 405;
|
|
1337
|
+
}${NO_SERVICE_WORKER}
|
|
1338
|
+
${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
|
|
1339
|
+
try_files $uri /index.html;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
location @previous {
|
|
1343
|
+
${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
|
|
1344
|
+
root ${treesDir}/previous;
|
|
1345
|
+
try_files $uri =404;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
`;
|
|
1349
|
+
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Write the vhost of a plugin's web surface (see {@link renderWebSurfaceVhost}).
|
|
1354
|
+
*
|
|
1355
|
+
* @param {{ id: string, fqdn: string }} site
|
|
1356
|
+
* @param {string} certDir
|
|
1357
|
+
* @param {string} domain
|
|
1358
|
+
* @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
|
|
1359
|
+
*/
|
|
1360
|
+
export async function writeWebSurfaceVhost(site, certDir, domain, opts) {
|
|
1361
|
+
const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
|
|
1362
|
+
return safeWriteVhost(name, config, fqdn, { enabled: true });
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Hand a hostname from one vhost to another in one nginx reload: vhost
|
|
1367
|
+
* `fromName` is unlinked, `name` written with `config` and linked, the
|
|
1368
|
+
* configuration tested and nginx reloaded once; `fromName` stays installed,
|
|
1369
|
+
* unlinked. Until the reload nginx serves the old vhost, after it the new
|
|
1370
|
+
* one; there is no moment in between. On any failure before the reload
|
|
1371
|
+
* both are put back as they were.
|
|
1372
|
+
*
|
|
1373
|
+
* @param {string} fromName the vhost giving the hostname up (linked)
|
|
1374
|
+
* @param {string} name a vhost that does not exist yet
|
|
1375
|
+
* @param {string} config
|
|
1376
|
+
* @param {string} fqdn
|
|
1377
|
+
*/
|
|
1378
|
+
async function handOverVhost(fromName, name, config, fqdn) {
|
|
1379
|
+
if (await siteExists(name)) throw new Error(`Vhost ${name} exists already`);
|
|
1380
|
+
const fromEnabled = await isSiteEnabled(fromName);
|
|
1381
|
+
const restore = async () => {
|
|
1382
|
+
await siteAction('remove', name).catch(() => {});
|
|
1383
|
+
if (fromEnabled) await enableSite(fromName).catch(() => {});
|
|
1384
|
+
};
|
|
1385
|
+
try {
|
|
1386
|
+
await disableSite(fromName);
|
|
1387
|
+
await writeVhostFile(name, config);
|
|
1388
|
+
await enableSite(name);
|
|
1389
|
+
const result = await testConfig();
|
|
1390
|
+
if (!result.valid) {
|
|
1391
|
+
throw new Error(`Nginx config test failed handing ${fqdn} to ${name}: ${result.error}`);
|
|
1392
|
+
}
|
|
1393
|
+
await reload();
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
await restore();
|
|
1396
|
+
throw err;
|
|
1397
|
+
}
|
|
1398
|
+
// `fromName` stays installed, only unlinked: the tunnel it served can be
|
|
1399
|
+
// enabled again by hand once the new vhost is gone.
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Unlink tunnel vhost `subdomain` when it is still enabled although a
|
|
1404
|
+
* plugin's site holds its hostname — a move the panel did not finish (it
|
|
1405
|
+
* wrote the state, then stopped before the handover). Does not reload:
|
|
1406
|
+
* the caller's next vhost install does, or it reloads itself. Returns
|
|
1407
|
+
* whether it unlinked.
|
|
1408
|
+
*
|
|
1409
|
+
* @param {string} subdomain the moved tunnel's
|
|
1410
|
+
*/
|
|
1411
|
+
export async function unlinkMovedAppVhost(subdomain) {
|
|
1412
|
+
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
1413
|
+
if (!(await isSiteEnabled(name))) return false;
|
|
1414
|
+
await disableSite(name);
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Serve a plugin's web surface on the hostname of tunnel vhost `subdomain`
|
|
1420
|
+
* in one reload (see {@link handOverVhost}).
|
|
1421
|
+
*
|
|
1422
|
+
* @param {string} subdomain the tunnel's
|
|
1423
|
+
* @param {{ id: string, fqdn: string }} site
|
|
1424
|
+
* @param {string} certDir
|
|
1425
|
+
* @param {string} domain
|
|
1426
|
+
* @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
|
|
1427
|
+
*/
|
|
1428
|
+
export async function moveAppVhostToWebSurface(subdomain, site, certDir, domain, opts) {
|
|
1429
|
+
const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
|
|
1430
|
+
await handOverVhost(`lamalibre-lamaste-app-${subdomain}`, name, config, fqdn);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
939
1433
|
/**
|
|
940
1434
|
* Server block answering a site's aliases with a permanent redirect to the
|
|
941
1435
|
* primary hostname, on the same certificate (its lineage covers every alias).
|
|
@@ -1148,7 +1642,9 @@ export async function writeAgentPanelVhost(
|
|
|
1148
1642
|
* reached without going through nginx, so a header proves nothing to it.
|
|
1149
1643
|
*
|
|
1150
1644
|
* `X-SSL-Client-*` still go upstream for agents older than 4.3, which read
|
|
1151
|
-
* them; lamaste-agentd from 4.3 removes them unread.
|
|
1645
|
+
* them; lamaste-agentd from 4.3 removes them unread. Authelia's session
|
|
1646
|
+
* cookie never does ({@link BACKEND_COOKIE_HEADER}): the agent panel's host
|
|
1647
|
+
* is under the base domain like every other.
|
|
1152
1648
|
*
|
|
1153
1649
|
* @param {string} subdomain - `agent-<label>`
|
|
1154
1650
|
* @param {string} domain
|
|
@@ -1208,6 +1704,7 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
|
|
|
1208
1704
|
proxy_set_header Remote-Groups "";
|
|
1209
1705
|
proxy_set_header Remote-Name "";
|
|
1210
1706
|
proxy_set_header Remote-Email "";
|
|
1707
|
+
proxy_set_header X-Lamalibre-Relay-Assertion "";
|
|
1211
1708
|
}
|
|
1212
1709
|
|
|
1213
1710
|
# Reject requests without valid client certificate
|
|
@@ -1236,6 +1733,7 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
|
|
|
1236
1733
|
proxy_set_header Remote-Groups "";
|
|
1237
1734
|
proxy_set_header Remote-Name "";
|
|
1238
1735
|
proxy_set_header Remote-Email "";
|
|
1736
|
+
${BACKEND_COOKIE_HEADER}
|
|
1239
1737
|
|
|
1240
1738
|
# Standard proxy headers
|
|
1241
1739
|
proxy_set_header Host $host;
|