@@ -35,32 +35,102 @@ const { Container, Node, Site } = db;
3535const { parseArgs } = require ( path . join ( __dirname , '..' , 'utils' , 'cli' ) ) ;
3636
3737/**
38- * Fetch JSON from a URL with optional headers
38+ * Low-level HTTP GET that returns status, headers, and body without throwing on 4xx
3939 * @param {string } url - The URL to fetch
40- * @param {object } headers - Optional headers
41- * @returns {Promise<object> } Parsed JSON response
40+ * @param {object } headers - Optional request headers
41+ * @returns {Promise<{statusCode: number, headers: object, body: string}> }
4242 */
43- function fetchJson ( url , headers = { } ) {
43+ function httpGet ( url , headers = { } ) {
4444 return new Promise ( ( resolve , reject ) => {
4545 const req = https . get ( url , { headers } , ( res ) => {
4646 let data = '' ;
4747 res . on ( 'data' , chunk => data += chunk ) ;
4848 res . on ( 'end' , ( ) => {
49- if ( res . statusCode >= 400 ) {
50- reject ( new Error ( `HTTP ${ res . statusCode } : ${ data } ` ) ) ;
51- } else {
52- try {
53- resolve ( JSON . parse ( data ) ) ;
54- } catch ( e ) {
55- reject ( new Error ( `Failed to parse JSON: ${ e . message } ` ) ) ;
56- }
57- }
49+ resolve ( { statusCode : res . statusCode , headers : res . headers , body : data } ) ;
5850 } ) ;
5951 } ) ;
6052 req . on ( 'error' , reject ) ;
6153 } ) ;
6254}
6355
56+ /**
57+ * Fetch JSON from a URL with optional headers (throws on non-2xx)
58+ * @param {string } url - The URL to fetch
59+ * @param {object } headers - Optional headers
60+ * @returns {Promise<object> } Parsed JSON response
61+ */
62+ async function fetchJson ( url , headers = { } ) {
63+ const res = await httpGet ( url , headers ) ;
64+ if ( res . statusCode >= 400 ) {
65+ throw new Error ( `HTTP ${ res . statusCode } : ${ res . body } ` ) ;
66+ }
67+ return JSON . parse ( res . body ) ;
68+ }
69+
70+ /**
71+ * Parse a WWW-Authenticate Bearer challenge header
72+ * Example: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"
73+ * @param {string } header - The WWW-Authenticate header value
74+ * @returns {object|null } Parsed fields { realm, service, scope } or null if not Bearer
75+ */
76+ function parseWwwAuthenticate ( header ) {
77+ if ( ! header || ! header . startsWith ( 'Bearer ' ) ) return null ;
78+ const params = { } ;
79+ const regex = / ( \w + ) = " ( [ ^ " ] * ) " / g;
80+ let match ;
81+ while ( ( match = regex . exec ( header ) ) !== null ) {
82+ params [ match [ 1 ] ] = match [ 2 ] ;
83+ }
84+ return params ;
85+ }
86+
87+ /**
88+ * Fetch JSON from a registry URL with automatic token authentication
89+ * Implements the Docker Registry Token Authentication spec:
90+ * 1. Attempt the request
91+ * 2. If 401, parse WWW-Authenticate for Bearer challenge
92+ * 3. Request a token from the auth service
93+ * 4. Retry with the Bearer token
94+ * @param {string } url - The registry URL to fetch
95+ * @param {object } headers - Optional request headers
96+ * @returns {Promise<object> } Parsed JSON response
97+ */
98+ async function authenticatedFetchJson ( url , headers = { } ) {
99+ const res = await httpGet ( url , headers ) ;
100+
101+ if ( res . statusCode === 200 ) {
102+ return JSON . parse ( res . body ) ;
103+ }
104+
105+ if ( res . statusCode !== 401 ) {
106+ throw new Error ( `HTTP ${ res . statusCode } : ${ res . body } ` ) ;
107+ }
108+
109+ // Parse the Bearer challenge from WWW-Authenticate header
110+ const challenge = parseWwwAuthenticate ( res . headers [ 'www-authenticate' ] ) ;
111+ if ( ! challenge || ! challenge . realm ) {
112+ throw new Error ( `Registry returned 401 but no Bearer challenge in WWW-Authenticate header` ) ;
113+ }
114+
115+ // Build token request URL with query parameters from the challenge
116+ const tokenUrl = new URL ( challenge . realm ) ;
117+ if ( challenge . service ) tokenUrl . searchParams . set ( 'service' , challenge . service ) ;
118+ if ( challenge . scope ) tokenUrl . searchParams . set ( 'scope' , challenge . scope ) ;
119+
120+ const tokenData = await fetchJson ( tokenUrl . toString ( ) ) ;
121+ if ( ! tokenData . token ) {
122+ throw new Error ( 'Auth service did not return a token' ) ;
123+ }
124+
125+ // Retry the original request with the Bearer token
126+ headers [ 'Authorization' ] = `Bearer ${ tokenData . token } ` ;
127+ const retryRes = await httpGet ( url , headers ) ;
128+ if ( retryRes . statusCode >= 400 ) {
129+ throw new Error ( `HTTP ${ retryRes . statusCode } after auth: ${ retryRes . body } ` ) ;
130+ }
131+ return JSON . parse ( retryRes . body ) ;
132+ }
133+
64134/**
65135 * Get the digest (sha256 hash) of a Docker/OCI image from the registry
66136 * Handles both single-arch and multi-arch (manifest list) images
@@ -70,27 +140,20 @@ function fetchJson(url, headers = {}) {
70140 * @returns {Promise<string> } Short digest (first 12 chars of sha256)
71141 */
72142async function getImageDigest ( registry , repo , tag ) {
73- let headers = { } ;
74-
75- // Docker Hub requires auth token
76- if ( registry === 'docker.io' || registry === 'registry-1.docker.io' ) {
77- const tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${ repo } :pull` ;
78- const tokenData = await fetchJson ( tokenUrl ) ;
79- headers [ 'Authorization' ] = `Bearer ${ tokenData . token } ` ;
80- }
81-
82143 const registryHost = registry === 'docker.io' ? 'registry-1.docker.io' : registry ;
83144
84- // Fetch manifest - accept both single manifest and manifest list
85- headers [ 'Accept' ] = [
86- 'application/vnd.docker.distribution.manifest.v2+json' ,
87- 'application/vnd.oci.image.manifest.v1+json' ,
88- 'application/vnd.docker.distribution.manifest.list.v2+json' ,
89- 'application/vnd.oci.image.index.v1+json'
90- ] . join ( ', ' ) ;
145+ // Fetch manifest with automatic registry auth challenge-response
146+ const acceptHeaders = {
147+ 'Accept' : [
148+ 'application/vnd.docker.distribution.manifest.v2+json' ,
149+ 'application/vnd.oci.image.manifest.v1+json' ,
150+ 'application/vnd.docker.distribution.manifest.list.v2+json' ,
151+ 'application/vnd.oci.image.index.v1+json'
152+ ] . join ( ', ' )
153+ } ;
91154
92155 const manifestUrl = `https://${ registryHost } /v2/${ repo } /manifests/${ tag } ` ;
93- let manifest = await fetchJson ( manifestUrl , headers ) ;
156+ let manifest = await authenticatedFetchJson ( manifestUrl , { ... acceptHeaders } ) ;
94157
95158 // Handle manifest list (multi-arch) - select amd64/linux
96159 if ( manifest . manifests && Array . isArray ( manifest . manifests ) ) {
@@ -101,10 +164,12 @@ async function getImageDigest(registry, repo, tag) {
101164 throw new Error ( 'No amd64/linux manifest found in manifest list' ) ;
102165 }
103166
104- // Fetch the actual manifest for amd64
105- headers [ 'Accept' ] = 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json' ;
167+ // Fetch the actual manifest for amd64 (reuse same auth flow)
168+ const archHeaders = {
169+ 'Accept' : 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json'
170+ } ;
106171 const archManifestUrl = `https://${ registryHost } /v2/${ repo } /manifests/${ amd64Manifest . digest } ` ;
107- manifest = await fetchJson ( archManifestUrl , headers ) ;
172+ manifest = await authenticatedFetchJson ( archManifestUrl , { ... archHeaders } ) ;
108173 }
109174
110175 // Get config digest from manifest
0 commit comments