feat: Implement Docker registry token endpoint and enhance registry request handling
This commit is contained in:
@@ -20,7 +20,7 @@
|
|||||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.0",
|
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.0",
|
||||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
"@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"
|
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export class OneboxHttpServer {
|
|||||||
return this.handleLogStreamUpgrade(req, serviceName);
|
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)
|
// Docker Registry v2 API (no auth required - registry handles it)
|
||||||
if (path.startsWith('/v2/')) {
|
if (path.startsWith('/v2/')) {
|
||||||
return await this.oneboxRef.registry.handleRequest(req);
|
return await this.oneboxRef.registry.handleRequest(req);
|
||||||
@@ -1234,6 +1239,79 @@ export class OneboxHttpServer {
|
|||||||
|
|
||||||
// ============ Registry Endpoints ============
|
// ============ 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> {
|
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const tags = await this.oneboxRef.registry.getImageTags(serviceName);
|
const tags = await this.oneboxRef.registry.getImageTags(serviceName);
|
||||||
|
|||||||
@@ -83,6 +83,29 @@ export class Onebox {
|
|||||||
// Initialize Reverse Proxy
|
// Initialize Reverse Proxy
|
||||||
await this.reverseProxy.init();
|
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)
|
// Initialize DNS (non-critical)
|
||||||
try {
|
try {
|
||||||
await this.dns.init();
|
await this.dns.init();
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ export class RegistryManager {
|
|||||||
},
|
},
|
||||||
ociTokens: {
|
ociTokens: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
realm: 'onebox-registry',
|
realm: 'http://localhost:3000/v2/token',
|
||||||
service: 'onebox-registry',
|
service: 'onebox-registry',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
oci: {
|
oci: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
basePath: '/v2',
|
basePath: '', // Empty basePath - OCI paths are passed directly as /v2/...
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +105,68 @@ export class RegistryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Registry request error: ${getErrorMessage(error)}`);
|
logger.error(`Registry request error: ${getErrorMessage(error)}`);
|
||||||
return new Response('Internal registry error', { status: 500 });
|
return new Response('Internal registry error', { status: 500 });
|
||||||
@@ -195,6 +256,41 @@ export class RegistryManager {
|
|||||||
return randomSecret;
|
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
|
* Get the registry base URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user