fix(update-manager): add ghcr.io registry support
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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user