@lamalibre/create-lamaste 4.5.0 → 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 +34 -2
- 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/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +2 -2
- 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/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 +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +65 -7
- 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/agent-hosted-plugins.md +112 -37
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +19 -1
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/sites.md +4 -0
- package/vendor/docs/04-api-reference/tunnels.md +21 -19
- 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 +15 -6
- package/vendor/docs/06-reference/config-files.md +19 -15
- 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/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +3 -3
- 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 +3 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -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/tunnels.d.ts +3 -6
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- 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 +29 -10
- 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 +103 -22
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +63 -6
- package/vendor/server/src/commands/sites.js +15 -1
- package/vendor/server/src/plugin-approval.js +80 -1
- 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 +9 -4
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- package/vendor/serverd/src/lib/gatekeeper-client.js +18 -7
- package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/nginx.js +269 -2
- package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
- package/vendor/serverd/src/lib/plugin-lifecycle.js +128 -5
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +39 -6
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/user-surface.js +48 -27
- package/vendor/serverd/src/lib/vhost-reconcile.js +5 -3
- package/vendor/serverd/src/lib/web-surface.js +766 -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/sites.js +143 -65
- package/vendor/serverd/src/routes/management/tunnels.js +11 -20
- package/vendor/serverd/src/routes/management.js +2 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
|
@@ -4,8 +4,8 @@
|
|
|
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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BvEfCZYS.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BZwQ_aQc.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body style="background-color: #09090b">
|
|
11
11
|
<div id="root"></div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lamalibre/lamaste-serverd",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"description": "Lamaste server daemon — REST API and plugin host",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"publishConfig": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@fastify/rate-limit": "^10.3.0",
|
|
30
30
|
"@fastify/static": "^9.1.1",
|
|
31
31
|
"@fastify/websocket": "^11.0.0",
|
|
32
|
-
"@lamalibre/lamaste": "^4.
|
|
32
|
+
"@lamalibre/lamaste": "^4.6.0",
|
|
33
33
|
"bcryptjs": "^3.0.3",
|
|
34
34
|
"execa": "^9.6.1",
|
|
35
35
|
"fastify": "^5.8.4",
|
|
@@ -45,6 +45,7 @@ import { startIdentityServices, stopIdentityServices } from './lib/identity-sync
|
|
|
45
45
|
import { syncOidcClients } from './lib/plugin-oidc.js';
|
|
46
46
|
import { ensureRelaySigningKey } from './lib/relay-signing.js';
|
|
47
47
|
import { reconcileSiteVhosts, reconcileTunnelVhosts } from './lib/vhost-reconcile.js';
|
|
48
|
+
import { reconcileWebSurfaces } from './lib/web-surface.js';
|
|
48
49
|
import {
|
|
49
50
|
loadTicketScopeCapabilities,
|
|
50
51
|
checkInstanceLiveness,
|
|
@@ -217,7 +218,7 @@ async function start() {
|
|
|
217
218
|
|
|
218
219
|
// The auth portal and static site vhosts an older version wrote name the
|
|
219
220
|
// client's Host header, not their own hostname, to Authelia. Before listen:
|
|
220
|
-
//
|
|
221
|
+
// no site change can arrive until the panel listens.
|
|
221
222
|
if (config.onboarding.status === 'COMPLETED' && config.domain) {
|
|
222
223
|
await reconcileSiteVhosts(server.log, config.domain).catch((err) => {
|
|
223
224
|
server.log.error({ err: err.message }, 'Startup site vhost reconcile failed');
|
|
@@ -239,9 +240,13 @@ async function start() {
|
|
|
239
240
|
// relay, and Gatekeeper vhosts name the client's Host header to Gatekeeper.
|
|
240
241
|
// After listen: a rewritten agent-panel vhost asks this server at once.
|
|
241
242
|
if (config.onboarding.status === 'COMPLETED') {
|
|
242
|
-
reconcileTunnelVhosts(server.log)
|
|
243
|
-
|
|
244
|
-
|
|
243
|
+
reconcileTunnelVhosts(server.log)
|
|
244
|
+
.catch((err) => {
|
|
245
|
+
server.log.error({ err: err.message }, 'Startup tunnel vhost reconcile failed');
|
|
246
|
+
})
|
|
247
|
+
// Plugins' web surfaces: each approved tree served (a redeploy may
|
|
248
|
+
// have reset the web root), each site's vhost in line.
|
|
249
|
+
.then(() => reconcileWebSurfaces(server.log));
|
|
245
250
|
}
|
|
246
251
|
|
|
247
252
|
// --- Health of hosted agent-hosted plugins (every 10s; idle without hostings) ---
|
|
@@ -30,17 +30,29 @@ export function chiselReloadsAuthfile() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Whether an authfile change must restart chisel: a grant withdrawn (so
|
|
34
|
+
* live sessions drop the binding) — unless `keepLiveSessions` — or any
|
|
35
|
+
* change a chisel too old to reload its authfile would not see.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ changed: boolean, revoked: boolean }} change
|
|
38
|
+
* @param {{ keepLiveSessions?: boolean }} [opts]
|
|
39
|
+
*/
|
|
40
|
+
export function authfileChangeNeedsRestart(change, { keepLiveSessions = false } = {}) {
|
|
41
|
+
return (change.revoked && !keepLiveSessions) || (change.changed && !runningPinnedRelease);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Restart chisel if the change requires it ({@link authfileChangeNeedsRestart}).
|
|
46
|
+
* `try-restart` leaves a stopped chisel stopped (see chisel-reconcile.js: a
|
|
47
|
+
* server that failed to reconcile keeps chisel down until it succeeds).
|
|
36
48
|
*
|
|
37
49
|
* @param {{ changed: boolean, revoked: boolean }} change
|
|
38
50
|
* @param {import('pino').Logger} [logger]
|
|
51
|
+
* @param {{ keepLiveSessions?: boolean }} [opts]
|
|
39
52
|
* @returns {Promise<{ restartOk: boolean, restartError?: string }>}
|
|
40
53
|
*/
|
|
41
|
-
export async function applyAuthfileChange(change, logger) {
|
|
42
|
-
|
|
43
|
-
if (!needsRestart) return { restartOk: true };
|
|
54
|
+
export async function applyAuthfileChange(change, logger, opts = {}) {
|
|
55
|
+
if (!authfileChangeNeedsRestart(change, opts)) return { restartOk: true };
|
|
44
56
|
const result = await reloadChiselAuth(execa, logger);
|
|
45
57
|
return result.ok ? { restartOk: true } : { restartOk: false, restartError: result.error };
|
|
46
58
|
}
|
|
@@ -71,10 +71,18 @@ export function getChiselStatus() {
|
|
|
71
71
|
* grant, chisel is restarted so live sessions drop the binding. Call only
|
|
72
72
|
* after the tunnel state has been written. Throws if the restart fails,
|
|
73
73
|
* since a withdrawn grant would otherwise stay live.
|
|
74
|
+
*
|
|
75
|
+
* `keepLiveSessions`: withdraw the grant for new sessions only, without the
|
|
76
|
+
* restart — which drops every session, the hosting agents' internal plugin
|
|
77
|
+
* tunnels with them. Only for a port nothing routes to any more, whose
|
|
78
|
+
* agent drops the binding at its next sync (a tunnel moved to a plugin's
|
|
79
|
+
* web surface).
|
|
80
|
+
*
|
|
81
|
+
* @param {{ keepLiveSessions?: boolean }} [opts]
|
|
74
82
|
*/
|
|
75
|
-
export async function syncChisel() {
|
|
83
|
+
export async function syncChisel(opts = {}) {
|
|
76
84
|
const change = await syncChiselAuthfile();
|
|
77
|
-
const { restartOk, restartError } = await applyAuthfileChange(change);
|
|
85
|
+
const { restartOk, restartError } = await applyAuthfileChange(change, undefined, opts);
|
|
78
86
|
if (!restartOk) {
|
|
79
87
|
throw new Error(`Chisel restart failed after withdrawing a port grant: ${restartError}`);
|
|
80
88
|
}
|
|
@@ -225,26 +225,37 @@ function groupOf(raw) {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
/**
|
|
228
|
-
* Every grant on
|
|
229
|
-
* <route>`) — with roles or without.
|
|
228
|
+
* Every grant on one resource (`resourceType`, `resourceId`).
|
|
230
229
|
*
|
|
231
|
-
* @param {string}
|
|
230
|
+
* @param {string} resourceType
|
|
231
|
+
* @param {string} resourceId
|
|
232
232
|
* @returns {Promise<{ ok: true, grants: GatekeeperGrant[] } | Extract<GatekeeperResult, { ok: false }>>}
|
|
233
233
|
*/
|
|
234
|
-
export async function
|
|
235
|
-
const qs = new URLSearchParams({ resourceType
|
|
234
|
+
export async function listResourceGrants(resourceType, resourceId) {
|
|
235
|
+
const qs = new URLSearchParams({ resourceType, resourceId }).toString();
|
|
236
236
|
const result = await gatekeeperRequest('GET', `/api/grants?${qs}`);
|
|
237
237
|
if (!result.ok) return result;
|
|
238
238
|
if (!isRecord(result.data) || !Array.isArray(result.data.grants)) return malformed('its grants');
|
|
239
239
|
const grants = result.data.grants.map(grantOf);
|
|
240
240
|
if (grants.some((g) => g === null)) return malformed('its grants');
|
|
241
|
-
// Gatekeeper filters by the query; a grant on anything else is not this
|
|
241
|
+
// Gatekeeper filters by the query; a grant on anything else is not this resource's.
|
|
242
242
|
return {
|
|
243
243
|
ok: true,
|
|
244
|
-
grants: grants.filter((g) => g.resourceType ===
|
|
244
|
+
grants: grants.filter((g) => g.resourceType === resourceType && g.resourceId === resourceId),
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Every grant on plugin `route` (`resourceType: 'plugin'`, `resourceId:
|
|
250
|
+
* <route>`) — with roles or without.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} route
|
|
253
|
+
* @returns {Promise<{ ok: true, grants: GatekeeperGrant[] } | Extract<GatekeeperResult, { ok: false }>>}
|
|
254
|
+
*/
|
|
255
|
+
export function listPluginGrants(route) {
|
|
256
|
+
return listResourceGrants('plugin', route);
|
|
257
|
+
}
|
|
258
|
+
|
|
248
259
|
/**
|
|
249
260
|
* Every Lamaste group with its members.
|
|
250
261
|
*
|
|
@@ -871,8 +871,10 @@ export async function runUninstallPipeline(op, ctx, { force = false } = {}) {
|
|
|
871
871
|
/**
|
|
872
872
|
* The install pipeline around an upgrade of the relay package (the agent
|
|
873
873
|
* half is already running; its stages go through the tunnel). `apply` is
|
|
874
|
-
* Lamaste's step — install the new package and its hook module; `
|
|
875
|
-
*
|
|
874
|
+
* Lamaste's step — install the new package and its hook module; `serve`,
|
|
875
|
+
* when given, is the last step (the new version's web tree served, once
|
|
876
|
+
* every stage passed);
|
|
877
|
+
* `revert` restores the previous ones. Agent and relay stages see `phase: "upgrade"`.
|
|
876
878
|
*
|
|
877
879
|
* A hosted plugin whose agent half Lamaste installs gets the new version of
|
|
878
880
|
* it first (after the agent's pre stage): the agent's helper installs it
|
|
@@ -880,7 +882,7 @@ export async function runUninstallPipeline(op, ctx, { force = false } = {}) {
|
|
|
880
882
|
* tunnel. A failure after that switch activates the version that ran
|
|
881
883
|
* before (`ctx.previousAgentHalfVersion`).
|
|
882
884
|
*/
|
|
883
|
-
export async function runUpgradePipeline(op, ctx, { apply, revert, hosted }) {
|
|
885
|
+
export async function runUpgradePipeline(op, ctx, { apply, serve, revert, hosted }) {
|
|
884
886
|
let applied = false;
|
|
885
887
|
try {
|
|
886
888
|
if (hosted) {
|
|
@@ -906,6 +908,9 @@ export async function runUpgradePipeline(op, ctx, { apply, revert, hosted }) {
|
|
|
906
908
|
await agentStageOrSkip(op, ctx, 'install:post-tunnel', 'upgrade', 'tunnel');
|
|
907
909
|
await relayStageOrSkip(op, ctx, 'install:post-tunnel', 'upgrade');
|
|
908
910
|
}
|
|
911
|
+
// What the new version serves its users from the relay (its web tree):
|
|
912
|
+
// the last step, once every stage passed — a failure still reverts.
|
|
913
|
+
if (serve) await op.step('serve the new web tree', serve);
|
|
909
914
|
} catch (err) {
|
|
910
915
|
op.warn(`Reverting: ${String(err.message ?? err).slice(0, 300)}`);
|
|
911
916
|
try {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- A plugin's web surface (4.6, slice 7).
|
|
2
|
+
--
|
|
3
|
+
-- user_surface is the manifest's `userSurface` block as the installed
|
|
4
|
+
-- version declares it (JSON; NULL when it declares none): its `web` names
|
|
5
|
+
-- the default subdomain and access mode Lamaste offers for the plugin's own
|
|
6
|
+
-- site and the directory of the server package that holds the shell.
|
|
7
|
+
--
|
|
8
|
+
-- web_tree_sha256 is the digest of that directory's web tree (core
|
|
9
|
+
-- `webTreeManifest`), read from the installed package: it enters the
|
|
10
|
+
-- approval digest, and the relay serves a copy of the tree only when the
|
|
11
|
+
-- copy's own digest equals it. NULL when the version declares no web
|
|
12
|
+
-- surface.
|
|
13
|
+
ALTER TABLE plugins ADD COLUMN user_surface TEXT;
|
|
14
|
+
ALTER TABLE plugins ADD COLUMN web_tree_sha256 TEXT;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
USER_SURFACE_MAX_RESPONSE_SECONDS,
|
|
8
8
|
isUserSurfaceRoute,
|
|
9
9
|
userSurfaceApiPath,
|
|
10
|
+
WEB_TREE_CONTENT_TYPES,
|
|
10
11
|
} from '@lamalibre/lamaste';
|
|
11
12
|
|
|
12
13
|
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
@@ -590,12 +591,14 @@ const USER_SURFACE_PROXY_HEADERS = `
|
|
|
590
591
|
* `X-Original-URL` and in `Host` — never by the client's `Host` header (see
|
|
591
592
|
* {@link originalUrl}) — and the session cookie is passed on.
|
|
592
593
|
*
|
|
593
|
-
* @param {'tunnel'|'user-surface'} check
|
|
594
|
+
* @param {'tunnel'|'user-surface'|'user-surface-shell'} check
|
|
594
595
|
* @param {string} fqdn
|
|
595
596
|
* @returns {string}
|
|
596
597
|
*/
|
|
597
598
|
function gatekeeperSubrequest(check, fqdn) {
|
|
598
599
|
return `internal;
|
|
600
|
+
# A subrequest is never authorized itself (a host may authorize at server level)
|
|
601
|
+
auth_request off;
|
|
599
602
|
|
|
600
603
|
proxy_pass http://127.0.0.1:9294/authz/${check}/${fqdn};
|
|
601
604
|
proxy_pass_request_body off;
|
|
@@ -673,7 +676,7 @@ ${USER_SURFACE_PROXY_HEADERS}
|
|
|
673
676
|
# The user surface of plugin "${route}"
|
|
674
677
|
location = ${apiPath} ${block}
|
|
675
678
|
|
|
676
|
-
location ${apiPath}/ ${block}
|
|
679
|
+
location ^~ ${apiPath}/ ${block}
|
|
677
680
|
`;
|
|
678
681
|
});
|
|
679
682
|
return `
|
|
@@ -685,6 +688,7 @@ ${USER_SURFACE_PROXY_HEADERS}
|
|
|
685
688
|
|
|
686
689
|
location = /internal/lamaste/user-surface-401 {
|
|
687
690
|
internal;
|
|
691
|
+
auth_request off;
|
|
688
692
|
|
|
689
693
|
proxy_pass http://127.0.0.1:9294/authz/user-surface-unauthenticated;
|
|
690
694
|
proxy_pass_request_body off;
|
|
@@ -1163,6 +1167,269 @@ ${aliasBlock}`;
|
|
|
1163
1167
|
return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
|
|
1164
1168
|
}
|
|
1165
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
|
+
|
|
1166
1433
|
/**
|
|
1167
1434
|
* Server block answering a site's aliases with a permanent redirect to the
|
|
1168
1435
|
* primary hostname, on the same certificate (its lineage covers every alias).
|
|
@@ -218,28 +218,45 @@ export function installedAgentHalf(plugin) {
|
|
|
218
218
|
return { ...declaration, version: pin.version, integrity: pin.integrity };
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The web tree a plugin version's approval covers: `{ treeSha256 }` when it
|
|
223
|
+
* declares a web surface (`userSurface.web`), null otherwise. A declared
|
|
224
|
+
* surface without the tree's digest cannot be approved.
|
|
225
|
+
*/
|
|
226
|
+
function installedWebTree(plugin) {
|
|
227
|
+
if (!plugin.userSurface?.web) return null;
|
|
228
|
+
if (typeof plugin.webTreeSha256 !== 'string') {
|
|
229
|
+
throw new Error(`Plugin "${plugin.name}" declares a web surface but its web tree is unknown`);
|
|
230
|
+
}
|
|
231
|
+
return { treeSha256: plugin.webTreeSha256 };
|
|
232
|
+
}
|
|
233
|
+
|
|
221
234
|
/**
|
|
222
235
|
* What approving a plugin version covers — its relay hook set, the agent
|
|
223
|
-
* half Lamaste installs
|
|
224
|
-
* stores it) — and the digest the approval names.
|
|
225
|
-
* there is nothing to approve. A plugin without an
|
|
226
|
-
* digest it had before
|
|
236
|
+
* half Lamaste installs, its identity block (normalized, as the registry
|
|
237
|
+
* stores it) and its web tree — and the digest the approval names.
|
|
238
|
+
* `digest` is null when there is nothing to approve. A plugin without an
|
|
239
|
+
* identity block or a web surface has the digest it had before they
|
|
240
|
+
* existed.
|
|
227
241
|
*/
|
|
228
242
|
export function installedApproval(plugin) {
|
|
229
243
|
const relay = installedRelayHooks(plugin);
|
|
230
244
|
const agentHalf = installedAgentHalf(plugin);
|
|
231
245
|
const identity = plugin.identity ?? null;
|
|
232
|
-
const
|
|
246
|
+
const web = installedWebTree(plugin);
|
|
247
|
+
const required = relay !== null || agentHalf !== null || identity !== null || web !== null;
|
|
233
248
|
return {
|
|
234
249
|
relay,
|
|
235
250
|
agentHalf,
|
|
236
251
|
identity,
|
|
252
|
+
web,
|
|
237
253
|
required,
|
|
238
254
|
digest: required
|
|
239
255
|
? approvalDigest({
|
|
240
256
|
relay: relay ? { set: relay.set, entrySha256: relay.entrySha256 } : null,
|
|
241
257
|
agentHalf,
|
|
242
258
|
identity,
|
|
259
|
+
web,
|
|
243
260
|
})
|
|
244
261
|
: null,
|
|
245
262
|
};
|
|
@@ -269,7 +286,7 @@ export async function installedVersionApproved(plugin) {
|
|
|
269
286
|
*/
|
|
270
287
|
export async function hookStatus(plugin) {
|
|
271
288
|
const approval = installedApproval(plugin);
|
|
272
|
-
const { relay, agentHalf, identity } = approval;
|
|
289
|
+
const { relay, agentHalf, identity, web } = approval;
|
|
273
290
|
const agent = agentHookSetOf({ hooks: plugin.hooks });
|
|
274
291
|
const state = await readHookState(plugin.name);
|
|
275
292
|
const approved = isApproved(approval, state);
|
|
@@ -290,6 +307,12 @@ export async function hookStatus(plugin) {
|
|
|
290
307
|
approved: identity !== null && approved,
|
|
291
308
|
block: identity,
|
|
292
309
|
},
|
|
310
|
+
web: {
|
|
311
|
+
declared: web !== null,
|
|
312
|
+
approved: web !== null && approved,
|
|
313
|
+
treeSha256: web?.treeSha256 ?? null,
|
|
314
|
+
surface: plugin.userSurface?.web ?? null,
|
|
315
|
+
},
|
|
293
316
|
approval: {
|
|
294
317
|
required: approval.required,
|
|
295
318
|
state: !approval.required ? 'not-required' : approved ? 'approved' : 'pending',
|
|
@@ -319,16 +342,17 @@ export const IDENTITY_CHANGED = 'identity.changed';
|
|
|
319
342
|
/**
|
|
320
343
|
* The reason a staged version waits because what it brings — its relay
|
|
321
344
|
* hooks (`relay`), the agent half Lamaste installs (`agentHalf`), its
|
|
322
|
-
* identity block (`identity`); each truthy when it
|
|
323
|
-
* was approved.
|
|
345
|
+
* identity block (`identity`), its web tree (`web`); each truthy when it
|
|
346
|
+
* brings one — is not what was approved.
|
|
324
347
|
*
|
|
325
348
|
* @returns {{ code: string, reason: string }}
|
|
326
349
|
*/
|
|
327
|
-
export function digestUnapprovedReason({ relay, agentHalf, identity = null }) {
|
|
350
|
+
export function digestUnapprovedReason({ relay, agentHalf, identity = null, web = null }) {
|
|
328
351
|
const parts = [
|
|
329
|
-
...(relay || (!agentHalf && !identity) ? ['relay hooks'] : []),
|
|
352
|
+
...(relay || (!agentHalf && !identity && !web) ? ['relay hooks'] : []),
|
|
330
353
|
...(agentHalf ? ['agent half'] : []),
|
|
331
354
|
...(identity ? ['identity'] : []),
|
|
355
|
+
...(web ? ['web surface'] : []),
|
|
332
356
|
];
|
|
333
357
|
const named =
|
|
334
358
|
parts.length === 1
|
|
@@ -351,6 +375,7 @@ function pendingApprovalRequired(pending) {
|
|
|
351
375
|
relay: pending.preview,
|
|
352
376
|
agentHalf: pending.agentHalf,
|
|
353
377
|
identity: pending.identity?.after ?? null,
|
|
378
|
+
web: pending.web?.after ?? null,
|
|
354
379
|
}),
|
|
355
380
|
]
|
|
356
381
|
);
|
|
@@ -369,7 +394,10 @@ function pendingApprovalRequired(pending) {
|
|
|
369
394
|
* how it changes the identity block (`identity`: `{ before, after,
|
|
370
395
|
* rolesAdded, rolesRemoved, attributesAdded, attributesRemoved,
|
|
371
396
|
* labelsChanged }` with the normalized blocks, null when neither version
|
|
372
|
-
* declares one — part of the digest),
|
|
397
|
+
* declares one — part of the digest), its web tree (`web`: `{ before,
|
|
398
|
+
* after }`, the installed version's tree digest and the staged one's
|
|
399
|
+
* `{ sha256, files, totalBytes }`, null when neither declares a web
|
|
400
|
+
* surface — part of the digest), why it waits for an administrator
|
|
373
401
|
* (`approvalRequired`: `[{ code, reason }]`, `digest.unapproved`,
|
|
374
402
|
* `settings.secret-to-plain` and `identity.changed`; never empty), and —
|
|
375
403
|
* when the plugin has auto-approve on — why auto-approve did not cover it
|
|
@@ -385,6 +413,7 @@ export function pendingUpdateView(pending) {
|
|
|
385
413
|
agentHalf: pending.agentHalf ?? null,
|
|
386
414
|
settings: pending.settings ?? null,
|
|
387
415
|
identity: pending.identity ?? null,
|
|
416
|
+
web: pending.web ?? null,
|
|
388
417
|
approvalRequired: pendingApprovalRequired(pending),
|
|
389
418
|
autoApproveWithheld: pending.autoApproveWithheld ?? null,
|
|
390
419
|
};
|