feat: Implement Docker registry token endpoint and enhance registry request handling

This commit is contained in:
2025-11-25 19:46:18 +00:00
parent 76793d512b
commit 5cf9c72dd4
4 changed files with 201 additions and 4 deletions

View File

@@ -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<Response> {
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<Response> {
try {
const tags = await this.oneboxRef.registry.getImageTags(serviceName);

View File

@@ -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();

View File

@@ -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<string, string> = {};
req.headers.forEach((value, key) => {
headers[key] = value;
});
const query: Record<string, string> = {};
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<string> {
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
*/