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 (`/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//manifest.json * vnc/plugins/build// * * 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 }, // VNCdirectory deep-link. Users are managed in the directory, not the // webmail; this plugin adds a "User management" Settings entry that opens // the directory's user list. Force-enabled so it is always present. { id: 'manage-users', gate: 'manageUsersEnabled', forceEnable: true }, // SRC video meetings (VNCtalk / Jitsi). "Start a meeting" asks the server // for a signed JWT and opens the room in meet.src-advisory.com. { id: 'jitsi-meet', gate: 'jitsiMeetEnabled', 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, 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(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; code: string } | null > { const manifestPath = path.join(dir, id, 'manifest.json'); if (!existsSync(manifestPath)) return null; let manifest: Record; 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; } 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 { 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 { 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; }