From de3215f7049fa8e5d2df711de77976c00bbb48bb Mon Sep 17 00:00:00 2001 From: Krystie Date: Tue, 14 Jul 2026 04:10:35 -0700 Subject: [PATCH] fix(update-manager): add ghcr.io registry support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, /api/v1/updates/available silently skipped any image on a non-Docker-Hub registry (line 154 routing: 'ghcr.io/seerr-team/seerr' has 3 slash-delimited segments → 'Custom registry not yet supported'). Symptoms: 4 of 6 production containers (seerr, albyhub, phoenixd, velxio) all on ghcr.io. UpdateManager would log 'Custom registry not yet supported: ghcr.io/...' and return null. Updates invisible in the Updates modal, even when newer images existed. Fix: - Rewrite image parsing to detect the tag-vs-registry-host colon correctly (lastColon > lastSlash guard, handles ghcr.io:443/path). - Add getGhcrDigest() mirroring the DockerHub pattern, against ghcr.io's OCI distribution endpoint. Same bearer-token auth flow, the existing parseAuthHeader + authenticateAndGetDigest already handle the WWW-Authenticate format ghcr.io returns. - Multiple Accept headers for the response — Docker Hub used manifest.v2 only; GHCR serves manifest.list.v2 for multi-arch tags like ':latest', and the response is the multi-arch manifest itself with the platform-specific digest in the Child header chain. We use the digest from the 'docker-content-digest' response header, which the GHCR endpoint sets even for manifest lists. Verified: UpdateManager log now shows 'Found 6 updates available' on DNS2 (previously 0-2, all from non-Docker-Hub images). /api/v1/updates/available returns entries for seerr, albyhub, etc. Per-tile Update button (core.js:811) + Updates modal Update/Update All (features.js:1508 + L()) are already wired and now functional for all registries. --- dashcaddy-api/src/managers/update-manager.js | 79 +++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/dashcaddy-api/src/managers/update-manager.js b/dashcaddy-api/src/managers/update-manager.js index 4ecd4f8..f157a45 100644 --- a/dashcaddy-api/src/managers/update-manager.js +++ b/dashcaddy-api/src/managers/update-manager.js @@ -146,17 +146,29 @@ class UpdateManager extends EventEmitter { */ async getLatestImageDigest(imageName) { try { - // Parse image name - const [repository, tag] = imageName.split(':'); - const imageTag = tag || 'latest'; - - // For Docker Hub images - if (!repository.includes('/') || repository.split('/').length === 2) { - return await this.getDockerHubDigest(repository, imageTag); + // Parse image name — strip any leading registry host first + let imageTag = 'latest'; + let remainder = imageName; + const lastColon = imageName.lastIndexOf(':'); + // Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`) + const lastSlash = imageName.lastIndexOf('/'); + if (lastColon > lastSlash) { + imageTag = imageName.substring(lastColon + 1); + remainder = imageName.substring(0, lastColon); } - - // For other registries (would need authentication) - console.warn(`[UpdateManager] Custom registry not yet supported: ${repository}`); + + // ghcr.io: GitHub Container Registry (tokenless for public images) + if (remainder.startsWith('ghcr.io/')) { + return await this.getGhcrDigest(remainder, imageTag); + } + + // Docker Hub images (library/nginx OR org/image with single slash) + if (!remainder.includes('/') || remainder.split('/').length === 2) { + return await this.getDockerHubDigest(remainder, imageTag); + } + + // gcr.io / quay.io / registry.gitlab.com — currently unsupported + console.warn(`[UpdateManager] Custom registry not yet supported: ${remainder}`); return null; } catch (error) { console.error(`[UpdateManager] Error getting digest for ${imageName}:`, error.message); @@ -164,6 +176,53 @@ class UpdateManager extends EventEmitter { } } + /** + * Get image digest from GitHub Container Registry (ghcr.io) + * Public images are tokenless via the registry-1.docker.io-style bearer flow, + * but using ghcr.io's own auth endpoint. + */ + async getGhcrDigest(repository, tag) { + // ghcr.io uses the same OCI distribution spec as Docker Hub + const imageRepo = repository.replace(/^ghcr\.io\//, ''); + return new Promise((resolve, reject) => { + const options = { + hostname: 'ghcr.io', + path: `/v2/${imageRepo}/manifests/${tag}`, + method: 'GET', + headers: { + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json' + } + }; + + const req = https.request(options, (res) => { + if (res.statusCode === 401) { + const authHeader = res.headers['www-authenticate']; + const authUrl = this.parseAuthHeader(authHeader); + if (authUrl) { + // ghcr.io auth endpoint accepts scope=repository:owner/name:pull + this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject); + } else { + reject(new Error('Authentication required but no auth URL found')); + } + return; + } + + if (res.statusCode !== 200) { + // Drain body to avoid socket leak + res.resume(); + reject(new Error(`ghcr.io returned HTTP ${res.statusCode}`)); + return; + } + + const digest = res.headers['docker-content-digest']; + resolve(digest || null); + }); + + req.on('error', reject); + req.end(); + }); + } + /** * Get image digest from Docker Hub */