Merge branch 'claude/activate-smime-plugin' into 'dev'
feat(smime): actually install the audited S/MIME plugin in real builds See merge request gitlab-instance-b9b5cf2f/vncmail-plus!5
This commit is contained in:
@@ -6,6 +6,10 @@ node_modules
|
||||
!.env.example
|
||||
!.env.dev.example
|
||||
scripts/
|
||||
# ...except the first-party plugin builder, which the image build runs
|
||||
# (see Dockerfile). Without this the whole scripts/ dir is absent from the
|
||||
# build context and the RUN step fails with "Cannot find module".
|
||||
!scripts/build-plugins.mjs
|
||||
TODO.md
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
+7
-3
@@ -62,10 +62,14 @@ next-env.d.ts
|
||||
# k8s deploy secrets (create from the matching overlay's secret.example.yaml)
|
||||
/deploy/k8s/overlays/*/secret.yaml
|
||||
|
||||
# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build)
|
||||
vnc/plugins/smime/node_modules/
|
||||
vnc/plugins/smime/dist/
|
||||
# First-party plugin build output (rebuild with: npm run build:plugins).
|
||||
# vnc/plugins/build/ is the staging dir the server installs from at startup
|
||||
# (see lib/admin/bundled-plugins.ts) - built, never committed.
|
||||
vnc/plugins/build/
|
||||
vnc/plugins/*/node_modules/
|
||||
vnc/plugins/*/dist/
|
||||
vnc/plugins/smime/smime-vnc.zip
|
||||
vnc/plugins/smime/smime.zip
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
+10
@@ -21,6 +21,12 @@ ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
# Build the first-party plugins (vnc/plugins/*) that ship with this fork -
|
||||
# currently the audited S/MIME plugin, which the server installs into its
|
||||
# plugin registry at startup (lib/admin/bundled-plugins.ts). Each plugin has
|
||||
# its own package.json + lockfile, so this does its own npm ci.
|
||||
# Runs BEFORE next build so a broken plugin fails the image build.
|
||||
RUN node scripts/build-plugins.mjs
|
||||
RUN npx next build --webpack
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
@@ -43,6 +49,10 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# Staged first-party plugin bundles. Read by path at runtime, so Next's output
|
||||
# file tracing does not carry them into .next/standalone - copy explicitly or
|
||||
# the image boots with the S/MIME policy toggle on and no plugin installed.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/vnc/plugins/build ./vnc/plugins/build
|
||||
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
+4
-1
@@ -100,7 +100,10 @@ export default [
|
||||
// above. Pre-existing gap: this was blocking `npm run lint` (and thus
|
||||
// the pre-commit hook) repo-wide before this Electron work even
|
||||
// touched anything - see the electron-desktop branch's first commits.
|
||||
"vnc/plugins/smime/**",
|
||||
// Independent sub-packages, plus the generated staging dir
|
||||
// vnc/plugins/build/** that scripts/build-plugins.mjs writes the bundled
|
||||
// artifacts into (1.7 MB of vendored crypto - not our source to lint).
|
||||
"vnc/plugins/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -43,6 +43,16 @@ migrateLegacyAdminLayout()
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
// Install the first-party plugins this fork ships with (currently the
|
||||
// audited S/MIME plugin) into the server plugin registry - the same admin
|
||||
// channel an operator-uploaded ZIP lands in, so bundles still get
|
||||
// Ed25519-signed on serve and the privileged-tier gates still apply.
|
||||
// Staged by scripts/build-plugins.mjs; gated by the matching policy
|
||||
// feature toggle. Never throws.
|
||||
const { seedBundledPlugins } = await import("./lib/admin/bundled-plugins");
|
||||
await seedBundledPlugins();
|
||||
})
|
||||
.then(async () => {
|
||||
// Anonymous telemetry - on by default. Admins can disable via the
|
||||
// admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint.
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { ALL_PERMISSIONS, MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
|
||||
import { auditLog } from './audit';
|
||||
import { configManager } from './config-manager';
|
||||
import { isConfigReadOnly } from './paths';
|
||||
import {
|
||||
getPluginRegistry,
|
||||
savePlugin,
|
||||
updatePluginMeta,
|
||||
type ServerPlugin,
|
||||
} from './plugin-registry';
|
||||
import type { FeatureGates } from './types';
|
||||
|
||||
/**
|
||||
* First-party ("bundled") plugin installation.
|
||||
*
|
||||
* The plugin registry (`<CONFIG_DIR>/plugins/`) is the host's admin channel:
|
||||
* bundles served out of it are Ed25519-signed on the way out by
|
||||
* `app/api/admin/plugins/[id]/bundle`, and `/api/plugins` is what makes a
|
||||
* plugin `managed` on the client - which is what `resolvePluginTier` requires
|
||||
* before it will grant the privileged (same-origin) tier.
|
||||
*
|
||||
* This module installs the plugins this fork ships with THROUGH that same
|
||||
* channel, so nothing about the signing / approval / consent chain is
|
||||
* bypassed or relaxed: the operator's own server performs the install that an
|
||||
* operator would otherwise perform by uploading the ZIP in /admin.
|
||||
*
|
||||
* Input is the staging directory produced by `scripts/build-plugins.mjs`:
|
||||
*
|
||||
* vnc/plugins/build/<id>/manifest.json
|
||||
* vnc/plugins/build/<id>/<entrypoint>
|
||||
*
|
||||
* Nothing here is trusted blindly - the manifest is validated the same way the
|
||||
* admin upload route validates one, and an unknown permission or a bad id is a
|
||||
* refusal, not a warning.
|
||||
*/
|
||||
|
||||
/** Where the staged bundles live, relative to cwd (override for odd layouts). */
|
||||
function getBundledPluginsDir(): string {
|
||||
return (
|
||||
process.env.BUNDLED_PLUGINS_DIR ||
|
||||
path.join(process.cwd(), 'vnc', 'plugins', 'build')
|
||||
);
|
||||
}
|
||||
|
||||
interface FirstPartyPlugin {
|
||||
id: string;
|
||||
/**
|
||||
* Feature gate that decides whether this plugin is installed and served.
|
||||
* Turning the gate off in the admin policy disables the plugin (and stops
|
||||
* it being re-installed on the next boot) - that, not the Delete button, is
|
||||
* the way to remove a bundled plugin, since a delete would be undone by the
|
||||
* next restart.
|
||||
*/
|
||||
gate: keyof FeatureGates;
|
||||
/**
|
||||
* Force-enable for every user. Required for a bundled plugin to be reachable
|
||||
* at all under the default policy: `pluginsEnabled` defaults to false, which
|
||||
* hides the user-facing Settings > Plugins tab, so there would be no way for
|
||||
* a user to switch it on by hand.
|
||||
*/
|
||||
forceEnable: boolean;
|
||||
}
|
||||
|
||||
const FIRST_PARTY_PLUGINS: FirstPartyPlugin[] = [
|
||||
// The audited S/MIME implementation (vnc/plugins/smime). It IS the S/MIME
|
||||
// feature - the former in-host native pipeline is gone - so the long-standing
|
||||
// `smimeEnabled` policy gate now controls this plugin.
|
||||
{ id: 'smime', gate: 'smimeEnabled', forceEnable: true },
|
||||
];
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const VALID_TYPES = new Set(['ui-extension', 'sidebar-app', 'hook']);
|
||||
|
||||
function asString(v: unknown, fallback = ''): string {
|
||||
return typeof v === 'string' ? v : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a staged manifest and turn it into a registry entry. Returns a list
|
||||
* of errors instead of throwing so one bad bundle can't take down startup.
|
||||
*/
|
||||
function toServerPlugin(
|
||||
manifest: Record<string, unknown>,
|
||||
code: string,
|
||||
opts: { enabled: boolean; forceEnabled: boolean; installedAt: string },
|
||||
): { plugin: ServerPlugin } | { errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
const id = asString(manifest.id);
|
||||
if (!ID_RE.test(id)) errors.push(`invalid id ${JSON.stringify(manifest.id)}`);
|
||||
if (!asString(manifest.name)) errors.push('missing "name"');
|
||||
if (!asString(manifest.version)) errors.push('missing "version"');
|
||||
if (!asString(manifest.author)) errors.push('missing "author"');
|
||||
const entrypoint = asString(manifest.entrypoint);
|
||||
if (!entrypoint) errors.push('missing "entrypoint"');
|
||||
if (!VALID_TYPES.has(asString(manifest.type))) {
|
||||
errors.push(`invalid type ${JSON.stringify(manifest.type)}`);
|
||||
}
|
||||
|
||||
const permissions = Array.isArray(manifest.permissions)
|
||||
? manifest.permissions.filter((p): p is string => typeof p === 'string')
|
||||
: [];
|
||||
const known = new Set<string>(ALL_PERMISSIONS as readonly string[]);
|
||||
const unknownPerms = permissions.filter(p => !known.has(p));
|
||||
if (unknownPerms.length > 0) {
|
||||
errors.push(`unknown permissions: ${unknownPerms.join(', ')}`);
|
||||
}
|
||||
|
||||
const size = Buffer.byteLength(code, 'utf-8');
|
||||
if (size > MAX_PLUGIN_SIZE) {
|
||||
errors.push(`bundle is ${size} bytes, over the ${MAX_PLUGIN_SIZE} byte limit`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { errors };
|
||||
|
||||
return {
|
||||
plugin: {
|
||||
id,
|
||||
name: asString(manifest.name),
|
||||
version: asString(manifest.version),
|
||||
author: asString(manifest.author),
|
||||
description: asString(manifest.description),
|
||||
type: asString(manifest.type),
|
||||
// Only 'privileged' is meaningful; anything else falls through to the
|
||||
// default untrusted tier. Same narrowing the admin upload route applies.
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: opts.enabled,
|
||||
forceEnabled: opts.forceEnabled,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
installedAt: opts.installedAt,
|
||||
updatedAt: opts.installedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readStaged(dir: string, id: string): Promise<
|
||||
{ manifest: Record<string, unknown>; code: string } | null
|
||||
> {
|
||||
const manifestPath = path.join(dir, id, 'manifest.json');
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
||||
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object');
|
||||
manifest = parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
logger.error(`[bundled-plugins] ${id}: unreadable manifest`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const entrypoint = asString(manifest.entrypoint, 'index.js');
|
||||
if (entrypoint.includes('/') || entrypoint.includes('\\')) {
|
||||
logger.error(`[bundled-plugins] ${id}: entrypoint must be a bare filename`);
|
||||
return null;
|
||||
}
|
||||
const codePath = path.join(dir, id, entrypoint);
|
||||
try {
|
||||
return { manifest, code: await readFile(codePath, 'utf-8') };
|
||||
} catch (err) {
|
||||
logger.error(`[bundled-plugins] ${id}: cannot read bundle ${entrypoint}`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install / update / disable the bundled first-party plugins. Idempotent: a
|
||||
* boot where nothing changed writes nothing.
|
||||
*
|
||||
* Never throws - a failure here must not stop the server from starting, it
|
||||
* just means the plugin isn't there (and says so in the log).
|
||||
*/
|
||||
export async function seedBundledPlugins(): Promise<void> {
|
||||
try {
|
||||
const dir = getBundledPluginsDir();
|
||||
const staged = existsSync(dir);
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const features = configManager.getPolicy().features;
|
||||
const registry = await getPluginRegistry();
|
||||
|
||||
for (const spec of FIRST_PARTY_PLUGINS) {
|
||||
const gateOn = features[spec.gate] !== false;
|
||||
const existing = registry.plugins.find(p => p.id === spec.id);
|
||||
|
||||
if (!gateOn) {
|
||||
// Policy says off. Stop serving it (the client's own sync then treats
|
||||
// it as removed and cleans it up) but leave the bundle on disk so
|
||||
// flipping the gate back on is instant.
|
||||
if (existing && (existing.enabled || existing.forceEnabled)) {
|
||||
if (isConfigReadOnly()) {
|
||||
logger.warn(
|
||||
`[bundled-plugins] ${spec.id}: policy "${spec.gate}" is off but the ` +
|
||||
'config dir is read-only, so it stays enabled',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await updatePluginMeta(spec.id, { enabled: false, forceEnabled: false });
|
||||
logger.info(`[bundled-plugins] ${spec.id} disabled ("${spec.gate}" is off in policy)`);
|
||||
await auditLog('plugin.bundled.disable', { id: spec.id, gate: spec.gate }, 'system');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!staged) continue;
|
||||
|
||||
const read = await readStaged(dir, spec.id);
|
||||
if (!read) {
|
||||
logger.warn(
|
||||
`[bundled-plugins] ${spec.id}: not staged in ${dir} - ` +
|
||||
'run "npm run build:plugins" (the container and standalone builds do this for you)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bundleHash = createHash('sha256').update(read.code).digest('hex');
|
||||
const version = asString(read.manifest.version);
|
||||
const unchanged =
|
||||
existing !== undefined &&
|
||||
existing.version === version &&
|
||||
existing.bundleHash === bundleHash &&
|
||||
existing.enabled === true &&
|
||||
existing.forceEnabled === spec.forceEnable;
|
||||
if (unchanged) {
|
||||
logger.debug(`[bundled-plugins] ${spec.id} v${version} already installed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isConfigReadOnly()) {
|
||||
logger.warn(
|
||||
`[bundled-plugins] ${spec.id} v${version} cannot be installed: the admin ` +
|
||||
'config dir is read-only. Remount it read-write (or unset ' +
|
||||
'ADMIN_CONFIG_READONLY) once, so the plugin registry can be written.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const built = toServerPlugin(read.manifest, read.code, {
|
||||
enabled: true,
|
||||
forceEnabled: spec.forceEnable,
|
||||
installedAt: existing?.installedAt ?? new Date().toISOString(),
|
||||
});
|
||||
if ('errors' in built) {
|
||||
logger.error(`[bundled-plugins] ${spec.id}: manifest rejected`, {
|
||||
errors: built.errors.join('; '),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await savePlugin(built.plugin, read.code);
|
||||
const action = existing ? 'update' : 'install';
|
||||
logger.info(
|
||||
`[bundled-plugins] ${existing ? 'updated' : 'installed'} ${spec.id} v${version} ` +
|
||||
`(tier=${built.plugin.tier ?? 'untrusted'}, forceEnabled=${spec.forceEnable})`,
|
||||
);
|
||||
await auditLog(
|
||||
`plugin.bundled.${action}`,
|
||||
{ id: spec.id, version, bundleHash, tier: built.plugin.tier ?? 'untrusted' },
|
||||
'system',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[bundled-plugins] seeding failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed for diagnostics / tests. */
|
||||
export async function listStagedBundledPlugins(): Promise<string[]> {
|
||||
const dir = getBundledPluginsDir();
|
||||
if (!existsSync(dir)) return [];
|
||||
const names = await readdir(dir);
|
||||
const out: string[] = [];
|
||||
for (const name of names) {
|
||||
if (name.startsWith('.')) continue;
|
||||
try {
|
||||
if ((await stat(path.join(dir, name))).isDirectory()) out.push(name);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -193,10 +193,22 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
||||
author: asString(manifest.author),
|
||||
description: asString(manifest.description),
|
||||
type: asString(manifest.type, 'hook'),
|
||||
// Requested execution tier. Dropped here previously, which silently pinned
|
||||
// every dev-loaded plugin to the untrusted (null-origin) tier - so a
|
||||
// privileged plugin such as S/MIME could never be exercised from
|
||||
// PLUGIN_DEV_DIR. Only 'privileged' is meaningful (same narrowing as the
|
||||
// admin upload route); the tier is still *granted* only by
|
||||
// resolvePluginTier, which additionally requires managed + consent.
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: true,
|
||||
forceEnabled: false,
|
||||
// Manifest i18n tables - also previously dropped, so api.i18n.t() fell back
|
||||
// to raw keys for dev-loaded plugins.
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
|
||||
+4
-3
@@ -23,16 +23,17 @@
|
||||
"typescript"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"dev": "npm run build:plugins && next dev --turbopack",
|
||||
"build": "npm run build:plugins && next build --turbopack",
|
||||
"start": "next start",
|
||||
"build:plugins": "node scripts/build-plugins.mjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts",
|
||||
"test:integration": "bash integration/run-tests.sh",
|
||||
"prepare": "husky",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:standalone": "next build --webpack && node scripts/assemble-standalone.mjs",
|
||||
"build:standalone": "npm run build:plugins && next build --webpack && node scripts/assemble-standalone.mjs",
|
||||
"build:electron": "node scripts/build-electron.mjs",
|
||||
"electron:dev": "npm run build:standalone && npm run build:electron && electron .",
|
||||
"test:electron": "playwright test -c playwright.electron.config.ts",
|
||||
|
||||
@@ -58,4 +58,27 @@ if (existsSync(sqlcipherSrc)) {
|
||||
);
|
||||
}
|
||||
|
||||
// The staged first-party plugin bundles (scripts/build-plugins.mjs). The
|
||||
// server installs these into its plugin registry at startup
|
||||
// (lib/admin/bundled-plugins.ts), reading them from
|
||||
// `<cwd>/vnc/plugins/build` - and Next's generated server.js chdir's to its
|
||||
// own directory, so "cwd" is this standalone dir in every packaged build.
|
||||
//
|
||||
// Output file tracing cannot find these: nothing imports them, they are read
|
||||
// by path at runtime. Without this copy the Electron/standalone build boots
|
||||
// with no S/MIME plugin at all while the policy toggle still says it is on -
|
||||
// the same silent-drop failure mode as the sqlcipher prebuilds above.
|
||||
const pluginsSrc = path.join(rootDir, "vnc", "plugins", "build");
|
||||
if (existsSync(pluginsSrc)) {
|
||||
const pluginsDest = path.join(standaloneDir, "vnc", "plugins", "build");
|
||||
rmSync(pluginsDest, { recursive: true, force: true });
|
||||
cpSync(pluginsSrc, pluginsDest, { recursive: true });
|
||||
console.log("Copied bundled first-party plugins into the standalone output");
|
||||
} else {
|
||||
console.warn(
|
||||
"No bundled plugins staged at vnc/plugins/build - " +
|
||||
'run "npm run build:plugins" first, or the packaged app ships without S/MIME',
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Assembled standalone server at", standaloneDir);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the FIRST-PARTY plugins that ship with this fork (vnc/plugins/*) and
|
||||
// stages them where the running server can install them.
|
||||
//
|
||||
// Why this exists
|
||||
// ---------------
|
||||
// `vnc/plugins/smime` is audited SOURCE, not a prebuilt drop (upstream's own
|
||||
// smime.zip was deliberately distrusted). Nothing built it in any real build,
|
||||
// so the S/MIME policy toggle was on while no plugin existed. This script is
|
||||
// the missing build step; `lib/admin/bundled-plugins.ts` is the matching
|
||||
// install step that runs at server startup.
|
||||
//
|
||||
// Output layout (gitignored, produced not committed):
|
||||
//
|
||||
// vnc/plugins/build/<id>/manifest.json
|
||||
// vnc/plugins/build/<id>/<entrypoint> e.g. index.js
|
||||
//
|
||||
// That directory is copied into the container image (Dockerfile) and into
|
||||
// .next/standalone (scripts/assemble-standalone.mjs), so every distribution
|
||||
// path - container, Electron, local dev - boots with the same artifact.
|
||||
//
|
||||
// Each plugin keeps its OWN package.json + lockfile so its (crypto) deps stay
|
||||
// pinned by the audit, rather than being restated in the app's root manifest.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const pluginsDir = path.join(rootDir, "vnc", "plugins");
|
||||
const outDir = path.join(pluginsDir, "build");
|
||||
|
||||
// Mirrors MAX_PLUGIN_SIZE in lib/plugin-types.ts - the cap the admin upload
|
||||
// route enforces. A first-party plugin must live inside the same budget.
|
||||
const MAX_BUNDLE_BYTES = 5 * 1024 * 1024;
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function run(cmd, args, cwd) {
|
||||
execFileSync(cmd, args, { cwd, stdio: "inherit" });
|
||||
}
|
||||
|
||||
function listPluginDirs() {
|
||||
if (!existsSync(pluginsDir)) return [];
|
||||
return readdirSync(pluginsDir)
|
||||
.filter((name) => name !== "build" && !name.startsWith("."))
|
||||
.map((name) => path.join(pluginsDir, name))
|
||||
.filter((dir) => statSync(dir).isDirectory())
|
||||
.filter((dir) => existsSync(path.join(dir, "manifest.json")));
|
||||
}
|
||||
|
||||
function buildOne(pluginDir) {
|
||||
const manifestPath = path.join(pluginDir, "manifest.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
const id = manifest.id;
|
||||
if (typeof id !== "string" || !ID_RE.test(id)) {
|
||||
throw new Error(`${manifestPath}: invalid plugin id ${JSON.stringify(id)}`);
|
||||
}
|
||||
const entrypoint =
|
||||
typeof manifest.entrypoint === "string" ? manifest.entrypoint : "index.js";
|
||||
if (entrypoint.includes("/") || entrypoint.includes("\\")) {
|
||||
throw new Error(`${manifestPath}: entrypoint must be a bare filename`);
|
||||
}
|
||||
|
||||
const pkgPath = path.join(pluginDir, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
throw new Error(`${pluginDir}: no package.json, cannot build`);
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
if (!pkg.scripts?.build) {
|
||||
throw new Error(`${pkgPath}: no "build" script`);
|
||||
}
|
||||
|
||||
// Install the plugin's own deps only when they're missing. Keeps repeat
|
||||
// builds (and `npm run dev`) fast - the actual esbuild step is ~20ms.
|
||||
if (!existsSync(path.join(pluginDir, "node_modules"))) {
|
||||
const hasLock = existsSync(path.join(pluginDir, "package-lock.json"));
|
||||
console.log(`[build-plugins] installing deps for ${id}`);
|
||||
run(npm, hasLock ? ["ci"] : ["install", "--no-audit", "--no-fund"], pluginDir);
|
||||
}
|
||||
|
||||
console.log(`[build-plugins] building ${id}`);
|
||||
run(npm, ["run", "build"], pluginDir);
|
||||
|
||||
const built = path.join(pluginDir, "dist", entrypoint);
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`${id}: build produced no ${path.relative(rootDir, built)}`);
|
||||
}
|
||||
const size = statSync(built).size;
|
||||
if (size > MAX_BUNDLE_BYTES) {
|
||||
throw new Error(
|
||||
`${id}: bundle is ${(size / 1024 / 1024).toFixed(2)} MB, over the ` +
|
||||
`${MAX_BUNDLE_BYTES / 1024 / 1024} MB plugin limit`,
|
||||
);
|
||||
}
|
||||
|
||||
const stageDir = path.join(outDir, id);
|
||||
rmSync(stageDir, { recursive: true, force: true });
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
cpSync(manifestPath, path.join(stageDir, "manifest.json"));
|
||||
cpSync(built, path.join(stageDir, entrypoint));
|
||||
|
||||
console.log(
|
||||
`[build-plugins] staged ${id} v${manifest.version} ` +
|
||||
`(${(size / 1024).toFixed(0)} KB) -> ${path.relative(rootDir, stageDir)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const dirs = listPluginDirs();
|
||||
if (dirs.length === 0) {
|
||||
console.log("[build-plugins] no first-party plugins found under vnc/plugins");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Rebuild the staging root from scratch so a plugin removed from the tree does
|
||||
// not linger as a stale bundle that the server would happily keep installing.
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const dir of dirs) buildOne(dir);
|
||||
console.log(`[build-plugins] done (${dirs.length} plugin(s))`);
|
||||
@@ -33,12 +33,48 @@ material ever leaves the device.
|
||||
"in-memory, cleared on reload" behaviour.
|
||||
- Returned HTML still passes through the host sanitizer.
|
||||
|
||||
## Build
|
||||
## Build & installation
|
||||
|
||||
Nothing manual is required in a normal build. From the repo root:
|
||||
|
||||
```bash
|
||||
cd repos/plugins/smime
|
||||
npm install # pulls pkijs / asn1js / pvtsutils / webcrypto-liner + esbuild
|
||||
npm run build # → dist/index.js (~1.7 MB, under the privileged cap)
|
||||
npm run build:plugins # → vnc/plugins/smime/dist/index.js
|
||||
# + staged at vnc/plugins/build/smime/{manifest.json,index.js}
|
||||
```
|
||||
|
||||
`npm run dev`, `npm run build` and `npm run build:standalone` all run it first,
|
||||
and the `Dockerfile` runs `node scripts/build-plugins.mjs` in the builder stage.
|
||||
The staged directory is copied into the container image (Dockerfile) and into
|
||||
`.next/standalone` (`scripts/assemble-standalone.mjs`), which is what the
|
||||
Electron package ships.
|
||||
|
||||
At server startup `lib/admin/bundled-plugins.ts` installs the staged bundle into
|
||||
the **server plugin registry** (`<ADMIN_CONFIG_DIR>/plugins/`) — the same place
|
||||
an operator-uploaded ZIP lands. That matters for the security model below: the
|
||||
registry is the signed admin channel, so `/api/admin/plugins/smime/bundle`
|
||||
Ed25519-signs the bytes on the way out and `/api/plugins` marks the plugin
|
||||
`managed`, which is what `resolvePluginTier` needs before it will grant the
|
||||
privileged tier. No gate is bypassed to get there.
|
||||
|
||||
Installation is idempotent (a boot where the version + bundle hash are unchanged
|
||||
writes nothing) and gated on the **`smimeEnabled` feature policy**, which is
|
||||
therefore the operator's on/off switch for S/MIME:
|
||||
|
||||
* `smimeEnabled: true` (the default) → installed, `forceEnabled`, served.
|
||||
* `smimeEnabled: false` → the registry entry is disabled, `/api/plugins` stops
|
||||
serving it, and clients clean it up on their next sync.
|
||||
|
||||
Force-enabling is deliberate: `pluginsEnabled` defaults to `false`, which hides
|
||||
the user-facing Settings ▸ Plugins tab, so a user would otherwise have no way to
|
||||
switch the plugin on. To remove the plugin, turn the policy toggle **off** —
|
||||
deleting it in the admin plugin list is undone by the next restart.
|
||||
|
||||
For manual/one-off distribution the plugin also still packages as a ZIP:
|
||||
|
||||
```bash
|
||||
cd vnc/plugins/smime
|
||||
npm ci # pkijs / asn1js / pvtsutils / webcrypto-liner + esbuild
|
||||
npm run build # → dist/index.js (~1.7 MB, under the 5 MB plugin cap)
|
||||
npm run package # → smime.zip (manifest.json + index.js) for admin upload
|
||||
```
|
||||
|
||||
@@ -46,6 +82,20 @@ The build aliases the Node `crypto` builtin (referenced by a dead
|
||||
`typeof process` branch in `asmcrypto.js`) to a browser shim so the bundle is
|
||||
self-contained.
|
||||
|
||||
### Known import limitation
|
||||
|
||||
PKCS#12 files whose bags are encrypted with the old
|
||||
`pbeWithSHA1And40BitRC2-CBC` / `pbeWithSHA1And128BitRC2-CBC` PBEs fail to import
|
||||
with a bare `Unrecognized name` error. `crypto-engine.js` maps those OIDs to an
|
||||
`RC2-CBC` WebCrypto algorithm that neither the browser nor `webcrypto-liner`
|
||||
actually provides, so the declared support is not real. This is the default
|
||||
`openssl pkcs12 -export` certificate PBE on LibreSSL (i.e. macOS's system
|
||||
`openssl`). 3DES and PBES2/AES bags — what current OpenSSL, Windows and
|
||||
Thunderbird produce — import fine. Re-export with
|
||||
`-certpbe aes-256-cbc -keypbe aes-256-cbc` (or `-certpbe PBE-SHA1-3DES`) as a
|
||||
workaround; a real fix needs either an RC2 implementation or an explicit,
|
||||
actionable error.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -66,8 +116,10 @@ src/
|
||||
node-crypto-shim.js browser shim for the Node "crypto" builtin
|
||||
```
|
||||
|
||||
The crypto modules are faithful ports of the host's `lib/smime/*` (the former
|
||||
native pipeline), so the plugin produces byte-compatible CMS.
|
||||
The crypto modules are faithful ports of the host's former `lib/smime/*` native
|
||||
pipeline (since removed), so the plugin produces byte-compatible CMS. With that
|
||||
directory gone, this plugin **is** the S/MIME feature — which is why the
|
||||
`smimeEnabled` policy gate now controls it.
|
||||
|
||||
## Note on host wiring
|
||||
|
||||
|
||||
Reference in New Issue
Block a user