fix(update-manager): add ghcr.io registry support
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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.
This commit is contained in:
Krystie
2026-07-14 04:10:35 -07:00
parent fb8942a3fa
commit de3215f704
+68 -9
View File
@@ -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
*/