feat(registries): add private registry credential management (Team Pro) (#240)

Add centralized credential storage for private Docker registries with
support for Docker Hub, GHCR, AWS ECR, and self-hosted registries.

- New `registries` table with AES-256-GCM encrypted secrets
- RegistryService with CRUD, test connectivity, Docker config generation
- 5 API endpoints gated by requireTeamPro + requireAdmin
- ComposeService injects credentials via temp DOCKER_CONFIG on deploy/pull
- ImageUpdateService passes stored credentials for private registry checks
- AWS ECR just-in-time token refresh via @aws-sdk/client-ecr
- RegistriesSection UI in Settings Hub with type-aware form
- Documentation with screenshots
This commit is contained in:
Anso
2026-03-29 12:56:30 -04:00
committed by GitHub
parent 362b4a43d0
commit 244c83a0c3
16 changed files with 2317 additions and 20 deletions
+124
View File
@@ -29,6 +29,7 @@ import { LicenseService } from './services/LicenseService';
import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
import YAML from 'yaml';
@@ -691,6 +692,9 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'PUT /scheduled-tasks': 'Updated scheduled task',
'DELETE /scheduled-tasks': 'Deleted scheduled task',
'PATCH /scheduled-tasks': 'Toggled scheduled task',
'POST /registries': 'Created registry credential',
'PUT /registries': 'Updated registry credential',
'DELETE /registries': 'Deleted registry credential',
};
function getAuditSummary(method: string, apiPath: string): string {
@@ -3664,6 +3668,126 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void =>
}
});
// --- Private Registry Routes (Team Pro, admin-only, local-only) ---
const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const;
app.get('/api/registries', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
res.json(RegistryService.getInstance().getAll());
} catch (error) {
console.error('[Registries] List error:', error);
res.status(500).json({ error: 'Failed to fetch registries' });
}
});
app.post('/api/registries', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const { name, url, type, username, secret, aws_region } = req.body;
if (!name || typeof name !== 'string' || name.length > 100) {
res.status(400).json({ error: 'Name is required (max 100 characters).' }); return;
}
if (!url || typeof url !== 'string' || url.length > 500) {
res.status(400).json({ error: 'URL is required (max 500 characters).' }); return;
}
if (!type || !VALID_REGISTRY_TYPES.includes(type)) {
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
}
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'Username is required.' }); return;
}
if (!secret || typeof secret !== 'string') {
res.status(400).json({ error: 'Secret/token is required.' }); return;
}
if (type === 'ecr' && (!aws_region || typeof aws_region !== 'string')) {
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
}
const id = RegistryService.getInstance().create({ name, url, type, username, secret, aws_region: aws_region ?? null });
res.status(201).json({ id });
} catch (error) {
console.error('[Registries] Create error:', error);
res.status(500).json({ error: 'Failed to create registry' });
}
});
app.put('/api/registries/:id', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const existing = RegistryService.getInstance().getById(id);
if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; }
const { name, url, type, username, secret, aws_region } = req.body;
if (name !== undefined && (typeof name !== 'string' || name.length > 100)) {
res.status(400).json({ error: 'Name must be a string (max 100 characters).' }); return;
}
if (url !== undefined && (typeof url !== 'string' || url.length > 500)) {
res.status(400).json({ error: 'URL must be a string (max 500 characters).' }); return;
}
if (type !== undefined && !VALID_REGISTRY_TYPES.includes(type)) {
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
}
const effectiveType = type ?? existing.type;
if (effectiveType === 'ecr' && aws_region !== undefined && (typeof aws_region !== 'string' || !aws_region)) {
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
}
RegistryService.getInstance().update(id, { name, url, type, username, secret, aws_region });
res.json({ success: true });
} catch (error) {
console.error('[Registries] Update error:', error);
res.status(500).json({ error: 'Failed to update registry' });
}
});
app.delete('/api/registries/:id', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const existing = RegistryService.getInstance().getById(id);
if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; }
RegistryService.getInstance().delete(id);
res.json({ success: true });
} catch (error) {
console.error('[Registries] Delete error:', error);
res.status(500).json({ error: 'Failed to delete registry' });
}
});
app.post('/api/registries/:id/test', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const result = await RegistryService.getInstance().testConnection(id);
res.json(result);
} catch (error) {
console.error('[Registries] Test error:', error);
res.status(500).json({ error: 'Failed to test registry connection' });
}
});
// --- System Maintenance Routes (The System Janitor) ---
app.get('/api/system/orphans', async (req: Request, res: Response) => {
+47 -9
View File
@@ -1,10 +1,14 @@
import { spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { RegistryService } from './RegistryService';
/**
* ComposeService - local docker compose CLI execution.
@@ -30,12 +34,13 @@ export class ComposeService {
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
throwOnError = true,
env?: Record<string, string | undefined>
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
env: env ?? {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
@@ -73,6 +78,31 @@ export class ComposeService {
});
}
private async withRegistryAuth<T>(fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T> {
const registries = DatabaseService.getInstance().getRegistries();
if (registries.length === 0) {
return fn({
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
});
}
const dockerConfig = await RegistryService.getInstance().resolveDockerConfig();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-docker-'));
const configPath = path.join(tmpDir, 'config.json');
try {
fs.writeFileSync(configPath, JSON.stringify(dockerConfig), { mode: 0o600 });
return await fn({
...process.env,
DOCKER_CONFIG: tmpDir,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
});
} finally {
try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch { /* best-effort cleanup */ }
}
}
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
await this.execute('docker', ['compose', action], stackDir, ws);
@@ -107,7 +137,9 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -138,7 +170,9 @@ export class ComposeService {
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.restoreStackFiles(stackName);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
@@ -289,11 +323,13 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
sendOutput('=== Pulling latest images ===\n');
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
sendOutput('=== Pulling latest images ===\n');
await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env);
sendOutput('=== Recreating containers ===\n');
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Recreating containers ===\n');
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
// Post-Update Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -326,7 +362,9 @@ export class ComposeService {
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.restoreStackFiles(stackName);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
+70
View File
@@ -167,6 +167,20 @@ export interface ScheduledTaskRun {
triggered_by: 'scheduler' | 'manual';
}
export type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
export interface Registry {
id: number;
name: string;
url: string;
type: RegistryType;
username: string;
secret: string;
aws_region: string | null;
created_at: number;
updated_at: number;
}
export class DatabaseService {
private static instance: DatabaseService;
private db: Database.Database;
@@ -186,6 +200,7 @@ export class DatabaseService {
this.migrateAdminToUsersTable();
this.migrateEncryptNodeTokens();
this.migrateSSOColumns();
this.migrateRegistries();
}
public static getInstance(): DatabaseService {
@@ -498,6 +513,22 @@ export class DatabaseService {
`);
}
private migrateRegistries(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS registries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'custom',
username TEXT NOT NULL DEFAULT '',
secret TEXT NOT NULL DEFAULT '',
aws_region TEXT DEFAULT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
// --- Agents ---
public getAgents(): Agent[] {
@@ -1083,6 +1114,45 @@ export class DatabaseService {
this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
}
// --- Registries ---
public getRegistries(): Registry[] {
return this.db.prepare('SELECT * FROM registries ORDER BY name ASC').all() as Registry[];
}
public getRegistry(id: number): Registry | undefined {
return this.db.prepare('SELECT * FROM registries WHERE id = ?').get(id) as Registry | undefined;
}
public addRegistry(reg: Omit<Registry, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO registries (name, url, type, username, secret, aws_region, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(reg.name, reg.url, reg.type, reg.username, reg.secret, reg.aws_region, reg.created_at, reg.updated_at);
return result.lastInsertRowid as number;
}
public updateRegistry(id: number, updates: Partial<Omit<Registry, 'id' | 'created_at'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.url !== undefined) { fields.push('url = ?'); values.push(updates.url); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
if (updates.secret !== undefined) { fields.push('secret = ?'); values.push(updates.secret); }
if (updates.aws_region !== undefined) { fields.push('aws_region = ?'); values.push(updates.aws_region); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE registries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteRegistry(id: number): void {
this.db.prepare('DELETE FROM registries WHERE id = ?').run(id);
}
// --- Scheduled Tasks ---
public getScheduledTasks(): ScheduledTask[] {
+24 -6
View File
@@ -2,6 +2,7 @@ import https from 'https';
import http from 'http';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { RegistryService } from './RegistryService';
// ─── Image ref parsing ────────────────────────────────────────────────────────
@@ -73,15 +74,24 @@ function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs =
// ─── Registry auth ────────────────────────────────────────────────────────────
async function getAuthToken(registry: string, repo: string): Promise<string | null> {
async function getAuthToken(
registry: string,
repo: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
const basicHeaders: Record<string, string> = {};
if (credentials) {
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
// Ping /v2/ to get the WWW-Authenticate challenge
const ping = await httpGet(`https://${registry}/v2/`);
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
@@ -96,7 +106,7 @@ async function getAuthToken(registry: string, repo: string): Promise<string | nu
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl);
const tokenRes = await httpGet(tokenUrl, basicHeaders);
if (tokenRes.statusCode !== 200) return null;
const parsed = JSON.parse(tokenRes.body);
@@ -117,9 +127,14 @@ const MANIFEST_ACCEPT = [
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
async function getRemoteDigest(registry: string, repo: string, tag: string): Promise<string | null> {
async function getRemoteDigest(
registry: string,
repo: string,
tag: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
const token = await getAuthToken(registry, repo);
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
@@ -259,6 +274,9 @@ export class ImageUpdateService {
const parsed = parseImageRef(imageRef);
if (!parsed) return false;
// Look up stored credentials for this registry
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
// Get local digest from RepoDigests
let localDigest: string | null = null;
try {
@@ -281,7 +299,7 @@ export class ImageUpdateService {
if (!localDigest) return false; // Locally built or never pulled with a digest
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag);
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials);
if (!remoteDigest) return false; // Registry unreachable - no false positives
const hasUpdate = localDigest !== remoteDigest;
+263
View File
@@ -0,0 +1,263 @@
import https from 'https';
import http from 'http';
import { CryptoService } from './CryptoService';
import { DatabaseService, type Registry, type RegistryType } from './DatabaseService';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface RegistryCreateInput {
name: string;
url: string;
type: RegistryType;
username: string;
secret: string;
aws_region?: string | null;
}
export interface RegistryUpdateInput {
name?: string;
url?: string;
type?: RegistryType;
username?: string;
secret?: string;
aws_region?: string | null;
}
interface DockerConfigJson {
auths: Record<string, { auth: string }>;
}
interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
// ─── HTTP helper ─────────────────────────────────────────────────────────────
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http;
const req = lib.get(url, { headers }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers as Record<string, string | string[] | undefined>,
body,
}));
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
});
}
// ─── Service ─────────────────────────────────────────────────────────────────
export class RegistryService {
private static instance: RegistryService;
private crypto: CryptoService;
private constructor() {
this.crypto = CryptoService.getInstance();
}
public static getInstance(): RegistryService {
if (!RegistryService.instance) {
RegistryService.instance = new RegistryService();
}
return RegistryService.instance;
}
// ─── CRUD ────────────────────────────────────────────────────────────────
public getAll(): (Omit<Registry, 'secret'> & { has_secret: boolean })[] {
const db = DatabaseService.getInstance();
return db.getRegistries().map(r => {
const { secret, ...rest } = r;
return { ...rest, has_secret: !!secret };
});
}
public getById(id: number): (Omit<Registry, 'secret'> & { has_secret: boolean }) | undefined {
const db = DatabaseService.getInstance();
const r = db.getRegistry(id);
if (!r) return undefined;
const { secret, ...rest } = r;
return { ...rest, has_secret: !!secret };
}
public create(input: RegistryCreateInput): number {
const db = DatabaseService.getInstance();
const now = Date.now();
return db.addRegistry({
name: input.name,
url: input.url,
type: input.type,
username: input.username,
secret: this.crypto.encrypt(input.secret),
aws_region: input.aws_region ?? null,
created_at: now,
updated_at: now,
});
}
public update(id: number, input: RegistryUpdateInput): void {
const db = DatabaseService.getInstance();
const existing = db.getRegistry(id);
if (!existing) throw new Error('Registry not found');
const updates: Partial<Omit<Registry, 'id' | 'created_at'>> = {
updated_at: Date.now(),
};
if (input.name !== undefined) updates.name = input.name;
if (input.url !== undefined) updates.url = input.url;
if (input.type !== undefined) updates.type = input.type;
if (input.username !== undefined) updates.username = input.username;
if (input.secret !== undefined && input.secret !== '') {
updates.secret = this.crypto.encrypt(input.secret);
}
if (input.aws_region !== undefined) updates.aws_region = input.aws_region;
db.updateRegistry(id, updates);
}
public delete(id: number): void {
DatabaseService.getInstance().deleteRegistry(id);
}
// ─── Test connectivity ───────────────────────────────────────────────────
public async testConnection(id: number): Promise<{ success: boolean; error?: string }> {
const db = DatabaseService.getInstance();
const reg = db.getRegistry(id);
if (!reg) return { success: false, error: 'Registry not found' };
try {
const username = reg.username;
const password = this.crypto.decrypt(reg.secret);
if (reg.type === 'ecr') {
await this.getEcrToken(username, password, reg.aws_region!);
return { success: true };
}
// Standard registry: attempt /v2/ ping with Basic auth
const registryUrl = this.normalizeRegistryUrl(reg.url);
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
const res = await httpGet(`${registryUrl}/v2/`, { Authorization: `Basic ${basicAuth}` });
if (res.statusCode === 200 || res.statusCode === 401) {
// 401 with valid challenge means registry is reachable
// Try token-based auth if we got 401
if (res.statusCode === 401) {
const wwwAuth = res.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return { success: false, error: 'Registry returned 401 without auth challenge' };
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
if (!realmMatch) return { success: false, error: 'Could not parse auth challenge' };
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
const tokenUrl = `${realmMatch[1]}?${params.toString()}`;
const tokenRes = await httpGet(tokenUrl, { Authorization: `Basic ${basicAuth}` });
if (tokenRes.statusCode !== 200) {
return { success: false, error: `Authentication failed (${tokenRes.statusCode})` };
}
}
return { success: true };
}
return { success: false, error: `Registry returned HTTP ${res.statusCode}` };
} catch (e) {
return { success: false, error: (e as Error).message };
}
}
// ─── Docker config resolution (for ComposeService) ───────────────────────
public async resolveDockerConfig(): Promise<DockerConfigJson> {
const db = DatabaseService.getInstance();
const registries = db.getRegistries();
const auths: Record<string, { auth: string }> = {};
for (const reg of registries) {
try {
const decryptedSecret = this.crypto.decrypt(reg.secret);
let username = reg.username;
let password = decryptedSecret;
if (reg.type === 'ecr') {
const ecrCreds = await this.getEcrToken(reg.username, decryptedSecret, reg.aws_region!);
username = ecrCreds.username;
password = ecrCreds.password;
}
const auth = Buffer.from(`${username}:${password}`).toString('base64');
auths[reg.url] = { auth };
} catch (e) {
console.error(`[RegistryService] Failed to resolve credentials for ${reg.name}:`, e);
}
}
return { auths };
}
// ─── Registry auth for ImageUpdateService ────────────────────────────────
public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> {
const db = DatabaseService.getInstance();
const registries = db.getRegistries();
// Match by URL containing the registry host
const match = registries.find(r => {
const normalizedUrl = r.url.replace(/^https?:\/\//, '').replace(/\/$/, '');
return normalizedUrl === registryHost || normalizedUrl.includes(registryHost) || registryHost.includes(normalizedUrl);
});
if (!match) return null;
try {
const decryptedSecret = this.crypto.decrypt(match.secret);
if (match.type === 'ecr') {
return await this.getEcrToken(match.username, decryptedSecret, match.aws_region!);
}
return { username: match.username, password: decryptedSecret };
} catch (e) {
console.error(`[RegistryService] Failed to resolve auth for ${registryHost}:`, e);
return null;
}
}
// ─── ECR token fetch ─────────────────────────────────────────────────────
private async getEcrToken(accessKeyId: string, secretAccessKey: string, region: string): Promise<{ username: string; password: string }> {
const { ECRClient, GetAuthorizationTokenCommand } = await import('@aws-sdk/client-ecr');
const client = new ECRClient({
region,
credentials: { accessKeyId, secretAccessKey },
});
const response = await client.send(new GetAuthorizationTokenCommand({}));
const authData = response.authorizationData?.[0];
if (!authData?.authorizationToken) throw new Error('ECR returned no authorization token');
const decoded = Buffer.from(authData.authorizationToken, 'base64').toString();
const [username, ...passwordParts] = decoded.split(':');
return { username, password: passwordParts.join(':') };
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private normalizeRegistryUrl(url: string): string {
// Ensure URL has a protocol
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `https://${url}`;
}
return url.replace(/\/$/, '');
}
}