diff --git a/CHANGELOG.md b/CHANGELOG.md
index d757b0f93..40329098e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
- Slovak, Croatian, Slovenian, Lithuanian, Latvian, Estonian and Malay
- The desktop app can update itself. Pick Automatic, Ask first or Never in Settings, Updates. Store and package manager installs keep updating the way they do now
+- Community plugins. Turn on the community registry in Plugins, Registries to browse and install plugins made by other people, each version reviewed before it is listed
+- Custom plugin registries. Add your own registry by its index URL; its signing key is pinned when you add it
### Changed
diff --git a/README.md b/README.md
index 786a96996..597ab9b7a 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +92,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
-Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry).
+Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
diff --git a/env.catalog.json b/env.catalog.json
index 9f0934845..431df1b61 100644
--- a/env.catalog.json
+++ b/env.catalog.json
@@ -361,6 +361,11 @@
"group": "plugins",
"description": "Use another plugin index instead of the official registry."
},
+ {
+ "name": "TERMIX_COMMUNITY_REGISTRY_URL",
+ "group": "plugins",
+ "description": "Use another plugin index instead of the community registry. Its plugins still have to be signed with the community key."
+ },
{
"name": "TERMIX_PLUGIN_STATS_URL",
"group": "plugins",
diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts
index 5dffe395a..d37fa1002 100644
--- a/src/backend/database/routes/plugins.ts
+++ b/src/backend/database/routes/plugins.ts
@@ -23,6 +23,7 @@ import {
PluginManageError,
} from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js";
+import { RegistryError } from "../../plugins/registries.js";
import {
pluginDocsUrl,
pluginEnvVars,
@@ -404,6 +405,7 @@ async function auditPluginAction(
action: string,
pluginId: string,
details: Record = {},
+ resourceType = "plugin",
): Promise {
const userId = (req as AuthenticatedRequest).userId as string;
const { ipAddress, userAgent } = getRequestMeta(req);
@@ -412,7 +414,7 @@ async function auditPluginAction(
userId,
username: await getAuditUsername(userId),
action,
- resourceType: "plugin",
+ resourceType,
resourceId: pluginId,
resourceName: pluginId,
details: JSON.stringify(details),
@@ -431,7 +433,7 @@ function sendManageError(
fallback: string,
operation: string,
): void {
- if (error instanceof PluginManageError) {
+ if (error instanceof PluginManageError || error instanceof RegistryError) {
res.status(error.status).json({
error: error.message,
code: error.code,
@@ -582,6 +584,304 @@ router.post(
},
);
+/**
+ * @openapi
+ * /plugins/registries:
+ * get:
+ * summary: List the plugin registries
+ * description: The official registry, which is always on, the community registry and whether it is on, and every custom registry an admin added with its pinned key fingerprint.
+ * tags:
+ * - Plugins
+ * responses:
+ * 200:
+ * description: The registries.
+ * 403:
+ * description: The caller lacks admin.plugins.manage.
+ */
+router.get(
+ "/registries",
+ authenticateJWT,
+ requireManagePlugins,
+ async (_req: Request, res: Response) => {
+ try {
+ const { readRegistriesConfig } =
+ await import("../../plugins/registries.js");
+ const { officialSource, communitySource } =
+ await import("../../plugins/registry-index.js");
+ const config = await readRegistriesConfig();
+ res.json({
+ managedByServer: await isLinkedDesktop(),
+ official: { id: "official", url: officialSource().url },
+ community: {
+ id: "community",
+ url: communitySource().url,
+ enabled: config.community.enabled,
+ },
+ custom: config.custom.map((entry) => ({
+ id: entry.id,
+ name: entry.name,
+ url: entry.url,
+ keyId: entry.keyId,
+ enabled: entry.enabled,
+ addedAt: entry.addedAt,
+ })),
+ });
+ } catch (error) {
+ sendManageError(
+ res,
+ error,
+ "Failed to read plugin registries",
+ "plugin_registry",
+ );
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /plugins/registries/preview:
+ * post:
+ * summary: Read a registry index before adding it
+ * description: Fetches the index at url and returns its name, the id it would get, how many plugins it lists and the fingerprint of the key it signs with. Nothing is saved. The index must list a publicKey.
+ * tags:
+ * - Plugins
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - url
+ * properties:
+ * url:
+ * type: string
+ * responses:
+ * 200:
+ * description: What the registry would be added as.
+ * 400:
+ * description: Not an https URL, or the index has no publicKey.
+ * 403:
+ * description: The caller lacks admin.plugins.manage.
+ * 409:
+ * description: The registry is already added.
+ * 502:
+ * description: The index could not be read.
+ */
+router.post(
+ "/registries/preview",
+ authenticateJWT,
+ requireManagePlugins,
+ async (req: Request, res: Response) => {
+ if (await refuseOnLinkedDesktop(res)) return;
+ try {
+ const { previewRegistry } = await import("../../plugins/registries.js");
+ res.json(await previewRegistry(req.body?.url));
+ } catch (error) {
+ sendManageError(
+ res,
+ error,
+ "Failed to read the registry",
+ "plugin_registry",
+ );
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /plugins/registries:
+ * post:
+ * summary: Add a custom plugin registry
+ * description: Reads the index again and pins the key it names. keyId must be the fingerprint the preview showed, otherwise the answer is 409 KEY_MISMATCH and nothing is saved. Every plugin installed from it must be signed by that key.
+ * tags:
+ * - Plugins
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - url
+ * - keyId
+ * properties:
+ * url:
+ * type: string
+ * keyId:
+ * type: string
+ * responses:
+ * 200:
+ * description: The added registry.
+ * 400:
+ * description: Not an https URL, no publicKey, or too many registries.
+ * 403:
+ * description: The caller lacks admin.plugins.manage.
+ * 409:
+ * description: Already added, or the key changed since the preview.
+ * 502:
+ * description: The index could not be read.
+ */
+router.post(
+ "/registries",
+ authenticateJWT,
+ requireManagePlugins,
+ async (req: Request, res: Response) => {
+ if (await refuseOnLinkedDesktop(res)) return;
+ try {
+ const { addRegistry } = await import("../../plugins/registries.js");
+ const entry = await addRegistry(req.body?.url, req.body?.keyId);
+ await auditPluginAction(
+ req,
+ "add_plugin_registry",
+ entry.id,
+ { url: entry.url, keyId: entry.keyId },
+ "plugin_registry",
+ );
+ res.json({
+ id: entry.id,
+ name: entry.name,
+ url: entry.url,
+ keyId: entry.keyId,
+ enabled: entry.enabled,
+ addedAt: entry.addedAt,
+ });
+ } catch (error) {
+ sendManageError(
+ res,
+ error,
+ "Failed to add the registry",
+ "plugin_registry",
+ );
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /plugins/registries/{id}:
+ * patch:
+ * summary: Turn a plugin registry on or off
+ * description: Works for community and custom registries. Plugins already installed from a registry that is off keep running but get no updates.
+ * tags:
+ * - Plugins
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - enabled
+ * properties:
+ * enabled:
+ * type: boolean
+ * responses:
+ * 200:
+ * description: The registry was changed.
+ * 400:
+ * description: Invalid body, or the official registry.
+ * 403:
+ * description: The caller lacks admin.plugins.manage.
+ * 404:
+ * description: No such registry.
+ */
+router.patch(
+ "/registries/:id",
+ authenticateJWT,
+ requireManagePlugins,
+ async (req: Request, res: Response) => {
+ const id = String(req.params.id);
+ const enabled = req.body?.enabled;
+ if (typeof enabled !== "boolean") {
+ res.status(400).json({ error: "enabled must be a boolean" });
+ return;
+ }
+ if (await refuseOnLinkedDesktop(res)) return;
+ try {
+ const { setRegistryEnabled } =
+ await import("../../plugins/registries.js");
+ await setRegistryEnabled(id, enabled);
+ await auditPluginAction(
+ req,
+ enabled ? "enable_plugin_registry" : "disable_plugin_registry",
+ id,
+ {},
+ "plugin_registry",
+ );
+ res.json({ id, enabled });
+ } catch (error) {
+ sendManageError(
+ res,
+ error,
+ "Failed to change the registry",
+ "plugin_registry",
+ );
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /plugins/registries/{id}:
+ * delete:
+ * summary: Remove a custom plugin registry
+ * description: Refused with 409 REGISTRY_IN_USE while plugins installed from it remain, since its key has to stay trusted for them to load.
+ * tags:
+ * - Plugins
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: The registry was removed.
+ * 400:
+ * description: Official and community can only be turned off.
+ * 403:
+ * description: The caller lacks admin.plugins.manage.
+ * 404:
+ * description: No such registry.
+ * 409:
+ * description: Plugins from it are still installed.
+ */
+router.delete(
+ "/registries/:id",
+ authenticateJWT,
+ requireManagePlugins,
+ async (req: Request, res: Response) => {
+ const id = String(req.params.id);
+ if (await refuseOnLinkedDesktop(res)) return;
+ try {
+ const { removeRegistry } = await import("../../plugins/registries.js");
+ await removeRegistry(id);
+ await auditPluginAction(
+ req,
+ "remove_plugin_registry",
+ id,
+ {},
+ "plugin_registry",
+ );
+ res.json({ id });
+ } catch (error) {
+ sendManageError(
+ res,
+ error,
+ "Failed to remove the registry",
+ "plugin_registry",
+ );
+ }
+ },
+);
+
/**
* @openapi
* /plugins/{id}/state:
@@ -1609,10 +1909,11 @@ function readVersion(body: unknown): string | undefined | null {
* @openapi
* /plugins/registry:
* get:
- * summary: List the plugins in the official registry
+ * summary: List the plugins in every registry that is on
* description: >
- * Fetches the official registry index (cached for 15 minutes unless
- * refresh is set) and merges it with what is installed here: the
+ * Fetches the index of every registry that is on (each cached for 15
+ * minutes unless refresh is set), with each one's status under
+ * registries, and merges them with what is installed here: the
* installed version, the newest release this build can run, whether an
* update asks for new capabilities, and the pin and auto-update choices.
* tags:
@@ -1753,10 +2054,10 @@ router.post(
* @openapi
* /plugins/{id}/install:
* post:
- * summary: Install a plugin from the official registry
+ * summary: Install a plugin from a registry
* description: >
- * Downloads the release, checks its sha256 and signature against a key
- * this build trusts, loads it, grants the capabilities it declares and
+ * Downloads the release, checks its sha256 and signature against the keys of
+ * the registry it comes from, loads it, grants the capabilities it declares and
* starts it along with any installed dependency that is off. Without a
* version the newest compatible release is used; naming an older one
* pins the plugin there. A bundled plugin at its shipped version needs
@@ -1786,6 +2087,9 @@ router.post(
* type: string
* enum: [stable, beta]
* description: The channel to follow. beta installs the newest beta when it is newer than stable.
+ * registryId:
+ * type: string
+ * description: The registry to install from, official when unset. It must be turned on, and only the official registry can offer an official plugin id.
* capabilities:
* type: array
* items:
@@ -1798,9 +2102,9 @@ router.post(
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
- * description: Not in the official registry.
+ * description: Not in that registry.
* 409:
- * description: Already installed, incompatible, the consent does not match, or managed by a linked server.
+ * description: Already installed, incompatible, the consent does not match, the registry is off, the id belongs to an official plugin, or managed by a linked server.
* 502:
* description: The registry could not be reached.
*/
@@ -1816,15 +2120,19 @@ router.post(
req.body?.channel === undefined
? "stable"
: parsePluginChannel(req.body.channel);
+ const registryId =
+ req.body?.registryId === undefined ? undefined : req.body.registryId;
if (
!ID_PATTERN.test(pluginId) ||
version === null ||
capabilities === null ||
- channel === null
+ channel === null ||
+ (registryId !== undefined &&
+ (typeof registryId !== "string" || !ID_PATTERN.test(registryId)))
) {
- res
- .status(400)
- .json({ error: "Invalid plugin id, version, channel or capabilities" });
+ res.status(400).json({
+ error: "Invalid plugin id, version, channel, registry or capabilities",
+ });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
@@ -1834,11 +2142,13 @@ router.post(
const result = await installPlugin(pluginId, {
version,
channel,
+ registryId,
userId,
capabilities,
});
await auditPluginAction(req, "install_plugin", pluginId, {
version: result.version,
+ registryId: registryId ?? "official",
capabilities,
});
res.json(result);
@@ -1859,7 +2169,7 @@ router.post(
* post:
* summary: Update or change the installed version of a plugin
* description: >
- * Swaps in another release from the official registry, newer or older.
+ * Swaps in another release from the registry the plugin came from, newer or older.
* If the release declares capabilities the installed one does not, the
* request must set acceptCapabilities and list exactly those added
* capabilities in capabilities, otherwise it answers 409 with code
diff --git a/src/backend/plugins/install-counts.ts b/src/backend/plugins/install-counts.ts
index a0dd5d62b..59b9aad23 100644
--- a/src/backend/plugins/install-counts.ts
+++ b/src/backend/plugins/install-counts.ts
@@ -15,7 +15,12 @@
*/
import { pluginLogger } from "../utils/logger.js";
-import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js";
+import {
+ OFFICIAL_REGISTRY_ID,
+ fetchRegistryStats,
+ officialSource,
+ type RegistrySource,
+} from "./registry-index.js";
export const TELEMETRY_SOURCE = "aggregate-telemetry";
export const DOWNLOADS_SOURCE = "github-releases";
@@ -43,23 +48,33 @@ export function preferredCount(entry: {
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
export async function syncInstallCounts(): Promise {
+ let changed = 0;
+ let sources: RegistrySource[] = [officialSource()];
try {
- const stats = await fetchRegistryStats();
- if (!stats) return 0;
- const entries = [...stats.plugins].map(([pluginId, entry]) => ({
- pluginId,
- ...preferredCount(entry),
- }));
- return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries);
- } catch (error) {
- pluginLogger.warn(
- `Could not sync plugin install counts: ${
- error instanceof Error ? error.message : String(error)
- }`,
- { operation: "plugin_registry" },
- );
- return 0;
+ const { listSources } = await import("./registries.js");
+ sources = await listSources();
+ } catch {
+ // Official only when the registry choices cannot be read.
}
+ for (const source of sources) {
+ try {
+ const stats = await fetchRegistryStats(source);
+ if (!stats) continue;
+ const entries = [...stats.plugins].map(([pluginId, entry]) => ({
+ pluginId,
+ ...preferredCount(entry),
+ }));
+ changed += await (await repository()).upsertMany(source.id, entries);
+ } catch (error) {
+ pluginLogger.warn(
+ `Could not sync plugin install counts from ${source.id}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ { operation: "plugin_registry" },
+ );
+ }
+ }
+ return changed;
}
export async function readInstallCounts(
diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts
index 8521a8e61..862ac6537 100644
--- a/src/backend/plugins/loader.ts
+++ b/src/backend/plugins/loader.ts
@@ -28,7 +28,11 @@ import {
getPluginsDir,
getUnpackedPluginsDir,
} from "./paths.js";
-import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
+import {
+ requireSignedPlugins,
+ verifyAnyTrusted,
+ type KeyTier,
+} from "./trust.js";
import { moveWithRetry } from "./fs-retry.js";
import {
createPluginContext,
@@ -77,6 +81,8 @@ export interface LoadedPlugin {
artifact?: string;
/** Key id that signed the artifact, when a trusted key signed it. */
signedBy?: string;
+ /** Which set of keys that key belongs to. */
+ signedTier?: KeyTier;
}
export interface PluginLoaderOptions {
@@ -345,10 +351,11 @@ export class PluginLoader {
const buffer = await fs.promises.readFile(file);
const sigFile = `${file}.sig`;
let signedBy: string | undefined;
+ let signedTier: KeyTier | undefined;
if (fs.existsSync(sigFile)) {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
- const result = verifyPluginArtifact(
+ const result = verifyAnyTrusted(
buffer,
sha256,
await fs.promises.readFile(sigFile, "utf8"),
@@ -357,6 +364,7 @@ export class PluginLoader {
throw new Error(`signature check failed: ${result.reason}`);
}
signedBy = result.keyId;
+ signedTier = result.tier;
} else if (requireSignedPlugins()) {
throw new Error(
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
@@ -402,7 +410,7 @@ export class PluginLoader {
assertBundledUpdate(
id,
raw?.version,
- signedBy,
+ signedTier === "official" ? signedBy : undefined,
this.bundledVersions.get(id) ?? replaced?.manifest.version,
this.pinned.get(id),
);
@@ -427,6 +435,7 @@ export class PluginLoader {
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
plugin.artifact = file;
plugin.signedBy = signedBy;
+ plugin.signedTier = signedTier;
if (replaced) {
pluginLogger.info(
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
@@ -617,7 +626,8 @@ export class PluginLoader {
// A release signed by a trusted key is official whether it shipped in
// the image or was downloaded, so it may adopt its 2.8 tables too.
const applied = await migratePlugin(plugin.id, plugin.dir, {
- bundled: plugin.source === "bundled" || Boolean(plugin.signedBy),
+ bundled:
+ plugin.source === "bundled" || plugin.signedTier === "official",
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
@@ -849,7 +859,7 @@ export function assertBundledUpdate(
): void {
if (!signedBy) {
throw new Error(
- `a bundled plugin already uses the id "${id}"; only a signed update can replace it`,
+ `a bundled plugin already uses the id "${id}"; only an official signed update can replace it`,
);
}
if (typeof version !== "string" || !semver.valid(version)) {
diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts
index da377dbfe..5ef9a1fec 100644
--- a/src/backend/plugins/manage.ts
+++ b/src/backend/plugins/manage.ts
@@ -1,8 +1,9 @@
/**
* Installing, updating, uninstalling and switching plugins at runtime.
*
- * The official registry is the one real source. With developer mode on, an
- * admin can also install a .tmxplug from a file; that skips the download,
+ * Plugins come from the official registry, plus the community and custom
+ * registries an admin turned on (see registries.ts). With developer mode on,
+ * an admin can also install a .tmxplug from a file; that skips the download,
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
* image and can still be uninstalled: their files are deleted and the id is
* recorded in the settings table, so the shipped copy is ignored from then
@@ -38,11 +39,21 @@ import {
getRegistryStatus,
isApiCompatible,
latestOnChannel,
+ officialSource,
parsePluginChannel,
type PluginChannel,
type RegistryIndex,
+ type RegistryPlugin,
+ type RegistrySource,
+ type RegistryTier,
type RegistryVersion,
} from "./registry-index.js";
+import {
+ findSource,
+ listSources,
+ loadCustomRegistryKeys,
+ pinnedKeyOf,
+} from "./registries.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
import { moveWithRetry } from "./fs-retry.js";
import { readInstallCounts } from "./install-counts.js";
@@ -127,6 +138,7 @@ async function readPins(): Promise