Publish Docker Image / prepare (push) Successful in 2s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 8s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
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 },
|
|
// 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<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;
|
|
}
|