@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
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugins' user surfaces on the relay (slice 6): which host serves an
|
|
3
|
+
* agent-hosted plugin's `/api/<route>/app/*`, and the panel's side of every
|
|
4
|
+
* such request.
|
|
5
|
+
*
|
|
6
|
+
* **Binding.** A tunnel is bound as a plugin's user surface (core
|
|
7
|
+
* `bindUserSurface`; `TunnelEntry.userSurface` in `tunnels.json`, read with
|
|
8
|
+
* core `tunnelUserSurfaceRoutes`): an enabled app tunnel, `authenticated` or
|
|
9
|
+
* `restricted`, carried by the agent that hosts the plugin, its route held
|
|
10
|
+
* by an installed agent-hosted plugin; a route on one host at most. A
|
|
11
|
+
* plugin may be bound before it declares `identity`: its host *serves* the
|
|
12
|
+
* route — locations that ask Gatekeeper and pass the request to the panel —
|
|
13
|
+
* only while the plugin's installed, approved version declares it and the
|
|
14
|
+
* tunnel's agent is the plugin's hosting agent ({@link servedUserSurfaceRoutes}).
|
|
15
|
+
* Before that requests go down the tunnel as before, so a plugin's
|
|
16
|
+
* switch-over is a plain update. Whether a route is served changes with the
|
|
17
|
+
* plugin's approval, update, reversal, hosting and uninstall, and with the
|
|
18
|
+
* tunnel's agent: each re-renders the vhosts it is bound to
|
|
19
|
+
* ({@link refreshUserSurfaceVhosts}, {@link refreshTunnelUserSurfaceVhosts}
|
|
20
|
+
* after a revocation; a move re-renders in core); an uninstall also
|
|
21
|
+
* releases the binding everywhere — and any binding left for a route no
|
|
22
|
+
* installed plugin holds ({@link releasePluginUserSurface}).
|
|
23
|
+
*
|
|
24
|
+
* **Requests.** The panel's user-surface context (`routes/user-surface.js`,
|
|
25
|
+
* core `userSurfacePlugin`) admits a request only with a relay assertion
|
|
26
|
+
* Gatekeeper signed for the panel ({@link verifyUserSurfaceRelay}),
|
|
27
|
+
* forwards it to the plugin's agent half on the internal tunnel of the
|
|
28
|
+
* route's hosting ({@link resolveUserSurfaceTarget} — only from a host its
|
|
29
|
+
* hosting agent carries, never while the hosting is down) as the
|
|
30
|
+
* directory's user (`lib/user-caller.js`), and checks `Origin` against the
|
|
31
|
+
* bound host ({@link userSurfaceOrigin}) — never the request's `Host`.
|
|
32
|
+
* Every response still being sent is re-checked every few seconds
|
|
33
|
+
* ({@link sweepOpenUserSurfaceResponses}) and ended as soon as its user may
|
|
34
|
+
* no longer use the plugin, holds other roles, or the host no longer serves
|
|
35
|
+
* the route.
|
|
36
|
+
*
|
|
37
|
+
* Audit: `tunnel.user-surface.bound` and `tunnel.user-surface.unbound`
|
|
38
|
+
* (with the reason: `unbound`, `tunnel-deleted`, `plugin-uninstalled`).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import crypto from 'node:crypto';
|
|
42
|
+
import {
|
|
43
|
+
NonceStore,
|
|
44
|
+
RELAY_ASSERTION_HEADER,
|
|
45
|
+
RELAY_PANEL_AUDIENCE,
|
|
46
|
+
RELAY_PANEL_PORT,
|
|
47
|
+
isAgentHostedPlugin,
|
|
48
|
+
siteUserSurfaceRoute,
|
|
49
|
+
tunnelUserSurfaceRoutes,
|
|
50
|
+
userSurfaceHostServing,
|
|
51
|
+
userSurfaceHostingAgent,
|
|
52
|
+
userSurfaceHosts,
|
|
53
|
+
verifyRelayAssertion,
|
|
54
|
+
} from '@lamalibre/lamaste';
|
|
55
|
+
import {
|
|
56
|
+
TUNNEL_MAX_BODY_MB,
|
|
57
|
+
TunnelError,
|
|
58
|
+
bindUserSurface,
|
|
59
|
+
releaseUserSurfaceRoute,
|
|
60
|
+
unbindUserSurface,
|
|
61
|
+
} from '@lamalibre/lamaste/server';
|
|
62
|
+
import { httpError } from './app-error.js';
|
|
63
|
+
import { recordAuditBestEffort } from './audit.js';
|
|
64
|
+
import { getConfig } from './config.js';
|
|
65
|
+
import { installedVersionApproved } from './plugin-hooks.js';
|
|
66
|
+
import { findPluginByRoute } from './plugins.js';
|
|
67
|
+
import { relaySigner } from './relay-signing.js';
|
|
68
|
+
import { readSites, readTunnels } from './state.js';
|
|
69
|
+
import { tunnelCertbotDeps, tunnelNginxDeps, tunnelStateDeps } from './tunnel-deps.js';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The user surface's own rate limit (Fastify route config): per client
|
|
73
|
+
* address — nginx sets `X-Forwarded-For` to it and the panel trusts that
|
|
74
|
+
* one loopback hop — and plugin route. An application's page makes many
|
|
75
|
+
* requests; the panel's root limit (100/min) is for its administrators.
|
|
76
|
+
*/
|
|
77
|
+
const USER_SURFACE_RATE_LIMIT = Object.freeze({
|
|
78
|
+
max: 1200,
|
|
79
|
+
timeWindow: '1 minute',
|
|
80
|
+
keyGenerator: (request) => `${request.ip} ${request.params?.route ?? ''}`,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The user surface's route config: its rate limit, and no CORS — the
|
|
85
|
+
* panel's CORS (for its own origins) never answers for a user surface, so
|
|
86
|
+
* no other origin reads a user's answers or gets a preflight through.
|
|
87
|
+
*/
|
|
88
|
+
export const USER_SURFACE_ROUTE_CONFIG = Object.freeze({
|
|
89
|
+
rateLimit: USER_SURFACE_RATE_LIMIT,
|
|
90
|
+
cors: false,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Which bound routes a host serves
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The agent hosting installed plugin `plugin`, or null: its own hosting
|
|
99
|
+
* record names the agent, and `tunnels.json` agrees — that agent carries
|
|
100
|
+
* the route's internal tunnel (core `userSurfaceHostingAgent`, what
|
|
101
|
+
* Gatekeeper reads). A hosting whose agent was revoked (its internal
|
|
102
|
+
* tunnel then has no agent) hosts nothing here.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ name: string, route: string | null }} plugin
|
|
105
|
+
* @param {readonly object[]} tunnels
|
|
106
|
+
* @returns {Promise<string | null>}
|
|
107
|
+
*/
|
|
108
|
+
export async function hostingAgentOf(plugin, tunnels) {
|
|
109
|
+
if (!plugin.route) return null;
|
|
110
|
+
const { ownHosting } = await import('./plugin-hosting.js');
|
|
111
|
+
const record = await ownHosting(plugin);
|
|
112
|
+
if (!record) return null;
|
|
113
|
+
const agent = userSurfaceHostingAgent(tunnels, plugin.route);
|
|
114
|
+
return agent !== null && agent === record.agentLabel ? agent : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The agent hosting the plugin that holds `route`, or null (core
|
|
119
|
+
* `UserSurfaceDeps.hostingAgent`: binding refuses a tunnel any other agent
|
|
120
|
+
* carries). Called under the tunnel lock: reads the state without taking it.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} route
|
|
123
|
+
* @returns {Promise<string | null>}
|
|
124
|
+
*/
|
|
125
|
+
async function userSurfaceHostingAgentOf(route) {
|
|
126
|
+
const plugin = await findPluginByRoute(route);
|
|
127
|
+
return plugin ? hostingAgentOf(plugin, await readTunnels()) : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether plugin `route`'s user surface is served on a host it is bound to
|
|
132
|
+
* — the tunnel carried by `agentLabel` — and why not: `not_installed` (no
|
|
133
|
+
* installed plugin holds the route), `no_identity` (its installed version
|
|
134
|
+
* declares none), `not_approved` (that version is not approved),
|
|
135
|
+
* `not_hosting_agent` (the tunnel's agent is not the plugin's hosting
|
|
136
|
+
* agent: the tunnel was moved or its agent revoked, or the plugin is hosted
|
|
137
|
+
* elsewhere or not at all). Not the agent half's protocol answer: the panel
|
|
138
|
+
* checks that per request (503 until it answered).
|
|
139
|
+
*
|
|
140
|
+
* @param {string} route
|
|
141
|
+
* @param {string | undefined} agentLabel the agent carrying the bound tunnel
|
|
142
|
+
* @param {readonly object[]} tunnels the tunnel state
|
|
143
|
+
* @returns {Promise<{ served: true, plugin: object } | { served: false, reason: 'not_installed' | 'no_identity' | 'not_approved' | 'not_hosting_agent' }>}
|
|
144
|
+
*/
|
|
145
|
+
async function routeServing(route, agentLabel, tunnels) {
|
|
146
|
+
const plugin = await findPluginByRoute(route);
|
|
147
|
+
if (!plugin) return { served: false, reason: 'not_installed' };
|
|
148
|
+
if (!plugin.identity) return { served: false, reason: 'no_identity' };
|
|
149
|
+
// A version whose approval cannot even be judged is not approved.
|
|
150
|
+
if (!(await installedVersionApproved(plugin).catch(() => false))) {
|
|
151
|
+
return { served: false, reason: 'not_approved' };
|
|
152
|
+
}
|
|
153
|
+
const hosting = await hostingAgentOf(plugin, tunnels);
|
|
154
|
+
if (hosting === null || agentLabel !== hosting) {
|
|
155
|
+
return { served: false, reason: 'not_hosting_agent' };
|
|
156
|
+
}
|
|
157
|
+
return { served: true, plugin };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The body limit of the plugin's internal tunnel, in MiB — the default while it is not hosted.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} plugin
|
|
164
|
+
* @param {readonly object[]} tunnels the tunnel state
|
|
165
|
+
*/
|
|
166
|
+
export async function internalTunnelBodyLimit(plugin, tunnels) {
|
|
167
|
+
const { ownHosting } = await import('./plugin-hosting.js');
|
|
168
|
+
const record = await ownHosting(plugin);
|
|
169
|
+
if (!record) return TUNNEL_MAX_BODY_MB.default;
|
|
170
|
+
const tunnel = tunnels.find((t) => t.id === record.tunnelId);
|
|
171
|
+
return tunnel?.maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Of `routes` (bound to `tunnel`), the ones its vhost serves, in order,
|
|
176
|
+
* each with the body limit of its locations (the plugin's internal
|
|
177
|
+
* tunnel's). Core's `UserSurfaceDeps.servedRoutes` — called under the
|
|
178
|
+
* tunnel lock, so it reads the tunnel state without taking it.
|
|
179
|
+
*
|
|
180
|
+
* @param {readonly string[]} routes
|
|
181
|
+
* @param {{ agentLabel?: string }} tunnel the bound tunnel (its agent, as it is or is about to be)
|
|
182
|
+
* @returns {Promise<Array<{ route: string, maxBodySizeMb: number }>>}
|
|
183
|
+
*/
|
|
184
|
+
export async function servedUserSurfaceRoutes(routes, tunnel) {
|
|
185
|
+
if (routes.length === 0) return [];
|
|
186
|
+
const tunnels = await readTunnels();
|
|
187
|
+
const served = [];
|
|
188
|
+
for (const route of routes) {
|
|
189
|
+
const serving = await routeServing(route, tunnel?.agentLabel, tunnels);
|
|
190
|
+
if (!serving.served) continue;
|
|
191
|
+
served.push({ route, maxBodySizeMb: await internalTunnelBodyLimit(serving.plugin, tunnels) });
|
|
192
|
+
}
|
|
193
|
+
return served;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What core's tunnel workflows ask the panel about user surfaces. */
|
|
197
|
+
export const userSurfaceDeps = Object.freeze({
|
|
198
|
+
servedRoutes: servedUserSurfaceRoutes,
|
|
199
|
+
hostingAgent: userSurfaceHostingAgentOf,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A tunnel as the panel lists it: with `userSurfaceStatus`, how each route
|
|
204
|
+
* bound to it is served — `{ route, served, reason }`, `reason` null when
|
|
205
|
+
* served — on a tunnel that has a binding.
|
|
206
|
+
*
|
|
207
|
+
* @template {object} T
|
|
208
|
+
* @param {T} tunnel
|
|
209
|
+
* @returns {Promise<T>}
|
|
210
|
+
*/
|
|
211
|
+
export async function withUserSurfaceStatus(tunnel) {
|
|
212
|
+
const routes = tunnelUserSurfaceRoutes(tunnel);
|
|
213
|
+
if (routes.length === 0) return tunnel;
|
|
214
|
+
const tunnels = await readTunnels();
|
|
215
|
+
const status = [];
|
|
216
|
+
for (const route of routes) {
|
|
217
|
+
const serving = await routeServing(route, tunnel.agentLabel, tunnels);
|
|
218
|
+
status.push({ route, served: serving.served, reason: serving.served ? null : serving.reason });
|
|
219
|
+
}
|
|
220
|
+
return { ...tunnel, userSurfaceStatus: status };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Binding
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* What core's binding workflows need from the panel — among them its domain
|
|
229
|
+
* and Let's Encrypt email, which a vhost rewrite needs.
|
|
230
|
+
*/
|
|
231
|
+
function bindingOptions(id, route, logger) {
|
|
232
|
+
const config = getConfig();
|
|
233
|
+
if (!config.domain || !config.email) {
|
|
234
|
+
throw httpError('Domain and email must be configured', 400);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
id,
|
|
238
|
+
route,
|
|
239
|
+
domain: config.domain,
|
|
240
|
+
email: config.email,
|
|
241
|
+
nginx: tunnelNginxDeps,
|
|
242
|
+
certbot: tunnelCertbotDeps,
|
|
243
|
+
state: tunnelStateDeps,
|
|
244
|
+
userSurface: userSurfaceDeps,
|
|
245
|
+
logger,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Audit a change of `route`'s binding on `tunnel`. `plugin` defaults to the
|
|
251
|
+
* installed plugin that holds the route.
|
|
252
|
+
*
|
|
253
|
+
* @param {'tunnel.user-surface.bound' | 'tunnel.user-surface.unbound'} action
|
|
254
|
+
* @param {object} tunnel
|
|
255
|
+
* @param {string} route
|
|
256
|
+
* @param {{ actor: string, logger: import('pino').Logger, plugin?: string, reason?: string }} opts
|
|
257
|
+
*/
|
|
258
|
+
async function auditBinding(action, tunnel, route, { actor, logger, plugin, reason }) {
|
|
259
|
+
await recordAuditBestEffort(
|
|
260
|
+
{
|
|
261
|
+
actor,
|
|
262
|
+
action,
|
|
263
|
+
plugin: plugin ?? (await findPluginByRoute(route))?.name ?? null,
|
|
264
|
+
details: {
|
|
265
|
+
tunnelId: tunnel.id,
|
|
266
|
+
fqdn: tunnel.fqdn ?? null,
|
|
267
|
+
route,
|
|
268
|
+
...(reason === undefined ? {} : { reason }),
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
logger,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Throw (a core `TunnelError`) unless an installed agent-hosted plugin holds
|
|
277
|
+
* `route` — what binding requires, and what a binding is checked against
|
|
278
|
+
* again once it is written.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} route
|
|
281
|
+
*/
|
|
282
|
+
async function assertBindableRoute(route) {
|
|
283
|
+
const plugin = await findPluginByRoute(route);
|
|
284
|
+
if (!plugin) {
|
|
285
|
+
throw new TunnelError(`No installed plugin holds the route '${route}'`, 'INVALID_SETTING');
|
|
286
|
+
}
|
|
287
|
+
if (!isAgentHostedPlugin(plugin)) {
|
|
288
|
+
throw new TunnelError(
|
|
289
|
+
`Plugin "${plugin.name}" is not agent-hosted — only an agent-hosted plugin has a user surface`,
|
|
290
|
+
'INVALID_SETTING',
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
// A route is served on one host at most: the plugin's own site, when it
|
|
294
|
+
// has one, is that host.
|
|
295
|
+
if ((await readSites()).some((site) => siteUserSurfaceRoute(site) === route)) {
|
|
296
|
+
throw new TunnelError(
|
|
297
|
+
`Plugin "${plugin.name}" has a web surface — its user surface is served there`,
|
|
298
|
+
'INVALID_SETTING',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Whether an installed agent-hosted plugin holds `route`. */
|
|
304
|
+
async function isBindableRoute(route) {
|
|
305
|
+
return assertBindableRoute(route).then(
|
|
306
|
+
() => true,
|
|
307
|
+
() => false,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Bind plugin `route` as a user surface of tunnel `id`. The route must be
|
|
313
|
+
* held by an installed agent-hosted plugin — whether its installed version
|
|
314
|
+
* declares `identity` yet or not — hosted by the agent that carries the
|
|
315
|
+
* tunnel (core refuses with 400 otherwise). Idempotent. Throws a core
|
|
316
|
+
* `TunnelError` (or an `httpError`) to refuse.
|
|
317
|
+
*
|
|
318
|
+
* The plugin is checked under the tunnel lock, but its uninstall does not
|
|
319
|
+
* take that lock: should it be uninstalled while the binding is written,
|
|
320
|
+
* the binding is released again here (and by the uninstall's own release,
|
|
321
|
+
* which runs after its registry entry is gone) and the bind refused with
|
|
322
|
+
* 409 — no binding outlives the plugin it was made for.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} id
|
|
325
|
+
* @param {string} route
|
|
326
|
+
* @param {{ actor: string, logger: import('pino').Logger }} opts
|
|
327
|
+
* @returns {Promise<{ changed: boolean, tunnel: object }>} the tunnel with its `userSurfaceStatus`
|
|
328
|
+
*/
|
|
329
|
+
export async function bindTunnelUserSurface(id, route, { actor, logger }) {
|
|
330
|
+
const result = await bindUserSurface({
|
|
331
|
+
...bindingOptions(id, route, logger),
|
|
332
|
+
assertRoute: assertBindableRoute,
|
|
333
|
+
});
|
|
334
|
+
if (result.changed && !(await isBindableRoute(route))) {
|
|
335
|
+
await pruneUserSurfaceBindings(route, { plugin: null, logger });
|
|
336
|
+
throw httpError(
|
|
337
|
+
`The plugin holding the route '${route}' was uninstalled while it was being bound — nothing is bound`,
|
|
338
|
+
409,
|
|
339
|
+
{ code: 'user_surface_conflict' },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (result.changed) {
|
|
343
|
+
await auditBinding('tunnel.user-surface.bound', result.tunnel, route, { actor, logger });
|
|
344
|
+
}
|
|
345
|
+
return { changed: result.changed, tunnel: await withUserSurfaceStatus(result.tunnel) };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Unbind plugin `route` from tunnel `id`: requests for the route go down
|
|
350
|
+
* the tunnel again. Throws a core `TunnelError` (`NOT_FOUND` when it is not
|
|
351
|
+
* bound there) or an `httpError`.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} id
|
|
354
|
+
* @param {string} route
|
|
355
|
+
* @param {{ actor: string, logger: import('pino').Logger }} opts
|
|
356
|
+
* @returns {Promise<{ tunnel: object }>} the tunnel with its `userSurfaceStatus`
|
|
357
|
+
*/
|
|
358
|
+
export async function unbindTunnelUserSurface(id, route, { actor, logger }) {
|
|
359
|
+
const result = await unbindUserSurface(bindingOptions(id, route, logger));
|
|
360
|
+
await auditBinding('tunnel.user-surface.unbound', result.tunnel, route, {
|
|
361
|
+
actor,
|
|
362
|
+
logger,
|
|
363
|
+
reason: 'unbound',
|
|
364
|
+
});
|
|
365
|
+
return { tunnel: await withUserSurfaceStatus(result.tunnel) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* A deleted tunnel's bindings went with it: audit each.
|
|
370
|
+
*
|
|
371
|
+
* @param {object} tunnel the removed entry
|
|
372
|
+
* @param {{ actor: string, logger: import('pino').Logger }} opts
|
|
373
|
+
*/
|
|
374
|
+
export async function auditDeletedTunnelUserSurface(tunnel, opts) {
|
|
375
|
+
for (const route of tunnelUserSurfaceRoutes(tunnel)) {
|
|
376
|
+
await auditBinding('tunnel.user-surface.unbound', tunnel, route, {
|
|
377
|
+
...opts,
|
|
378
|
+
reason: 'tunnel-deleted',
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Following the plugins
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
/** Replaces the reconcile's nginx and state access (tests: no nginx to read or write). */
|
|
388
|
+
let reconcileDepsOverride = null;
|
|
389
|
+
|
|
390
|
+
/** @param {object | null} deps the deps of `reconcileUserSurfaceVhosts` */
|
|
391
|
+
export function setUserSurfaceReconcileDepsForTests(deps) {
|
|
392
|
+
reconcileDepsOverride = deps;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function reconcileVhostsOf(route, ids, logger) {
|
|
396
|
+
const { reconcileUserSurfaceVhosts } = await import('./vhost-reconcile.js');
|
|
397
|
+
// `undefined` leaves the reconcile its own deps.
|
|
398
|
+
return reconcileUserSurfaceVhosts(logger, route, { ids }, reconcileDepsOverride ?? undefined);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Tunnels `ids` lost their agent (its certificate was revoked: core
|
|
403
|
+
* `releaseAgentTunnels`): those bound as a user surface keep the binding
|
|
404
|
+
* but serve none of it — Gatekeeper and the panel refuse from the state
|
|
405
|
+
* already — and their vhosts lose its locations here. Best effort —
|
|
406
|
+
* logged, never thrown; the panel's start reconciles every vhost again.
|
|
407
|
+
*
|
|
408
|
+
* @param {readonly string[]} ids
|
|
409
|
+
* @param {import('pino').Logger} logger
|
|
410
|
+
*/
|
|
411
|
+
export async function refreshTunnelUserSurfaceVhosts(ids, logger) {
|
|
412
|
+
if (!ids || ids.length === 0) return;
|
|
413
|
+
try {
|
|
414
|
+
await reconcileVhostsOf(null, ids, logger);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
logger?.warn?.(
|
|
417
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
418
|
+
'Could not bring the vhosts of released tunnels in line',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Whether plugin `route`'s user surface is served may have changed — its
|
|
425
|
+
* version was approved, updated or reverted, it was hosted or unhosted (the
|
|
426
|
+
* body limit is its internal tunnel's): bring the vhosts of the tunnels it
|
|
427
|
+
* is bound to in line, and its web surface — the approved tree served, the
|
|
428
|
+
* vhost of the site it owns (`lib/web-surface.js`). Best effort — logged, never thrown; the panel's
|
|
429
|
+
* start reconciles every vhost again.
|
|
430
|
+
*
|
|
431
|
+
* @param {string | null | undefined} route
|
|
432
|
+
* @param {import('pino').Logger} logger
|
|
433
|
+
*/
|
|
434
|
+
export async function refreshUserSurfaceVhosts(route, logger) {
|
|
435
|
+
if (!route) return;
|
|
436
|
+
try {
|
|
437
|
+
await reconcileVhostsOf(route, [], logger);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
logger?.warn?.(
|
|
440
|
+
{ route, err: err instanceof Error ? err.message : String(err) },
|
|
441
|
+
'Could not bring the user-surface vhosts of a plugin in line',
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
// Its web surface too: the approved tree served, the site's vhost in line.
|
|
445
|
+
const { refreshWebSurface } = await import('./web-surface.js');
|
|
446
|
+
await refreshWebSurface(route, logger);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Release `route`'s binding from every tunnel — and, as a second check, the
|
|
451
|
+
* binding of every other route no installed agent-hosted plugin holds any
|
|
452
|
+
* more — the state first, so Gatekeeper and the panel refuse at once, then
|
|
453
|
+
* those tunnels' vhosts lose the locations. Audited per route and tunnel
|
|
454
|
+
* (`plugin-uninstalled`). Throws.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} route
|
|
457
|
+
* @param {{ plugin: string | null, logger: import('pino').Logger }} opts
|
|
458
|
+
*/
|
|
459
|
+
async function pruneUserSurfaceBindings(route, { plugin, logger }) {
|
|
460
|
+
const ids = new Set();
|
|
461
|
+
const release = async (r, owner) => {
|
|
462
|
+
const { released } = await releaseUserSurfaceRoute({
|
|
463
|
+
route: r,
|
|
464
|
+
state: tunnelStateDeps,
|
|
465
|
+
logger,
|
|
466
|
+
});
|
|
467
|
+
for (const tunnel of released) {
|
|
468
|
+
ids.add(tunnel.id);
|
|
469
|
+
await auditBinding('tunnel.user-surface.unbound', tunnel, r, {
|
|
470
|
+
actor: 'lamaste',
|
|
471
|
+
logger,
|
|
472
|
+
...(owner ? { plugin: owner } : {}),
|
|
473
|
+
reason: 'plugin-uninstalled',
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
await release(route, plugin);
|
|
478
|
+
const orphans = new Set();
|
|
479
|
+
for (const tunnel of await readTunnels()) {
|
|
480
|
+
for (const r of tunnelUserSurfaceRoutes(tunnel)) {
|
|
481
|
+
if (r !== route && !(await isBindableRoute(r))) orphans.add(r);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const r of orphans) await release(r, null);
|
|
485
|
+
if (ids.size > 0) await reconcileVhostsOf(route, [...ids], logger);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Plugin `plugin`, which held `route`, is uninstalled — its registry entry
|
|
490
|
+
* is gone: the route's binding goes from every tunnel, and so does any
|
|
491
|
+
* binding left for a route no installed plugin holds (a bind that raced an
|
|
492
|
+
* uninstall) — the state first, so Gatekeeper and the panel refuse the
|
|
493
|
+
* route at once — then those tunnels' vhosts lose its locations. Best
|
|
494
|
+
* effort — logged, never thrown: the uninstall happened.
|
|
495
|
+
*
|
|
496
|
+
* @param {string | null | undefined} route
|
|
497
|
+
* @param {{ plugin: string, logger: import('pino').Logger }} opts
|
|
498
|
+
*/
|
|
499
|
+
export async function releasePluginUserSurface(route, { plugin, logger }) {
|
|
500
|
+
if (!route) return;
|
|
501
|
+
try {
|
|
502
|
+
await pruneUserSurfaceBindings(route, { plugin, logger });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
logger?.error?.(
|
|
505
|
+
{ route, err: err instanceof Error ? err.message : String(err) },
|
|
506
|
+
'Could not release the user surface of an uninstalled plugin',
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
// The site it owns stays, answering 503 until the plugin is back (or an
|
|
510
|
+
// administrator deletes it): its vhost goes in line.
|
|
511
|
+
const { refreshWebSurface } = await import('./web-surface.js');
|
|
512
|
+
await refreshWebSurface(route, logger);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// Requests
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
/** One per process: a relay assertion is accepted once. */
|
|
520
|
+
const relayNonces = new NonceStore();
|
|
521
|
+
|
|
522
|
+
/** The verification key of the relay signing key, by key id (the current key only). */
|
|
523
|
+
const verifyKeys = new Map();
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The keys a relay assertion for the panel verifies with: the public half of
|
|
527
|
+
* the relay signing key — the file Gatekeeper signs with — re-read when it
|
|
528
|
+
* changes. Null when there is none; throws when it is unusable.
|
|
529
|
+
*/
|
|
530
|
+
async function panelVerifyKeys() {
|
|
531
|
+
const key = await relaySigner.current();
|
|
532
|
+
if (!key) return null;
|
|
533
|
+
if (!verifyKeys.has(key.kid)) {
|
|
534
|
+
verifyKeys.clear();
|
|
535
|
+
verifyKeys.set(key.kid, crypto.createPublicKey(key.privateKey));
|
|
536
|
+
}
|
|
537
|
+
return verifyKeys;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The relay check of a user-surface request (core `userSurfacePlugin`'s
|
|
542
|
+
* `verifyRelay`): an assertion Gatekeeper signed for the panel
|
|
543
|
+
* (`RELAY_PANEL_AUDIENCE`, the panel's port), bound to this request's
|
|
544
|
+
* method and raw request target, not expired, never seen before. Returns
|
|
545
|
+
* the username Gatekeeper attested and the route the assertion is for.
|
|
546
|
+
*
|
|
547
|
+
* @param {import('fastify').FastifyRequest} request
|
|
548
|
+
* @returns {Promise<{ ok: true, username: string, route: string } | { ok: false, reason: string }>}
|
|
549
|
+
*/
|
|
550
|
+
export async function verifyUserSurfaceRelay(request) {
|
|
551
|
+
let keys;
|
|
552
|
+
try {
|
|
553
|
+
keys = await panelVerifyKeys();
|
|
554
|
+
} catch (err) {
|
|
555
|
+
request.log.error(
|
|
556
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
557
|
+
'Relay signing key unusable — user-surface requests are refused',
|
|
558
|
+
);
|
|
559
|
+
return { ok: false, reason: 'relay_key_unusable' };
|
|
560
|
+
}
|
|
561
|
+
if (!keys) return { ok: false, reason: 'relay_key_missing' };
|
|
562
|
+
const header = request.headers[RELAY_ASSERTION_HEADER];
|
|
563
|
+
const result = verifyRelayAssertion(typeof header === 'string' ? header : undefined, {
|
|
564
|
+
keys,
|
|
565
|
+
aud: RELAY_PANEL_AUDIENCE,
|
|
566
|
+
port: RELAY_PANEL_PORT,
|
|
567
|
+
method: request.method,
|
|
568
|
+
url: request.raw.url ?? '',
|
|
569
|
+
nonces: relayNonces,
|
|
570
|
+
});
|
|
571
|
+
if (!result.ok) {
|
|
572
|
+
request.log.info({ reason: result.reason }, 'User-surface request without a valid assertion');
|
|
573
|
+
return { ok: false, reason: result.reason };
|
|
574
|
+
}
|
|
575
|
+
const { caller, target } = result.payload;
|
|
576
|
+
// A panel-audience payload is a username and a route with its path.
|
|
577
|
+
if (caller.type !== 'user' || !('route' in target)) return { ok: false, reason: 'malformed' };
|
|
578
|
+
return { ok: true, username: caller.username, route: target.route };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The enabled host that serves `route`'s user surface, or null — the check
|
|
583
|
+
* of every request, whatever a vhost not yet re-rendered still routes here:
|
|
584
|
+
* a tunnel bound to the route and carried by the agent hosting its plugin,
|
|
585
|
+
* or the site the plugin owns while its web surface is served
|
|
586
|
+
* (`lib/web-surface.js`). Read with core `userSurfaceHosts`, as Gatekeeper
|
|
587
|
+
* reads it, so a route two hosts would serve is served by neither.
|
|
588
|
+
*
|
|
589
|
+
* @param {string} route
|
|
590
|
+
* @returns {Promise<import('@lamalibre/lamaste').UserSurfaceHost | null>}
|
|
591
|
+
*/
|
|
592
|
+
async function servedUserSurfaceHost(route) {
|
|
593
|
+
const [tunnels, sites] = await Promise.all([readTunnels(), readSites()]);
|
|
594
|
+
const host = userSurfaceHostServing(userSurfaceHosts(tunnels, sites), route);
|
|
595
|
+
if (!host) return null;
|
|
596
|
+
if (host.kind === 'site') {
|
|
597
|
+
const { webSurfaceServing } = await import('./web-surface.js');
|
|
598
|
+
return (await webSurfaceServing(route, tunnels)).served ? host : null;
|
|
599
|
+
}
|
|
600
|
+
const plugin = await findPluginByRoute(route);
|
|
601
|
+
if (!plugin) return null;
|
|
602
|
+
const tunnel = tunnels.find((t) => t.id === host.id);
|
|
603
|
+
const hosting = await hostingAgentOf(plugin, tunnels);
|
|
604
|
+
return hosting !== null && tunnel?.agentLabel === hosting ? host : null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The forward target of `route`'s user surface (core `userSurfacePlugin`'s
|
|
609
|
+
* `resolveTarget`): null when no host serves it (see
|
|
610
|
+
* {@link servedUserSurfaceHost}), else what the admin forwarder resolves — the hosting's
|
|
611
|
+
* internal tunnel, or why not (not found, disabled, not hosted) — and
|
|
612
|
+
* `down` while the hosting's health is down: nothing of a user's request
|
|
613
|
+
* reaches the relay-loopback port then (503).
|
|
614
|
+
*
|
|
615
|
+
* @param {string} route
|
|
616
|
+
*/
|
|
617
|
+
export async function resolveUserSurfaceTarget(route) {
|
|
618
|
+
if (!(await servedUserSurfaceHost(route))) return null;
|
|
619
|
+
const { resolveForwardTarget } = await import('./plugin-hosting.js');
|
|
620
|
+
return resolveForwardTarget(route);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The origin a request that may change something on `route`'s user surface
|
|
625
|
+
* must come from (core `userSurfacePlugin`'s `originFor`): the bound host,
|
|
626
|
+
* as the state names it — never the request's `Host`. Null when no host
|
|
627
|
+
* serves the route.
|
|
628
|
+
*
|
|
629
|
+
* @param {string} route
|
|
630
|
+
* @returns {Promise<string | null>}
|
|
631
|
+
*/
|
|
632
|
+
export async function userSurfaceOrigin(route) {
|
|
633
|
+
const host = await servedUserSurfaceHost(route);
|
|
634
|
+
return host ? `https://${host.fqdn}` : null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// Open responses: none outlives its user's access
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
/** How often every open user-surface response is re-checked. */
|
|
642
|
+
const OPEN_RESPONSE_SWEEP_INTERVAL_MS = 5_000;
|
|
643
|
+
|
|
644
|
+
/** The responses being sent now (core `OpenUserSurfaceResponse`). */
|
|
645
|
+
const openResponses = new Set();
|
|
646
|
+
|
|
647
|
+
let sweepTimer = null;
|
|
648
|
+
let sweeping = null;
|
|
649
|
+
let sweepLogger = null;
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Core `userSurfacePlugin`'s `trackResponse`, logging with `logger`: keep
|
|
653
|
+
* each response until it is over (the function it returns), and re-check it
|
|
654
|
+
* every {@link OPEN_RESPONSE_SWEEP_INTERVAL_MS} meanwhile. The timer runs
|
|
655
|
+
* only while a response is open, and never keeps the process alive.
|
|
656
|
+
*
|
|
657
|
+
* @param {import('pino').Logger} logger
|
|
658
|
+
* @returns {(response: import('@lamalibre/lamaste/server').OpenUserSurfaceResponse) => () => void}
|
|
659
|
+
*/
|
|
660
|
+
export function userSurfaceResponseTracker(logger) {
|
|
661
|
+
sweepLogger = logger;
|
|
662
|
+
return trackUserSurfaceResponse;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Keep `response` until it is over (the returned function) — see
|
|
667
|
+
* {@link userSurfaceResponseTracker}.
|
|
668
|
+
*
|
|
669
|
+
* @param {import('@lamalibre/lamaste/server').OpenUserSurfaceResponse} response
|
|
670
|
+
* @returns {() => void}
|
|
671
|
+
*/
|
|
672
|
+
export function trackUserSurfaceResponse(response) {
|
|
673
|
+
openResponses.add(response);
|
|
674
|
+
if (!sweepTimer) {
|
|
675
|
+
sweepTimer = setInterval(() => {
|
|
676
|
+
void sweepOpenUserSurfaceResponses(sweepLogger ?? undefined);
|
|
677
|
+
}, OPEN_RESPONSE_SWEEP_INTERVAL_MS);
|
|
678
|
+
sweepTimer.unref();
|
|
679
|
+
}
|
|
680
|
+
return () => {
|
|
681
|
+
openResponses.delete(response);
|
|
682
|
+
if (openResponses.size === 0 && sweepTimer) {
|
|
683
|
+
clearInterval(sweepTimer);
|
|
684
|
+
sweepTimer = null;
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** How many user-surface responses are open now (tests, diagnostics). */
|
|
690
|
+
export function openUserSurfaceResponseCount() {
|
|
691
|
+
return openResponses.size;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function sameRoles(a, b) {
|
|
695
|
+
if (a.length !== b.length) return false;
|
|
696
|
+
const set = new Set(a);
|
|
697
|
+
return b.every((role) => set.has(role));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Re-check every open user-surface response and end those that may not go
|
|
702
|
+
* on: its user is no longer allowed on the plugin (`lib/user-caller.js`:
|
|
703
|
+
* disabled, removed, no longer entitled, the plugin not ready — or another
|
|
704
|
+
* user now holds the username) — `access_revoked`; holds other roles than
|
|
705
|
+
* the response was started with — `roles_changed`; or the route is no
|
|
706
|
+
* longer served on its host (unbound, the tunnel disabled or moved, the
|
|
707
|
+
* plugin unhosted) — `surface_withdrawn`. Each user and route is
|
|
708
|
+
* resolved once per sweep. One sweep at a time; a failed check keeps the
|
|
709
|
+
* response (the next sweep tries again) and is logged by the request's own
|
|
710
|
+
* line when it ends. Returns how many it ended.
|
|
711
|
+
*
|
|
712
|
+
* @param {import('pino').Logger} [logger]
|
|
713
|
+
* @returns {Promise<number>}
|
|
714
|
+
*/
|
|
715
|
+
export async function sweepOpenUserSurfaceResponses(logger) {
|
|
716
|
+
if (sweeping) return sweeping;
|
|
717
|
+
sweeping = (async () => {
|
|
718
|
+
const { resolveUserCaller } = await import('./user-caller.js');
|
|
719
|
+
const callers = new Map();
|
|
720
|
+
const served = new Map();
|
|
721
|
+
let ended = 0;
|
|
722
|
+
for (const response of [...openResponses]) {
|
|
723
|
+
const { route, caller } = response;
|
|
724
|
+
let verdict;
|
|
725
|
+
try {
|
|
726
|
+
if (!served.has(route)) served.set(route, (await servedUserSurfaceHost(route)) !== null);
|
|
727
|
+
const key = `${route}\n${caller.username}`;
|
|
728
|
+
if (!callers.has(key)) callers.set(key, await resolveUserCaller(route, caller.username));
|
|
729
|
+
const resolution = callers.get(key);
|
|
730
|
+
if (!resolution.ok || resolution.caller.userId !== caller.userId) {
|
|
731
|
+
verdict = 'access_revoked';
|
|
732
|
+
} else if (!sameRoles(resolution.caller.roles, caller.roles)) {
|
|
733
|
+
verdict = 'roles_changed';
|
|
734
|
+
} else if (!served.get(route)) verdict = 'surface_withdrawn';
|
|
735
|
+
} catch (err) {
|
|
736
|
+
logger?.warn?.(
|
|
737
|
+
{ route, err: err instanceof Error ? err.message : String(err) },
|
|
738
|
+
'Could not re-check an open user-surface response',
|
|
739
|
+
);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (verdict) {
|
|
743
|
+
response.end(verdict);
|
|
744
|
+
ended += 1;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return ended;
|
|
748
|
+
})();
|
|
749
|
+
try {
|
|
750
|
+
return await sweeping;
|
|
751
|
+
} finally {
|
|
752
|
+
sweeping = null;
|
|
753
|
+
}
|
|
754
|
+
}
|