From a439e3bd04539854d2fe973b582e4313e19e635c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sun, 19 Jul 2026 09:31:22 -0400 Subject: [PATCH 1/2] feature: plugin updates --- .../default/tables/public_custom_pages.yaml | 3 + .../down.sql | 1 + .../up.sql | 2 + src/custom-pages/custom-pages.controller.ts | 26 ++ src/system/system.service.spec.ts | 103 +++++ src/system/system.service.ts | 390 ++++++++++++------ 6 files changed, 410 insertions(+), 115 deletions(-) create mode 100644 hasura/migrations/default/1872000000200_custom_page_deployments/down.sql create mode 100644 hasura/migrations/default/1872000000200_custom_page_deployments/up.sql create mode 100644 src/system/system.service.spec.ts diff --git a/hasura/metadata/databases/default/tables/public_custom_pages.yaml b/hasura/metadata/databases/default/tables/public_custom_pages.yaml index 51a55710..a2b4cd2a 100644 --- a/hasura/metadata/databases/default/tables/public_custom_pages.yaml +++ b/hasura/metadata/databases/default/tables/public_custom_pages.yaml @@ -17,6 +17,7 @@ insert_permissions: - is_default - nav_group - nav_order + - deployments comment: "" select_permissions: - role: administrator @@ -34,6 +35,7 @@ select_permissions: - is_default - nav_group - nav_order + - deployments - created_at - updated_at filter: {} @@ -247,6 +249,7 @@ update_permissions: - is_default - nav_group - nav_order + - deployments filter: {} check: {} comment: "" diff --git a/hasura/migrations/default/1872000000200_custom_page_deployments/down.sql b/hasura/migrations/default/1872000000200_custom_page_deployments/down.sql new file mode 100644 index 00000000..408af4b5 --- /dev/null +++ b/hasura/migrations/default/1872000000200_custom_page_deployments/down.sql @@ -0,0 +1 @@ +ALTER TABLE "public"."custom_pages" DROP COLUMN IF EXISTS "deployments"; diff --git a/hasura/migrations/default/1872000000200_custom_page_deployments/up.sql b/hasura/migrations/default/1872000000200_custom_page_deployments/up.sql new file mode 100644 index 00000000..23a4ed05 --- /dev/null +++ b/hasura/migrations/default/1872000000200_custom_page_deployments/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE "public"."custom_pages" + ADD COLUMN IF NOT EXISTS "deployments" jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/src/custom-pages/custom-pages.controller.ts b/src/custom-pages/custom-pages.controller.ts index 3def378b..7dbb6d7a 100644 --- a/src/custom-pages/custom-pages.controller.ts +++ b/src/custom-pages/custom-pages.controller.ts @@ -3,6 +3,7 @@ import { Request, Response } from "express"; import { User } from "../auth/types/User"; import { isRoleAbove } from "src/utilities/isRoleAbove"; import { e_player_roles_enum } from "generated"; +import { SystemService } from "src/system/system.service"; @Controller("custom-pages") export class CustomPagesController { @@ -105,6 +106,9 @@ export class CustomPagesController { scope: manifest.scope ?? null, module: manifest.module ?? manifest.exposedModule ?? null, requiredRole: manifest.requiredRole ?? null, + deployments: CustomPagesController.resolveDeployments( + manifest.deployments, + ), }); } catch { return response @@ -113,6 +117,28 @@ export class CustomPagesController { } } + // Deployments the plugin wants watched for image updates. The panel restarts + // whatever is named here, and the manifest is third-party input, so reserved + // first-party names are dropped -- otherwise a plugin could declare "api" and + // get the panel to roll itself. Invalid entries are dropped rather than + // failing the detect, so one typo doesn't block registering the plugin. + private static resolveDeployments(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((name): name is string => { + return ( + typeof name === "string" && + name.length <= 63 && + /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name) && + !SystemService.isReservedDeployment(name) + ); + }) + .slice(0, 8); + } + // Best-effort SSRF guard for the admin-only detect fetch: blocks loopback, // link-local, private-range IP literals and obvious internal hostnames. // Hostnames resolving to private IPs are not caught -- admins are trusted; diff --git a/src/system/system.service.spec.ts b/src/system/system.service.spec.ts new file mode 100644 index 00000000..f99dd194 --- /dev/null +++ b/src/system/system.service.spec.ts @@ -0,0 +1,103 @@ +import { SystemService } from "./system.service"; + +describe("SystemService.parseImageRef", () => { + // The first-party services this has always had to handle, plus the shapes a + // third-party plugin can realistically ship. Getting the registry/repository + // split wrong sends the manifest request to the wrong host and every plugin + // silently reports "no update available". + const vectors: Array<{ + image: string; + registry: string; + repository: string; + tag: string; + }> = [ + { + image: "ghcr.io/5stackgg/api:latest", + registry: "ghcr.io", + repository: "5stackgg/api", + tag: "latest", + }, + // A plugin published under someone else's org -- the case the old + // hardcoded `5stackgg` path could not express at all. + { + image: "ghcr.io/lukepolo/5stack-inventory-plugin-frontend:latest", + registry: "ghcr.io", + repository: "lukepolo/5stack-inventory-plugin-frontend", + tag: "latest", + }, + // The deployed tag is the release channel; beta must not collapse to latest. + { + image: "ghcr.io/5stackgg/web:beta", + registry: "ghcr.io", + repository: "5stackgg/web", + tag: "beta", + }, + { + image: "docker.io/library/nginx:1.27", + registry: "docker.io", + repository: "library/nginx", + tag: "1.27", + }, + // No registry host and no tag: Docker Hub official image, implicit latest. + { + image: "nginx", + registry: "docker.io", + repository: "library/nginx", + tag: "latest", + }, + // Bare namespaced image is Hub too -- "myorg" has no dot, so it is not a host. + { + image: "myorg/myimage:v2", + registry: "docker.io", + repository: "myorg/myimage", + tag: "v2", + }, + // A port in the host means the first segment IS the registry, and the colon + // in it must not be mistaken for the tag separator. + { + image: "registry.local:5000/team/app:dev", + registry: "registry.local:5000", + repository: "team/app", + tag: "dev", + }, + { + image: "registry.local:5000/team/app", + registry: "registry.local:5000", + repository: "team/app", + tag: "latest", + }, + ]; + + for (const { image, ...expected } of vectors) { + it(`parses ${image}`, () => { + expect(SystemService.parseImageRef(image)).toEqual(expected); + }); + } + + // A digest-pinned image already names exact bytes, so there is nothing to + // poll -- returning a ref would make us compare a digest against itself. + it.each(["ghcr.io/5stackgg/api@sha256:abc123", "", null, undefined])( + "returns null for %p", + (image) => { + expect(SystemService.parseImageRef(image as string)).toBeNull(); + }, + ); +}); + +describe("SystemService.isReservedDeployment", () => { + // Plugin manifests are third-party input. If these names were claimable, a + // plugin could get the panel to restart the panel. + it.each(["api", "web", "hasura", "panel", "redis", "timescaledb"])( + "reserves %s", + (name) => { + expect(SystemService.isReservedDeployment(name)).toBe(true); + }, + ); + + it.each(["inventory-frontend", "inventory-backend", "example-plugin"])( + "allows %s", + (name) => { + expect(SystemService.isReservedDeployment(name)).toBe(false); + }, + ); +}); diff --git a/src/system/system.service.ts b/src/system/system.service.ts index 676ab9fa..9327587e 100644 --- a/src/system/system.service.ts +++ b/src/system/system.service.ts @@ -24,10 +24,6 @@ export class SystemService { private featuresDetected = false; - private static SERVICE_TO_REGISTRY: Record = { - "game-server-node-connector-nvidia": "game-server-node-connector", - }; - private static TRACKED_APPS = [ "api", "web", @@ -37,8 +33,21 @@ export class SystemService { "hasura", ]; - private serviceRegistry(service: string) { - return SystemService.SERVICE_TO_REGISTRY[service] ?? service; + // Deployment names a custom page is never allowed to claim. A plugin manifest + // is third-party input, so without this a plugin declaring `deployments: + // ["api"]` would render an Update button that restarts the panel itself. + private static RESERVED_DEPLOYMENTS = [ + ...SystemService.TRACKED_APPS, + "panel", + "typesense", + "timescaledb", + "redis", + "minio", + "mediamtx", + ]; + + public static isReservedDeployment(name: string) { + return SystemService.RESERVED_DEPLOYMENTS.includes(name); } constructor( @@ -186,14 +195,7 @@ export class SystemService { } public async updateServices() { - const services = await this.getServices(); - const latestVersions = await this.getLatestVersions(); - - for (const { pod, service, version } of Object.values(services)) { - if (version === latestVersions[this.serviceRegistry(service)]) { - continue; - } - + for (const { service, pod } of await this.getOutdated()) { void this.restartService(service, pod); } } @@ -212,10 +214,6 @@ export class SystemService { ); await this.restartPod(pod); } - } finally { - await this.cache.forget( - this.getServiceCacheKey(this.serviceRegistry(service)), - ); } } @@ -233,20 +231,7 @@ export class SystemService { }); } - const services = await this.getServices(); - const latestVersions = await this.getLatestVersions(); - - for (const { service, version, pod } of Object.values(services)) { - const latestVersion = latestVersions[this.serviceRegistry(service)]; - if (version !== latestVersion) { - hasUpdates.push({ - service, - pod, - currentVersion: version, - newVersion: latestVersion, - }); - } - } + hasUpdates.push(...(await this.getOutdated())); await this.hasura.mutation({ insert_settings_one: { @@ -265,55 +250,136 @@ export class SystemService { }); } - public async getLatestVersions(): Promise> { - const registries = [ - "api", - "web", - "game-server-node-connector", - "demo-parser", - ]; - const latestVersions: Record = {}; - - for (const registry of registries) { - const data = await this.cache.remember<{ - service: string; - latestVersion: string; - }>( - this.getServiceCacheKey(registry), + // Everything whose running image digest no longer matches the digest its tag + // points at. Shared by setVersions (report it) and updateServices (apply it), + // so the header list and the Update button can never disagree. + public async getOutdated() { + const outdated: Array<{ + service: string; + plugin?: string; + pod: string; + currentVersion: string; + newVersion: string; + }> = []; + + const services: Array<{ + pod: string; + service: string; + plugin?: string; + image: string; + version: string; + }> = [...(await this.getServices()), ...(await this.getPluginServices())]; + + for (const { service, plugin, pod, image, version } of services) { + const newVersion = await this.getLatestDigest(image); + + // An unreadable registry or pod tells us nothing. Reporting on it would + // show a phantom update; restarting on it would be an endless rollout. + if (!version || !newVersion || version === newVersion) { + continue; + } + + outdated.push({ + service, + plugin, + pod, + currentVersion: version, + newVersion, + }); + } + + return outdated; + } + + // The digest the given tag currently points at, or null if the registry is + // unreachable/unauthenticated. Never throws: a plugin pointed at a broken or + // private registry must not take down the check for api/web. + public async getLatestDigest(image: string): Promise { + const ref = SystemService.parseImageRef(image); + + if (!ref) { + return null; + } + + const { registry, repository, tag } = ref; + + try { + return await this.cache.remember( + this.getServiceCacheKey(`${registry}/${repository}:${tag}`), async () => { - const token = await this.getToken(registry); - const latestManifestResponse = await fetch( - `https://ghcr.io/v2/5stackgg/${registry}/manifests/latest`, + const token = await this.getRegistryToken(registry, repository); + + const response = await fetch( + `https://${registry === "docker.io" ? "registry-1.docker.io" : registry}/v2/${repository}/manifests/${tag}`, { headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.oci.image.index.v1+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + // Multi-arch images answer with an index, single-arch ones with + // a plain manifest. Third-party plugins publish both, so accept + // either rather than 404ing on the ones that aren't multi-arch. + Accept: [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ].join(","), }, }, ); - if (!latestManifestResponse.ok) { + if (!response.ok) { throw new Error( - `Failed to fetch manifest [${registry}]: ${latestManifestResponse.statusText}`, + `Failed to fetch manifest [${image}]: ${response.statusText}`, ); } - return { - service: registry, - latestVersion: latestManifestResponse.headers.get( - "docker-content-digest", - ), - }; + return response.headers.get("docker-content-digest"); }, 300, ); + } catch (error) { + this.logger.warn(`unable to resolve latest digest for ${image}`, error); + return null; + } + } - latestVersions[data.service] = data.latestVersion; + public static parseImageRef(image: string) { + // A digest-pinned image has nothing to poll -- the reference already names + // the exact bytes, so it can never be out of date. + if (!image || image.includes("@")) { + return null; } - latestVersions.hasura = latestVersions.api; + let remainder = image; + let registry = "docker.io"; - return latestVersions; + const slash = remainder.indexOf("/"); + const host = slash === -1 ? "" : remainder.slice(0, slash); + if (host.includes(".") || host.includes(":") || host === "localhost") { + registry = host; + remainder = remainder.slice(slash + 1); + } + + let tag = "latest"; + const colon = remainder.lastIndexOf(":"); + if (colon !== -1 && !remainder.slice(colon + 1).includes("/")) { + tag = remainder.slice(colon + 1); + remainder = remainder.slice(0, colon); + } + + if (!remainder) { + return null; + } + + return { + registry, + // Official Docker Hub images are addressed as library/. + repository: + registry === "docker.io" && !remainder.includes("/") + ? `library/${remainder}` + : remainder, + tag, + }; } public async restartPod(pod: string) { @@ -349,84 +415,178 @@ export class SystemService { } public async getServices() { - const nodes = await this.apiClient.listNode(); - - let podList = await this.apiClient.listNamespacedPod({ - namespace: "5stack", - }); - - const pods = podList.items.filter((pod) => { - if (pod.metadata.labels.codepier) { - return false; - } + const services: Array<{ + pod: string; + service: string; + image: string; + version: string; + }> = []; - const node = nodes.items.find((node) => { - return node.metadata.name === pod.spec?.nodeName; - }); + for (const pod of await this.readyPods()) { + const service = pod.metadata.labels?.app; - const status = node?.status?.conditions.find( - (condition) => condition.type === "Ready", - )?.status; - - if (status !== "True") { - return false; + if (!SystemService.TRACKED_APPS.includes(service)) { + continue; } - return SystemService.TRACKED_APPS.includes(pod.metadata.labels.app); - }); - - const services: Array<{ pod: string; service: string; version: string }> = - []; + // hasura runs the graphql engine, but it's the api image in its init + // container that tracks the panel's version. + const [spec, status] = + service === "hasura" + ? [ + pod.spec?.initContainers?.[0], + pod.status?.initContainerStatuses?.[0], + ] + : [pod.spec?.containers?.[0], pod.status?.containerStatuses?.[0]]; - for (const pod of pods) { - const service = pod.metadata.labels.app; services.push({ pod: pod.metadata.name, service, - version: await this.getServiceVersion(service, pod.metadata.name), + image: spec?.image, + version: SystemService.imageDigest(status?.imageID), }); } return services; } - private async getServiceVersion(service: string, podName: string) { - try { - const pod = await this.apiClient.readNamespacedPod({ - name: podName, - namespace: "5stack", - }); + // Deployments declared by registered custom pages. The image is read off the + // live deployment rather than the plugin manifest, so it can never drift from + // what is actually running -- the manifest only supplies the name. + private async getPluginServices() { + const services: Array<{ + pod: string; + service: string; + plugin: string; + image: string; + version: string; + }> = []; - let imageID: string | undefined; + let customPages: Array<{ title: string; deployments: unknown }>; - if (service === "hasura") { - imageID = pod.status?.initContainerStatuses?.[0]?.imageID; - } else { - imageID = pod.status?.containerStatuses?.[0]?.imageID; + try { + ({ custom_pages: customPages } = await this.hasura.query({ + custom_pages: { + __args: { + where: { + enabled: { + _eq: true, + }, + }, + }, + title: true, + deployments: true, + }, + })); + } catch (error) { + this.logger.warn("unable to fetch custom pages", error); + return services; + } + + for (const { title, deployments } of customPages) { + if (!Array.isArray(deployments)) { + continue; } - if (!imageID) { - throw new Error("imageID not found"); + for (const name of deployments) { + if ( + typeof name !== "string" || + SystemService.isReservedDeployment(name) + ) { + continue; + } + + try { + const deployment = await this.appsClient.readNamespacedDeployment({ + name, + namespace: "5stack", + }); + + const image = deployment.spec?.template?.spec?.containers?.[0]?.image; + + if (!image) { + continue; + } + + const [pod] = await this.readyPods( + Object.entries(deployment.spec?.selector?.matchLabels ?? {}) + .map(([label, value]) => `${label}=${value}`) + .join(","), + ); + + if (!pod) { + continue; + } + + services.push({ + pod: pod.metadata.name, + service: name, + plugin: title, + image, + version: SystemService.imageDigest( + pod.status?.containerStatuses?.[0]?.imageID, + ), + }); + } catch (error) { + // A plugin can name a deployment that was never installed, or was + // removed out from under it. That's its problem, not the panel's. + this.logger.warn( + `unable to inspect plugin deployment ${name}`, + error, + ); + } } + } + + return services; + } + + private async readyPods(labelSelector?: string) { + const nodes = await this.apiClient.listNode(); - const parts = imageID.split("@"); - if (parts.length < 2) { - throw new Error("imageID format invalid"); + const podList = await this.apiClient.listNamespacedPod({ + namespace: "5stack", + labelSelector, + }); + + return podList.items.filter((pod) => { + if (pod.metadata.labels?.codepier) { + return false; } - return parts[1]; - } catch (error) { - this.logger.error(`Error fetching pod info: ${error?.message || error}`); - } + const node = nodes.items.find((node) => { + return node.metadata.name === pod.spec?.nodeName; + }); + + return ( + node?.status?.conditions.find((condition) => condition.type === "Ready") + ?.status === "True" + ); + }); } - private async getToken(image: string) { - const tokenResponse = await fetch( - `https://ghcr.io/token?scope=repository:5stackgg/${image}:pull`, + private static imageDigest(imageID?: string) { + return imageID?.includes("@") ? imageID.split("@")[1] : undefined; + } + + private async getRegistryToken(registry: string, repository: string) { + const scope = `repository:${repository}:pull`; + + const response = await fetch( + registry === "docker.io" + ? `https://auth.docker.io/token?service=registry.docker.io&scope=${scope}` + : `https://${registry}/token?scope=${scope}`, ); - const { token } = await tokenResponse.json(); - return token; + if (!response.ok) { + // Not every registry issues anonymous tokens; try the manifest without + // one rather than giving up here. + return null; + } + + const { token } = (await response.json()) as { token?: string }; + + return token ?? null; } private getServiceCacheKey(service: string) { From 96d64d3abeeb21667ab3212e7b169b82e0e4de59 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sun, 19 Jul 2026 09:37:50 -0400 Subject: [PATCH 2/2] wip --- src/system/system.service.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/system/system.service.ts b/src/system/system.service.ts index 9327587e..5ce56896 100644 --- a/src/system/system.service.ts +++ b/src/system/system.service.ts @@ -275,7 +275,7 @@ export class SystemService { // An unreadable registry or pod tells us nothing. Reporting on it would // show a phantom update; restarting on it would be an endless rollout. - if (!version || !newVersion || version === newVersion) { + if (!image || !version || !newVersion || version === newVersion) { continue; } @@ -338,7 +338,9 @@ export class SystemService { 300, ); } catch (error) { - this.logger.warn(`unable to resolve latest digest for ${image}`, error); + this.logger.warn( + `[updates] registry lookup failed for ${image}: ${error?.message ?? error}`, + ); return null; } } @@ -484,15 +486,19 @@ export class SystemService { } for (const { title, deployments } of customPages) { - if (!Array.isArray(deployments)) { + if (!Array.isArray(deployments) || deployments.length === 0) { continue; } for (const name of deployments) { - if ( - typeof name !== "string" || - SystemService.isReservedDeployment(name) - ) { + if (typeof name !== "string") { + continue; + } + + if (SystemService.isReservedDeployment(name)) { + this.logger.warn( + `plugin "${title}" tried to claim reserved deployment "${name}"`, + ); continue; } @@ -532,7 +538,7 @@ export class SystemService { // removed out from under it. That's its problem, not the panel's. this.logger.warn( `unable to inspect plugin deployment ${name}`, - error, + error?.body?.message ?? error?.message ?? error, ); } }