feat: Implement Docker registry token endpoint and enhance registry request handling
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user