mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(/\/$/, '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user