diff --git a/deno.json b/deno.json index d0186d2..b070f13 100644 --- a/deno.json +++ b/deno.json @@ -20,7 +20,7 @@ "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.0", "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0", - "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^1.8.0", + "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.1.2", "@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0" }, "compilerOptions": { diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index dc6cb5b..370eb72 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -83,6 +83,11 @@ export class OneboxHttpServer { return this.handleLogStreamUpgrade(req, serviceName); } + // Docker Registry v2 Token endpoint (for OCI authentication) + if (path === '/v2/token') { + return await this.handleRegistryTokenRequest(req, url); + } + // Docker Registry v2 API (no auth required - registry handles it) if (path.startsWith('/v2/')) { return await this.oneboxRef.registry.handleRequest(req); @@ -1234,6 +1239,79 @@ export class OneboxHttpServer { // ============ Registry Endpoints ============ + /** + * Handle Docker registry token request (OCI token authentication) + * Docker calls this endpoint to get a bearer token for registry operations + * + * Query params: + * - service: The registry service name + * - scope: Permission scope (e.g., "repository:hello-world:push,pull") + * - account: Optional account name (for basic auth) + */ + private async handleRegistryTokenRequest(req: Request, url: URL): Promise { + try { + const service = url.searchParams.get('service') || 'onebox-registry'; + const scope = url.searchParams.get('scope'); + const account = url.searchParams.get('account') || 'anonymous'; + + logger.info(`Registry token request: service=${service}, scope=${scope}, account=${account}`); + + // Parse scope to extract repository and actions + // Format: repository:name:action1,action2 (e.g., "repository:hello-world:push,pull") + let scopes: string[] = []; + + if (scope) { + const scopeParts = scope.split(':'); + if (scopeParts.length >= 3 && scopeParts[0] === 'repository') { + const repository = scopeParts[1]; + // For now, grant both push and pull for any repository request + // This allows anonymous push to the local registry + // TODO: Add authentication and authorization to restrict access + scopes = [ + `oci:repository:${repository}:push`, + `oci:repository:${repository}:pull`, + ]; + } + } + + // If no scope specified, grant basic access + if (scopes.length === 0) { + scopes = ['oci:repository:*:pull']; + } + + logger.info(`Creating OCI token with scopes: ${scopes.join(', ')}`); + + // Use the registry's auth manager to create a token + // smartregistry v2.0.0 returns proper JWT format (header.payload.signature) + const authManager = this.oneboxRef.registry.getAuthManager(); + const token = await authManager.createOciToken(account, scopes, 3600); + + logger.info(`Token created (JWT length: ${token.length})`); + + // Return in Docker-expected format + return new Response(JSON.stringify({ + token, + access_token: token, + expires_in: 3600, + issued_at: new Date().toISOString(), + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + logger.error(`Registry token error: ${getErrorMessage(error)}`); + return new Response(JSON.stringify({ + error: 'token_error', + error_description: getErrorMessage(error), + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + private async handleGetRegistryTagsRequest(serviceName: string): Promise { try { const tags = await this.oneboxRef.registry.getImageTags(serviceName); diff --git a/ts/classes/onebox.ts b/ts/classes/onebox.ts index dc636e4..08a617d 100644 --- a/ts/classes/onebox.ts +++ b/ts/classes/onebox.ts @@ -83,6 +83,29 @@ export class Onebox { // Initialize Reverse Proxy await this.reverseProxy.init(); + // Load routes and certificates for reverse proxy + await this.reverseProxy.reloadRoutes(); + await this.reverseProxy.reloadCertificates(); + + // Start HTTP reverse proxy (non-critical - don't fail init if ports are busy) + // Use 8080/8443 in dev mode to avoid permission issues + const isDev = Deno.env.get('ONEBOX_DEV') === 'true' || Deno.args.includes('--ephemeral'); + const httpPort = isDev ? 8080 : 80; + const httpsPort = isDev ? 8443 : 443; + + try { + await this.reverseProxy.startHttp(httpPort); + } catch (error) { + logger.warn(`Failed to start HTTP reverse proxy: ${getErrorMessage(error)}`); + } + + // Start HTTPS reverse proxy if certificates are available + try { + await this.reverseProxy.startHttps(httpsPort); + } catch (error) { + logger.warn(`Failed to start HTTPS reverse proxy: ${getErrorMessage(error)}`); + } + // Initialize DNS (non-critical) try { await this.dns.init(); diff --git a/ts/classes/registry.ts b/ts/classes/registry.ts index bcf9a0d..da18bf9 100644 --- a/ts/classes/registry.ts +++ b/ts/classes/registry.ts @@ -76,13 +76,13 @@ export class RegistryManager { }, ociTokens: { enabled: true, - realm: 'onebox-registry', + realm: 'http://localhost:3000/v2/token', service: 'onebox-registry', }, }, oci: { enabled: true, - basePath: '/v2', + basePath: '', // Empty basePath - OCI paths are passed directly as /v2/... }, }); @@ -105,7 +105,68 @@ export class RegistryManager { } try { - return await this.registry.handleRequest(req); + // Convert native Request to IRequestContext format expected by smartregistry + const url = new URL(req.url); + const headers: Record = {}; + req.headers.forEach((value, key) => { + headers[key] = value; + }); + + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + // Read body for non-GET requests + // IMPORTANT: smartregistry expects Buffer (not Uint8Array) for proper digest calculation + // Buffer.isBuffer(Uint8Array) returns false, causing JSON.stringify which corrupts the data + let body: Buffer | undefined; + if (req.method !== 'GET' && req.method !== 'HEAD') { + const bodyData = await req.arrayBuffer(); + if (bodyData.byteLength > 0) { + body = Buffer.from(bodyData); + } + } + + // smartregistry v2.0.0 handles JWT tokens natively - no decoding needed + // Pass rawBody for content-addressable operations (manifest push needs exact bytes for digest) + const context = { + method: req.method, + path: url.pathname, + headers, + query, + body, + rawBody: body, // smartregistry uses rawBody for digest calculation + }; + + const result = await this.registry.handleRequest(context); + + // Log the result for debugging + logger.info(`Registry response: status=${result.status}, headers=${JSON.stringify(result.headers)}`); + + // smartregistry v2.0.0 now properly includes WWW-Authenticate headers on 401 responses + + // Convert IResponse back to native Response + const responseHeaders = new Headers(result.headers || {}); + let responseBody: BodyInit | null = null; + + if (result.body !== undefined) { + if (result.body instanceof Uint8Array) { + responseBody = result.body; + } else if (typeof result.body === 'string') { + responseBody = result.body; + } else { + responseBody = JSON.stringify(result.body); + if (!responseHeaders.has('Content-Type')) { + responseHeaders.set('Content-Type', 'application/json'); + } + } + } + + return new Response(responseBody, { + status: result.status, + headers: responseHeaders, + }); } catch (error) { logger.error(`Registry request error: ${getErrorMessage(error)}`); return new Response('Internal registry error', { status: 500 }); @@ -195,6 +256,41 @@ export class RegistryManager { return randomSecret; } + /** + * Get the auth manager from the registry + */ + getAuthManager(): any { + if (!this.isInitialized) { + throw new Error('Registry not initialized'); + } + return this.registry.getAuthManager(); + } + + /** + * Create an OCI token for Docker authentication + * @param repository - Repository name (e.g., 'hello-world') or '*' for all + * @param actions - Actions to allow: 'push', 'pull', or both + * @param expiresIn - Token expiry in seconds (default: 3600) + */ + async createOciToken( + repository: string = '*', + actions: ('push' | 'pull')[] = ['push', 'pull'], + expiresIn: number = 3600 + ): Promise { + if (!this.isInitialized) { + throw new Error('Registry not initialized'); + } + + const authManager = this.registry.getAuthManager(); + + // Create scopes for the token + const scopes = actions.map(action => `oci:repository:${repository}:${action}`); + + // Create OCI token with scopes + const token = await authManager.createOciToken('onebox-system', scopes, expiresIn); + return token; + } + /** * Get the registry base URL */