@lamalibre/create-lamaste 4.5.0 → 4.7.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/bundle-vendor.js +20 -3
- package/scripts/lamaste-priv +56 -5
- package/scripts/pin-release.js +98 -0
- package/src/index.js +8 -1
- package/src/lib/env.js +19 -0
- package/src/lib/service-config.js +7 -0
- package/src/tasks/redeploy.js +8 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +6 -2
- package/vendor/docs/01-concepts/authorization.md +4 -0
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +28 -3
- 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 +5 -11
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/second-domain.md +82 -0
- 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 +80 -12
- 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 +3 -3
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/domains.md +192 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
- 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/overview.md +9 -0
- package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
- package/vendor/docs/04-api-reference/sites.md +9 -0
- package/vendor/docs/04-api-reference/tunnels.md +35 -28
- package/vendor/docs/04-api-reference/users.md +41 -29
- 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/_index.json +105 -19
- package/vendor/docs/external-members-design.md +816 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +3 -3
- package/vendor/docs/multi-domain-relay-design.md +836 -0
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- 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/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +20 -13
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/secret.js +12 -0
- package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/templates.js +80 -14
- package/vendor/gatekeeper/dist/lib/templates.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 +6 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +56 -11
- 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 +325 -55
- 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 +5 -2
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +7 -0
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/domains.js +214 -0
- package/vendor/server/src/commands/plugin-identity.js +2 -16
- package/vendor/server/src/commands/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +175 -14
- package/vendor/server/src/commands/registry-credential.js +10 -3
- package/vendor/server/src/commands/sites.js +18 -1
- package/vendor/server/src/commands/status.js +19 -0
- package/vendor/server/src/commands/tunnels.js +4 -1
- package/vendor/server/src/commands/users.js +8 -19
- package/vendor/server/src/domains.js +92 -0
- package/vendor/server/src/fail.js +21 -0
- package/vendor/server/src/oidc-client-grants.js +63 -0
- package/vendor/server/src/panel-api.js +15 -0
- 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-B8Tv_ocF.js +84 -0
- package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +50 -19
- package/vendor/serverd/src/lib/app-error.js +24 -0
- package/vendor/serverd/src/lib/audience.js +12 -3
- package/vendor/serverd/src/lib/authelia.js +341 -35
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- package/vendor/serverd/src/lib/directory.js +67 -52
- package/vendor/serverd/src/lib/domains.js +1792 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
- package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
- package/vendor/serverd/src/lib/identity-sync.js +35 -0
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
- package/vendor/serverd/src/lib/nginx.js +576 -131
- package/vendor/serverd/src/lib/owned-groups.js +326 -0
- package/vendor/serverd/src/lib/plugin-domain.js +86 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
- package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
- package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +82 -9
- package/vendor/serverd/src/lib/registry-credential.js +39 -6
- package/vendor/serverd/src/lib/services.js +9 -1
- package/vendor/serverd/src/lib/state.js +29 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
- package/vendor/serverd/src/lib/user-surface.js +77 -31
- package/vendor/serverd/src/lib/users-lock.js +23 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
- package/vendor/serverd/src/lib/web-surface.js +783 -0
- package/vendor/serverd/src/middleware/user-access-session.js +11 -4
- package/vendor/serverd/src/routes/invite.js +15 -5
- package/vendor/serverd/src/routes/management/domains.js +154 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
- package/vendor/serverd/src/routes/management/identity.js +21 -7
- package/vendor/serverd/src/routes/management/invitations.js +33 -4
- package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +6 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
- package/vendor/serverd/src/routes/management/sites.js +188 -71
- package/vendor/serverd/src/routes/management/tunnels.js +37 -22
- package/vendor/serverd/src/routes/management/users.js +184 -64
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
- package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
- package/vendor/serverd/src/routes/user-access.js +13 -1
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugins' web surfaces on the relay (slice 7): the static shell an
|
|
3
|
+
* agent-hosted plugin ships in its server package (`userSurface.web`),
|
|
4
|
+
* served on a site the plugin owns beside its user surface's
|
|
5
|
+
* `/api/<route>/app/*` — one host, one Authelia session.
|
|
6
|
+
*
|
|
7
|
+
* **Trees.** The relay serves only the approved web tree of the installed
|
|
8
|
+
* version: once that version is approved (and after every update, revert or
|
|
9
|
+
* restart) its tree is copied out of the installed package into the store
|
|
10
|
+
* (core `stageWebTree`, checked against the approved digest) and switched
|
|
11
|
+
* to (core `switchWebTree`: `current` and `previous` replaced by a rename)
|
|
12
|
+
* — {@link syncWebSurfaceTree}. Until then the store keeps serving what it
|
|
13
|
+
* served.
|
|
14
|
+
*
|
|
15
|
+
* **The site.** A plugin owns at most one site (`owner: { plugin }` in
|
|
16
|
+
* sites.json), created on its declared subdomain once an administrator
|
|
17
|
+
* confirms ({@link createWebSurface}) or taken over from the tunnel bound
|
|
18
|
+
* as its user surface ({@link moveWebSurfaceFromTunnel}), and deleted
|
|
19
|
+
* ({@link deleteWebSurface}). Its files are never uploaded: the files API
|
|
20
|
+
* and `lamaste-agent deploy` refuse it. Its vhost serves the shell and the
|
|
21
|
+
* API while the surface is served ({@link webSurfaceServing}: the installed
|
|
22
|
+
* version approved, declaring identity and a web surface, hosted, its tree
|
|
23
|
+
* current) and answers 503 otherwise; {@link refreshWebSurface} brings it
|
|
24
|
+
* in line whenever that may have changed.
|
|
25
|
+
*
|
|
26
|
+
* **Locks.** Giving a site a hostname takes core's tunnel lock, then the
|
|
27
|
+
* sites lock (`withSitesLock`), as every such workflow does.
|
|
28
|
+
*
|
|
29
|
+
* Audit: `plugin.web-surface.created`, `.deleted`, `.moved`,
|
|
30
|
+
* `plugin.web-tree.switched`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import crypto from 'node:crypto';
|
|
34
|
+
import {
|
|
35
|
+
KeyedPromiseChainMutex,
|
|
36
|
+
isWebSurfaceSubdomain,
|
|
37
|
+
siteUserSurfaceRoute,
|
|
38
|
+
tunnelUserSurfaceRoutes,
|
|
39
|
+
WebTreeError,
|
|
40
|
+
} from '@lamalibre/lamaste';
|
|
41
|
+
import {
|
|
42
|
+
TUNNEL_MAX_BODY_MB,
|
|
43
|
+
WEB_TREES_ROOT,
|
|
44
|
+
hostnameHolder,
|
|
45
|
+
readWebTreeStore,
|
|
46
|
+
removeWebTreeStore,
|
|
47
|
+
stageWebTree,
|
|
48
|
+
switchWebTree,
|
|
49
|
+
tunnelHost,
|
|
50
|
+
webTreeStoreDir,
|
|
51
|
+
withTunnelLock,
|
|
52
|
+
} from '@lamalibre/lamaste/server';
|
|
53
|
+
import { httpError } from './app-error.js';
|
|
54
|
+
import { recordAuditBestEffort } from './audit.js';
|
|
55
|
+
import { getCertPath, issueTunnelCert } from './certbot.js';
|
|
56
|
+
import { syncChisel } from './chisel.js';
|
|
57
|
+
import { getConfig } from './config.js';
|
|
58
|
+
import { domainOfHostname, pluginDomainName } from './domains.js';
|
|
59
|
+
import {
|
|
60
|
+
createGatekeeperGrant,
|
|
61
|
+
listResourceGrants,
|
|
62
|
+
revokeGatekeeperGrant,
|
|
63
|
+
} from './gatekeeper-client.js';
|
|
64
|
+
import {
|
|
65
|
+
appVhostName,
|
|
66
|
+
installVhost,
|
|
67
|
+
moveAppVhostToWebSurface,
|
|
68
|
+
readInstalledVhost,
|
|
69
|
+
reload as reloadNginx,
|
|
70
|
+
removeStaticSiteVhost,
|
|
71
|
+
renderWebSurfaceVhost,
|
|
72
|
+
unlinkMovedAppVhost,
|
|
73
|
+
vhostCertDir,
|
|
74
|
+
} from './nginx.js';
|
|
75
|
+
import { installedVersionApproved } from './plugin-hooks.js';
|
|
76
|
+
import { installedWebTreeDir } from './plugin-packages.js';
|
|
77
|
+
import { findPluginByRoute } from './plugins.js';
|
|
78
|
+
import { readSites, readTunnels, withSitesLock, writeSites, writeTunnels } from './state.js';
|
|
79
|
+
import { hostingAgentOf, internalTunnelBodyLimit } from './user-surface.js';
|
|
80
|
+
|
|
81
|
+
/** Where the web trees live (a test points it elsewhere). */
|
|
82
|
+
const TREES_ROOT = process.env.LAMALIBRE_LAMASTE_WEB_TREES_ROOT || WEB_TREES_ROOT;
|
|
83
|
+
|
|
84
|
+
/** The tree store of plugin `route`. */
|
|
85
|
+
export function webTreesDir(route) {
|
|
86
|
+
return webTreeStoreDir(route, TREES_ROOT);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What the workflows below do to nginx, certificates and chisel (tests replace them). */
|
|
90
|
+
const defaultDeps = Object.freeze({
|
|
91
|
+
getCertPath,
|
|
92
|
+
issueTunnelCert,
|
|
93
|
+
installVhost,
|
|
94
|
+
moveAppVhostToWebSurface,
|
|
95
|
+
readInstalledVhost,
|
|
96
|
+
reloadNginx,
|
|
97
|
+
removeStaticSiteVhost,
|
|
98
|
+
syncChisel,
|
|
99
|
+
unlinkMovedAppVhost,
|
|
100
|
+
});
|
|
101
|
+
let deps = defaultDeps;
|
|
102
|
+
|
|
103
|
+
/** @param {Partial<typeof defaultDeps> | null} override */
|
|
104
|
+
export function setWebSurfaceDepsForTests(override) {
|
|
105
|
+
deps = override ? { ...defaultDeps, ...override } : defaultDeps;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const siteVhostName = (site) => `lamalibre-lamaste-site-${site.id}`;
|
|
109
|
+
|
|
110
|
+
/** One change of a plugin's tree store at a time (a copy, a switch, a removal). */
|
|
111
|
+
const treeMutex = new KeyedPromiseChainMutex();
|
|
112
|
+
|
|
113
|
+
/** The site plugin `route` owns among `sites`, or null. */
|
|
114
|
+
export function ownedSiteOf(route, sites) {
|
|
115
|
+
return sites.find((site) => siteUserSurfaceRoute(site) === route) ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether `site` was created for the installed plugin `plugin` — its route
|
|
120
|
+
* and its package. A site outlives its plugin's uninstall; a different
|
|
121
|
+
* package installed later under the same route does not inherit it (its
|
|
122
|
+
* hostname, its grants, the old tree) — an administrator deletes it first.
|
|
123
|
+
*/
|
|
124
|
+
function siteOwnedBy(site, plugin) {
|
|
125
|
+
return siteUserSurfaceRoute(site) === plugin.route && site.owner?.package === plugin.packageName;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function refuse(message, statusCode, code) {
|
|
129
|
+
return httpError(message, statusCode, { code });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Trees
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Serve the approved web tree of the version of plugin `route` installed
|
|
138
|
+
* now: copy it out of the installed package into the store when it is not
|
|
139
|
+
* there (checked against the approved digest), then switch `current` to
|
|
140
|
+
* it. The plugin is read inside the store's lock, so two refreshes never
|
|
141
|
+
* switch to an older version's tree. Nothing happens while the version
|
|
142
|
+
* declares no web surface or is not approved, or — unless `withoutSite`
|
|
143
|
+
* (a site being created for it) — while the plugin owns no site: the
|
|
144
|
+
* store keeps serving what it served, and a deleted store stays deleted.
|
|
145
|
+
* Returns whether `current` moved. Throws.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} route
|
|
148
|
+
* @param {{ logger?: import('pino').Logger, withoutSite?: boolean }} [opts]
|
|
149
|
+
*/
|
|
150
|
+
export async function syncWebSurfaceTree(route, { logger, withoutSite = false } = {}) {
|
|
151
|
+
const switched = await treeMutex.run(route, async () => {
|
|
152
|
+
const plugin = await findPluginByRoute(route);
|
|
153
|
+
if (!plugin?.userSurface?.web || !plugin.webTreeSha256) return null;
|
|
154
|
+
if (!withoutSite) {
|
|
155
|
+
const site = ownedSiteOf(route, await readSites());
|
|
156
|
+
if (!site || !siteOwnedBy(site, plugin)) return null;
|
|
157
|
+
}
|
|
158
|
+
if (!(await installedVersionApproved(plugin).catch(() => false))) return null;
|
|
159
|
+
const dir = webTreesDir(route);
|
|
160
|
+
const source = await installedWebTreeDir(plugin.packageName, plugin);
|
|
161
|
+
await stageWebTree(dir, source, plugin.webTreeSha256);
|
|
162
|
+
return (await switchWebTree(dir, plugin.webTreeSha256)) ? plugin : null;
|
|
163
|
+
});
|
|
164
|
+
if (switched) {
|
|
165
|
+
await recordAuditBestEffort(
|
|
166
|
+
{
|
|
167
|
+
actor: 'lamaste',
|
|
168
|
+
action: 'plugin.web-tree.switched',
|
|
169
|
+
plugin: switched.name,
|
|
170
|
+
details: { version: switched.version, treeSha256: switched.webTreeSha256 },
|
|
171
|
+
},
|
|
172
|
+
logger,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return switched !== null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Serving
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether plugin `route`'s web surface is served — its shell and API on the
|
|
184
|
+
* site it owns — and why not: `not_installed`, `no_identity`,
|
|
185
|
+
* `no_web_surface` (its installed version declares none), `not_owner` (its
|
|
186
|
+
* route's site was created for another package), `not_approved`,
|
|
187
|
+
* `not_hosted`, `tree_not_current` (the approved tree is not the one the
|
|
188
|
+
* store serves yet).
|
|
189
|
+
*
|
|
190
|
+
* @param {string} route
|
|
191
|
+
* @param {readonly object[]} tunnels the tunnel state
|
|
192
|
+
* @returns {Promise<{ served: true, plugin: object } | { served: false, reason: string, plugin: object | null }>}
|
|
193
|
+
*/
|
|
194
|
+
export async function webSurfaceServing(route, tunnels) {
|
|
195
|
+
const plugin = await findPluginByRoute(route);
|
|
196
|
+
if (!plugin) return { served: false, reason: 'not_installed', plugin: null };
|
|
197
|
+
if (!plugin.identity) return { served: false, reason: 'no_identity', plugin };
|
|
198
|
+
if (!plugin.userSurface?.web) return { served: false, reason: 'no_web_surface', plugin };
|
|
199
|
+
const site = ownedSiteOf(route, await readSites());
|
|
200
|
+
if (site && !siteOwnedBy(site, plugin)) return { served: false, reason: 'not_owner', plugin };
|
|
201
|
+
if (!(await installedVersionApproved(plugin).catch(() => false))) {
|
|
202
|
+
return { served: false, reason: 'not_approved', plugin };
|
|
203
|
+
}
|
|
204
|
+
if ((await hostingAgentOf(plugin, tunnels)) === null) {
|
|
205
|
+
return { served: false, reason: 'not_hosted', plugin };
|
|
206
|
+
}
|
|
207
|
+
const { current } = await readWebTreeStore(webTreesDir(route));
|
|
208
|
+
if (current !== plugin.webTreeSha256) {
|
|
209
|
+
return { served: false, reason: 'tree_not_current', plugin };
|
|
210
|
+
}
|
|
211
|
+
return { served: true, plugin };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The active domain a plugin's site is under — always one: it is created
|
|
216
|
+
* on its plugin's domain, and a domain a site uses cannot be removed.
|
|
217
|
+
*/
|
|
218
|
+
async function siteDomain(site) {
|
|
219
|
+
const domain = await domainOfHostname(site.fqdn);
|
|
220
|
+
if (domain === null) throw new Error(`${site.fqdn} is under none of the relay's active domains`);
|
|
221
|
+
return domain;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The vhost of the site `site` (owned by plugin `route`, under `domain`)
|
|
226
|
+
* as it should be now, on certificate directory `certDir`.
|
|
227
|
+
*/
|
|
228
|
+
async function renderOwnedSite(site, route, certDir, tunnels, domain) {
|
|
229
|
+
const serving = await webSurfaceServing(route, tunnels);
|
|
230
|
+
const maxBodySizeMb = serving.plugin
|
|
231
|
+
? await internalTunnelBodyLimit(serving.plugin, tunnels)
|
|
232
|
+
: undefined;
|
|
233
|
+
return renderWebSurfaceVhost(site, certDir, domain, {
|
|
234
|
+
route,
|
|
235
|
+
served: serving.served,
|
|
236
|
+
maxBodySizeMb: maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default,
|
|
237
|
+
treesDir: webTreesDir(route),
|
|
238
|
+
clearSiteData: !serving.served && serving.reason === 'not_installed',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Rewrite the vhost of the site plugin `route` owns when what it should be
|
|
244
|
+
* differs from what is installed (rendered on the certificate the installed
|
|
245
|
+
* vhost names). Returns whether it wrote.
|
|
246
|
+
*/
|
|
247
|
+
async function reconcileOwnedSiteVhost(site, route, tunnels) {
|
|
248
|
+
// A move stopped between its state writes and the handover leaves the
|
|
249
|
+
// tunnel's vhost linked beside the site's, on the same hostname.
|
|
250
|
+
const moved = tunnels.find((t) => t.fqdn === site.fqdn && t.enabled === false && t.subdomain);
|
|
251
|
+
const unlinked = moved ? await deps.unlinkMovedAppVhost(moved.fqdn) : false;
|
|
252
|
+
const installed = await deps.readInstalledVhost(siteVhostName(site));
|
|
253
|
+
const domain = await siteDomain(site);
|
|
254
|
+
const certDir =
|
|
255
|
+
(installed && vhostCertDir(installed)) || (await deps.getCertPath(site.fqdn, domain));
|
|
256
|
+
const { name, fqdn, config } = await renderOwnedSite(site, route, certDir, tunnels, domain);
|
|
257
|
+
if (installed === config) {
|
|
258
|
+
if (unlinked) await deps.reloadNginx();
|
|
259
|
+
return unlinked;
|
|
260
|
+
}
|
|
261
|
+
await deps.installVhost(name, config, fqdn, { enabled: true });
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Whether plugin `route`'s web surface is served may have changed — its
|
|
267
|
+
* version was approved, updated or reverted, it was hosted, unhosted or
|
|
268
|
+
* uninstalled: serve its approved tree, then bring its site's vhost in
|
|
269
|
+
* line. Best effort — logged, never thrown; the panel's start reconciles
|
|
270
|
+
* again.
|
|
271
|
+
*
|
|
272
|
+
* @param {string | null | undefined} route
|
|
273
|
+
* @param {import('pino').Logger} [logger]
|
|
274
|
+
*/
|
|
275
|
+
export async function refreshWebSurface(route, logger) {
|
|
276
|
+
if (!route) return;
|
|
277
|
+
try {
|
|
278
|
+
await syncWebSurfaceTree(route, { logger });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
logger?.error?.(
|
|
281
|
+
{ route, err: err instanceof Error ? err.message : String(err) },
|
|
282
|
+
'Could not serve the approved web tree of a plugin',
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
// Under the sites lock: a site deleted meanwhile never gets its vhost back.
|
|
287
|
+
await withSitesLock(async () => {
|
|
288
|
+
const site = ownedSiteOf(route, await readSites());
|
|
289
|
+
if (site) await reconcileOwnedSiteVhost(site, route, await readTunnels());
|
|
290
|
+
});
|
|
291
|
+
} catch (err) {
|
|
292
|
+
logger?.warn?.(
|
|
293
|
+
{ route, err: err instanceof Error ? err.message : String(err) },
|
|
294
|
+
'Could not bring the vhost of a web surface in line',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Every web surface at the panel's start: each plugin's approved tree
|
|
301
|
+
* served, each owned site's vhost in line. Logged, never thrown.
|
|
302
|
+
*
|
|
303
|
+
* @param {import('pino').Logger} logger
|
|
304
|
+
*/
|
|
305
|
+
export async function reconcileWebSurfaces(logger) {
|
|
306
|
+
const sites = await readSites().catch(() => []);
|
|
307
|
+
for (const site of sites) {
|
|
308
|
+
const route = siteUserSurfaceRoute(site);
|
|
309
|
+
if (route) await refreshWebSurface(route, logger);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Status
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Plugin `route`'s web surface as the panel shows it: what its installed
|
|
319
|
+
* version declares, the site it owns, whether it is served (and why not),
|
|
320
|
+
* the trees the store holds, and — when it owns no site — the subdomain
|
|
321
|
+
* Lamaste offers and whether it is free.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} route
|
|
324
|
+
*/
|
|
325
|
+
export async function webSurfaceStatus(route) {
|
|
326
|
+
const plugin = await findPluginByRoute(route);
|
|
327
|
+
const [sites, tunnels] = await Promise.all([readSites(), readTunnels()]);
|
|
328
|
+
const site = ownedSiteOf(route, sites);
|
|
329
|
+
// A site outlives its plugin: its status stays readable, so that it can
|
|
330
|
+
// be deleted.
|
|
331
|
+
if (!plugin && !site) {
|
|
332
|
+
throw refuse(`No installed plugin or web surface holds the route '${route}'`, 404, 'not_found');
|
|
333
|
+
}
|
|
334
|
+
const serving = await webSurfaceServing(route, tunnels);
|
|
335
|
+
const store = await readWebTreeStore(webTreesDir(route));
|
|
336
|
+
const declared = plugin?.userSurface?.web ?? null;
|
|
337
|
+
// Bound, enabled or not: creating a site is refused either way (a
|
|
338
|
+
// disabled one is unbound, or enabled and moved).
|
|
339
|
+
const boundTunnel = tunnels.find((t) => tunnelUserSurfaceRoutes(t).includes(route)) ?? null;
|
|
340
|
+
// While a tunnel is bound as its user surface, the way to a site is a
|
|
341
|
+
// move (creating one is refused): nothing is offered.
|
|
342
|
+
let offer = null;
|
|
343
|
+
if (!site && declared && !boundTunnel) {
|
|
344
|
+
const subdomain = declared.defaultSubdomain;
|
|
345
|
+
const domain = await pluginDomainName(plugin);
|
|
346
|
+
offer = {
|
|
347
|
+
subdomain,
|
|
348
|
+
domain,
|
|
349
|
+
accessMode: declared.accessMode,
|
|
350
|
+
available: (await hostnameTaken(subdomain, domain, sites, tunnels)) === null,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
declared,
|
|
355
|
+
site: site
|
|
356
|
+
? {
|
|
357
|
+
id: site.id,
|
|
358
|
+
name: site.name,
|
|
359
|
+
fqdn: site.fqdn,
|
|
360
|
+
accessMode: site.accessMode ?? null,
|
|
361
|
+
ownerPackage: site.owner?.package ?? null,
|
|
362
|
+
}
|
|
363
|
+
: null,
|
|
364
|
+
served: serving.served,
|
|
365
|
+
reason: serving.served ? null : serving.reason,
|
|
366
|
+
tree: {
|
|
367
|
+
approved: plugin?.webTreeSha256 ?? null,
|
|
368
|
+
current: store.current,
|
|
369
|
+
previous: store.previous,
|
|
370
|
+
},
|
|
371
|
+
boundTunnel: boundTunnel
|
|
372
|
+
? {
|
|
373
|
+
id: boundTunnel.id,
|
|
374
|
+
fqdn: boundTunnel.fqdn ?? null,
|
|
375
|
+
enabled: boundTunnel.enabled !== false,
|
|
376
|
+
routes: tunnelUserSurfaceRoutes(boundTunnel),
|
|
377
|
+
}
|
|
378
|
+
: null,
|
|
379
|
+
offer,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Creating, deleting, moving
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Why `subdomain` of `domain` cannot be a new web surface's hostname, or
|
|
389
|
+
* null when it can: reserved, held by a tunnel, or taken by a site (its
|
|
390
|
+
* hostname or an alias — or its name: site names are unique relay-wide).
|
|
391
|
+
*/
|
|
392
|
+
async function hostnameTaken(subdomain, domain, sites, tunnels) {
|
|
393
|
+
if (!isWebSurfaceSubdomain(subdomain))
|
|
394
|
+
return `'${subdomain}' cannot be a web surface's subdomain`;
|
|
395
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
396
|
+
const holder = hostnameHolder(fqdn, { tunnels, sites });
|
|
397
|
+
if (holder) return `${holder.kind === 'tunnel' ? 'a tunnel' : 'a site'} holds '${fqdn}'`;
|
|
398
|
+
if (sites.some((s) => s.name === subdomain)) {
|
|
399
|
+
return `a site is named '${subdomain}'`;
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The installed plugin at `route` whose installed version declares a web
|
|
406
|
+
* surface, checked for a workflow that gives it a site — or a refusal.
|
|
407
|
+
*/
|
|
408
|
+
async function webSurfacePlugin(route) {
|
|
409
|
+
const plugin = await findPluginByRoute(route);
|
|
410
|
+
if (!plugin) throw refuse(`No installed plugin holds the route '${route}'`, 404, 'not_found');
|
|
411
|
+
if (!plugin.userSurface?.web) {
|
|
412
|
+
throw refuse(
|
|
413
|
+
`Plugin "${plugin.name}" ${plugin.version} declares no web surface`,
|
|
414
|
+
400,
|
|
415
|
+
'no_web_surface',
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (!plugin.identity) {
|
|
419
|
+
throw refuse(`Plugin "${plugin.name}" declares no identity`, 400, 'no_identity');
|
|
420
|
+
}
|
|
421
|
+
return plugin;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** A plugin-owned site entry as sites.json stores it. */
|
|
425
|
+
function ownedSiteEntry({ id, subdomain, fqdn, plugin, accessMode }) {
|
|
426
|
+
const route = plugin.route;
|
|
427
|
+
return {
|
|
428
|
+
id,
|
|
429
|
+
name: subdomain,
|
|
430
|
+
fqdn,
|
|
431
|
+
type: 'managed',
|
|
432
|
+
spaMode: true,
|
|
433
|
+
autheliaProtected: false,
|
|
434
|
+
allowedUsers: [],
|
|
435
|
+
dnsVerified: true,
|
|
436
|
+
certIssued: true,
|
|
437
|
+
rootPath: `${webTreesDir(route)}/current`,
|
|
438
|
+
createdAt: new Date().toISOString(),
|
|
439
|
+
totalSize: 0,
|
|
440
|
+
owner: { plugin: route, package: plugin.packageName },
|
|
441
|
+
accessMode,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Serve the approved tree when there is one; a tree that cannot be served is a refusal. */
|
|
446
|
+
async function serveApprovedTree(plugin, logger) {
|
|
447
|
+
try {
|
|
448
|
+
await syncWebSurfaceTree(plugin.route, { logger, withoutSite: true });
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (err instanceof WebTreeError) {
|
|
451
|
+
throw refuse(
|
|
452
|
+
`The web tree of "${plugin.name}" cannot be served: ${err.message}`,
|
|
453
|
+
409,
|
|
454
|
+
'web_tree',
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
throw err;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Give plugin `route` its web surface on `subdomain` of the plugin's domain
|
|
463
|
+
* (default: the one its manifest declares) with `accessMode` (default: the
|
|
464
|
+
* declared one): a certificate, the site entry, the vhost. The plugin must
|
|
465
|
+
* declare a web surface and own no site, and no tunnel may be bound as its
|
|
466
|
+
* user surface (move that one instead). The site answers 503 until the
|
|
467
|
+
* surface is served.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} route
|
|
470
|
+
* @param {{ subdomain?: string, accessMode?: 'authenticated' | 'restricted' }} input
|
|
471
|
+
* @param {{ actor: string, logger: import('pino').Logger }} opts
|
|
472
|
+
*/
|
|
473
|
+
export function createWebSurface(route, input, { actor, logger }) {
|
|
474
|
+
return withTunnelLock(() =>
|
|
475
|
+
withSitesLock(async () => {
|
|
476
|
+
const plugin = await webSurfacePlugin(route);
|
|
477
|
+
const config = getConfig();
|
|
478
|
+
if (!config.domain || !config.email) {
|
|
479
|
+
throw refuse('Domain and email must be configured', 400, 'domain_not_configured');
|
|
480
|
+
}
|
|
481
|
+
const subdomain = input.subdomain ?? plugin.userSurface.web.defaultSubdomain;
|
|
482
|
+
const accessMode = input.accessMode ?? plugin.userSurface.web.accessMode;
|
|
483
|
+
const [sites, tunnels] = await Promise.all([readSites(), readTunnels()]);
|
|
484
|
+
if (ownedSiteOf(route, sites)) {
|
|
485
|
+
throw refuse(`Plugin "${plugin.name}" has a web surface already`, 409, 'exists');
|
|
486
|
+
}
|
|
487
|
+
const bound = tunnels.find((t) => tunnelUserSurfaceRoutes(t).includes(route));
|
|
488
|
+
if (bound) {
|
|
489
|
+
throw refuse(
|
|
490
|
+
`Tunnel '${bound.fqdn ?? bound.id}' is bound as the user surface of "${plugin.name}" — move it to the web surface instead`,
|
|
491
|
+
409,
|
|
492
|
+
'bound_to_tunnel',
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const domain = await pluginDomainName(plugin);
|
|
496
|
+
const taken = await hostnameTaken(subdomain, domain, sites, tunnels);
|
|
497
|
+
if (taken) throw refuse(`Cannot use '${subdomain}': ${taken}`, 409, 'hostname_taken');
|
|
498
|
+
|
|
499
|
+
await serveApprovedTree(plugin, logger);
|
|
500
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
501
|
+
const cert = await deps.issueTunnelCert(fqdn, config.email);
|
|
502
|
+
const site = ownedSiteEntry({ id: crypto.randomUUID(), subdomain, fqdn, plugin, accessMode });
|
|
503
|
+
await writeSites([...sites, site]);
|
|
504
|
+
try {
|
|
505
|
+
const { name, config: vhost } = await renderOwnedSite(
|
|
506
|
+
site,
|
|
507
|
+
route,
|
|
508
|
+
cert.certPath,
|
|
509
|
+
tunnels,
|
|
510
|
+
domain,
|
|
511
|
+
);
|
|
512
|
+
await deps.installVhost(name, vhost, fqdn, { enabled: true });
|
|
513
|
+
} catch (err) {
|
|
514
|
+
await writeSites(sites);
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
517
|
+
await recordAuditBestEffort(
|
|
518
|
+
{
|
|
519
|
+
actor,
|
|
520
|
+
action: 'plugin.web-surface.created',
|
|
521
|
+
plugin: plugin.name,
|
|
522
|
+
details: { siteId: site.id, fqdn, accessMode },
|
|
523
|
+
},
|
|
524
|
+
logger,
|
|
525
|
+
);
|
|
526
|
+
return webSurfaceStatus(route);
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Delete plugin `route`'s web surface: its vhost, its site entry, its
|
|
533
|
+
* tree store and the grants on the site. The plugin stays installed.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} route
|
|
536
|
+
* @param {{ actor: string, logger: import('pino').Logger }} opts
|
|
537
|
+
*/
|
|
538
|
+
export function deleteWebSurface(route, { actor, logger }) {
|
|
539
|
+
return withSitesLock(async () => {
|
|
540
|
+
const sites = await readSites();
|
|
541
|
+
const site = ownedSiteOf(route, sites);
|
|
542
|
+
if (!site) throw refuse(`No web surface for the route '${route}'`, 404, 'not_found');
|
|
543
|
+
await deps.removeStaticSiteVhost(site.id);
|
|
544
|
+
await writeSites(sites.filter((s) => s.id !== site.id));
|
|
545
|
+
await treeMutex.run(route, () => removeWebTreeStore(webTreesDir(route)));
|
|
546
|
+
const revoked = await revokeSiteGrants(site.id, logger);
|
|
547
|
+
// What is left on the deleted site: grants Gatekeeper kept, or none
|
|
548
|
+
// known because it could not list them.
|
|
549
|
+
const left = revoked.unlisted
|
|
550
|
+
? { grantsUnlisted: true }
|
|
551
|
+
: revoked.notRevoked.length > 0
|
|
552
|
+
? { grantsNotRevoked: revoked.notRevoked }
|
|
553
|
+
: {};
|
|
554
|
+
await recordAuditBestEffort(
|
|
555
|
+
{
|
|
556
|
+
actor,
|
|
557
|
+
action: 'plugin.web-surface.deleted',
|
|
558
|
+
plugin: (await findPluginByRoute(route))?.name ?? null,
|
|
559
|
+
details: { siteId: site.id, fqdn: site.fqdn, ...left },
|
|
560
|
+
},
|
|
561
|
+
logger,
|
|
562
|
+
);
|
|
563
|
+
return { ok: true, ...left };
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Revoke grants `grantIds`; returns the ids Gatekeeper did not revoke
|
|
569
|
+
* (logged), for the caller to report.
|
|
570
|
+
*/
|
|
571
|
+
async function revokeGrants(grantIds, logger) {
|
|
572
|
+
const failed = [];
|
|
573
|
+
for (const grantId of grantIds) {
|
|
574
|
+
const result = await revokeGatekeeperGrant(grantId).catch((err) => ({
|
|
575
|
+
ok: false,
|
|
576
|
+
error: err instanceof Error ? err.message : String(err),
|
|
577
|
+
}));
|
|
578
|
+
if (!result.ok) failed.push(grantId);
|
|
579
|
+
}
|
|
580
|
+
if (failed.length > 0) {
|
|
581
|
+
logger?.warn?.({ grantIds: failed }, 'Gatekeeper did not revoke grants of a web surface');
|
|
582
|
+
}
|
|
583
|
+
return failed;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Revoke every grant on site `siteId`: `{ notRevoked }`, the ids
|
|
588
|
+
* Gatekeeper kept — or `{ unlisted: true }` when it could not list them.
|
|
589
|
+
*/
|
|
590
|
+
async function revokeSiteGrants(siteId, logger) {
|
|
591
|
+
const grants = await listResourceGrants('site', siteId);
|
|
592
|
+
if (!grants.ok) {
|
|
593
|
+
logger?.warn?.({ siteId, error: grants.error }, 'Could not list the grants of a web surface');
|
|
594
|
+
return { unlisted: true };
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
notRevoked: await revokeGrants(
|
|
598
|
+
grants.grants.map((g) => g.grantId),
|
|
599
|
+
logger,
|
|
600
|
+
),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Copy the grants on tunnel `tunnelId` to the site `siteId` (a restricted
|
|
606
|
+
* host keeps its users). Returns the grants created; on a failure revokes
|
|
607
|
+
* them and throws.
|
|
608
|
+
*/
|
|
609
|
+
async function copyTunnelGrants(tunnelId, siteId, logger) {
|
|
610
|
+
const listed = await listResourceGrants('tunnel', tunnelId);
|
|
611
|
+
if (!listed.ok) {
|
|
612
|
+
throw refuse(
|
|
613
|
+
`Could not read the tunnel's grants from Gatekeeper: ${listed.error}`,
|
|
614
|
+
503,
|
|
615
|
+
'gatekeeper',
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const created = [];
|
|
619
|
+
for (const grant of listed.grants) {
|
|
620
|
+
const result = await createGatekeeperGrant({
|
|
621
|
+
principalType: grant.principalType,
|
|
622
|
+
principalId: grant.principalId,
|
|
623
|
+
resourceType: 'site',
|
|
624
|
+
resourceId: siteId,
|
|
625
|
+
context: grant.context,
|
|
626
|
+
});
|
|
627
|
+
if (!result.ok) {
|
|
628
|
+
await revokeGrants(
|
|
629
|
+
created.map((g) => g.grantId),
|
|
630
|
+
logger,
|
|
631
|
+
);
|
|
632
|
+
throw refuse(`Could not copy the tunnel's grants: ${result.error}`, 503, 'gatekeeper');
|
|
633
|
+
}
|
|
634
|
+
created.push(result.grant);
|
|
635
|
+
}
|
|
636
|
+
return created;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Move plugin `route`'s user surface from tunnel `tunnelId` — bound to it,
|
|
641
|
+
* and to nothing else — to the plugin's web surface on the same hostname,
|
|
642
|
+
* in one nginx reload: the shell is then served from the plugin's approved
|
|
643
|
+
* tree on the relay, and the API as before. The tunnel stays in the state,
|
|
644
|
+
* disabled and unbound (its port grant withdrawn once nginx no longer
|
|
645
|
+
* routes to it), for a rollback by hand. A restricted tunnel's grants are
|
|
646
|
+
* copied to the site. The plugin must be served — approved, hosted by the
|
|
647
|
+
* tunnel's agent — and its approved tree is served first.
|
|
648
|
+
*
|
|
649
|
+
* @param {string} route
|
|
650
|
+
* @param {string} tunnelId
|
|
651
|
+
* @param {{ actor: string, logger: import('pino').Logger }} opts
|
|
652
|
+
*/
|
|
653
|
+
export function moveWebSurfaceFromTunnel(route, tunnelId, { actor, logger }) {
|
|
654
|
+
return withTunnelLock(() =>
|
|
655
|
+
withSitesLock(async () => {
|
|
656
|
+
const plugin = await webSurfacePlugin(route);
|
|
657
|
+
const [sites, tunnels] = await Promise.all([readSites(), readTunnels()]);
|
|
658
|
+
if (ownedSiteOf(route, sites)) {
|
|
659
|
+
throw refuse(`Plugin "${plugin.name}" has a web surface already`, 409, 'exists');
|
|
660
|
+
}
|
|
661
|
+
const tunnel = tunnels.find((t) => t.id === tunnelId);
|
|
662
|
+
if (!tunnel) throw refuse('Tunnel not found', 404, 'not_found');
|
|
663
|
+
const routes = tunnelUserSurfaceRoutes(tunnel);
|
|
664
|
+
if (!routes.includes(route)) {
|
|
665
|
+
throw refuse(`Tunnel '${tunnel.fqdn}' is not bound to "${plugin.name}"`, 409, 'not_bound');
|
|
666
|
+
}
|
|
667
|
+
if (routes.length > 1) {
|
|
668
|
+
throw refuse(
|
|
669
|
+
`Tunnel '${tunnel.fqdn}' serves other user surfaces too (${routes.filter((r) => r !== route).join(', ')}) — unbind them first`,
|
|
670
|
+
409,
|
|
671
|
+
'shared_tunnel',
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (tunnel.enabled === false || typeof tunnel.fqdn !== 'string') {
|
|
675
|
+
throw refuse(`Tunnel '${tunnel.id}' is disabled`, 409, 'tunnel_disabled');
|
|
676
|
+
}
|
|
677
|
+
// As everywhere else, a tunnel without a known access mode is restricted.
|
|
678
|
+
const accessMode = tunnel.accessMode === 'authenticated' ? 'authenticated' : 'restricted';
|
|
679
|
+
if ((await hostingAgentOf(plugin, tunnels)) !== tunnel.agentLabel) {
|
|
680
|
+
throw refuse(
|
|
681
|
+
`Plugin "${plugin.name}" is not hosted by the agent carrying the tunnel`,
|
|
682
|
+
409,
|
|
683
|
+
'not_hosted',
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (!(await installedVersionApproved(plugin).catch(() => false))) {
|
|
687
|
+
throw refuse(
|
|
688
|
+
`Plugin "${plugin.name}" ${plugin.version} is not approved`,
|
|
689
|
+
409,
|
|
690
|
+
'not_approved',
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
if (sites.some((s) => s.fqdn === tunnel.fqdn || (s.aliases ?? []).includes(tunnel.fqdn))) {
|
|
694
|
+
throw refuse(`A site answers on '${tunnel.fqdn}' already`, 409, 'hostname_taken');
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
await serveApprovedTree(plugin, logger);
|
|
698
|
+
const site = ownedSiteEntry({
|
|
699
|
+
id: crypto.randomUUID(),
|
|
700
|
+
subdomain: tunnel.subdomain,
|
|
701
|
+
fqdn: tunnel.fqdn,
|
|
702
|
+
plugin,
|
|
703
|
+
accessMode,
|
|
704
|
+
});
|
|
705
|
+
const movedTunnels = tunnels.map((t) => {
|
|
706
|
+
if (t.id !== tunnel.id) return t;
|
|
707
|
+
const moved = { ...t, enabled: false };
|
|
708
|
+
delete moved.userSurface;
|
|
709
|
+
return moved;
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// Every step from the grants to the handover undoes in reverse order
|
|
713
|
+
// whatever ran before a failure: no grant, site entry or tunnel change
|
|
714
|
+
// outlives a move that did not happen.
|
|
715
|
+
const grants =
|
|
716
|
+
accessMode === 'restricted' ? await copyTunnelGrants(tunnel.id, site.id, logger) : [];
|
|
717
|
+
let wroteSites = false;
|
|
718
|
+
let wroteTunnels = false;
|
|
719
|
+
try {
|
|
720
|
+
const { domain } = tunnelHost(tunnel);
|
|
721
|
+
const installed = await deps.readInstalledVhost(appVhostName(tunnel.fqdn));
|
|
722
|
+
const certDir =
|
|
723
|
+
(installed && vhostCertDir(installed)) || (await deps.getCertPath(tunnel.fqdn, domain));
|
|
724
|
+
// The state first — Gatekeeper reads both files on a change of
|
|
725
|
+
// either, so the old vhost's API requests already resolve to the
|
|
726
|
+
// site — then one reload hands the hostname over, then the port
|
|
727
|
+
// grant goes.
|
|
728
|
+
await writeSites([...sites, site]);
|
|
729
|
+
wroteSites = true;
|
|
730
|
+
await writeTunnels(movedTunnels);
|
|
731
|
+
wroteTunnels = true;
|
|
732
|
+
await deps.moveAppVhostToWebSurface(tunnel.fqdn, site, certDir, domain, {
|
|
733
|
+
route,
|
|
734
|
+
served: (await webSurfaceServing(route, movedTunnels)).served,
|
|
735
|
+
maxBodySizeMb: await internalTunnelBodyLimit(plugin, movedTunnels),
|
|
736
|
+
treesDir: webTreesDir(route),
|
|
737
|
+
});
|
|
738
|
+
} catch (err) {
|
|
739
|
+
const undo = async (what, step) => {
|
|
740
|
+
try {
|
|
741
|
+
await step();
|
|
742
|
+
} catch (undoErr) {
|
|
743
|
+
logger?.error?.(
|
|
744
|
+
{ err: undoErr instanceof Error ? undoErr.message : String(undoErr) },
|
|
745
|
+
`Could not undo a failed move: ${what}`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
if (wroteTunnels) await undo('tunnels.json', () => writeTunnels(tunnels));
|
|
750
|
+
if (wroteSites) await undo('sites.json', () => writeSites(sites));
|
|
751
|
+
await revokeGrants(
|
|
752
|
+
grants.map((g) => g.grantId),
|
|
753
|
+
logger,
|
|
754
|
+
);
|
|
755
|
+
throw err;
|
|
756
|
+
}
|
|
757
|
+
// The port is withdrawn for new sessions only: nginx routes nothing to
|
|
758
|
+
// it any more, and restarting chisel would drop every agent's session
|
|
759
|
+
// — the plugin's internal tunnel too, its API answering 502 until the
|
|
760
|
+
// agent reconnects — and fail the requests nginx's old workers still
|
|
761
|
+
// send to the tunnel until they finish. The agent drops the binding
|
|
762
|
+
// at its next sync.
|
|
763
|
+
try {
|
|
764
|
+
await deps.syncChisel({ keepLiveSessions: true });
|
|
765
|
+
} catch (err) {
|
|
766
|
+
logger?.warn?.(
|
|
767
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
768
|
+
'Could not withdraw the moved tunnel’s port grant — the next sync will',
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
await recordAuditBestEffort(
|
|
772
|
+
{
|
|
773
|
+
actor,
|
|
774
|
+
action: 'plugin.web-surface.moved',
|
|
775
|
+
plugin: plugin.name,
|
|
776
|
+
details: { siteId: site.id, fqdn: site.fqdn, tunnelId: tunnel.id, accessMode },
|
|
777
|
+
},
|
|
778
|
+
logger,
|
|
779
|
+
);
|
|
780
|
+
return webSurfaceStatus(route);
|
|
781
|
+
}),
|
|
782
|
+
);
|
|
783
|
+
}
|