mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
6275adc6b3
* feat(registries): add stateless test endpoint, ECR caching, URL and host hardening Adds a POST /api/registries/test endpoint so credentials can be verified before being persisted. Caches ECR authorization tokens in memory until their AWS-reported expiry (minus a safety margin) instead of fetching on every compose invocation. Normalizes registry URLs on save so the stored values match the keys Docker expects in ~/.docker/config.json, fixes a bidirectional host-match bug in getAuthForRegistry that could cross-match overlapping hostnames, and surfaces per-registry decryption failures as warnings in the deploy log stream instead of swallowing them. Also strips the Authorization header on cross-host redirects in the test probe, rejects non-http(s) schemes on save, and validates the shape of returned ECR authorization tokens before use. * refactor(registries): align UI with design system and add in-form test button Swaps the registry type dropdown from shadcn Select to the project's Combobox, applies the canonical card bevel and top-border hover styling to the form container and each registry row, restyles the delete button to the ghost + muted destructive pattern, uses strokeWidth 1.5 on every Lucide icon, and routes all toast errors through the standard defensive chain. Adds a Test connection button inside the form so credentials can be verified before saving. * test(registries): cover RegistryService and deploy warnings surface Adds unit coverage for URL normalization, the encrypt/decrypt round trip through create and resolveDockerConfig, exact-host matching in getAuthForRegistry, resolveDockerConfig warnings on decryption failure, ECR token cache hit/miss and invalidation on update, the stateless testWithCredentials path for 200, 401 with and without a challenge, network errors, and ECR success and failure including malformed tokens. Extends the ComposeService tests to verify that warnings from resolveDockerConfig reach the deploy log stream. * docs(registries): document test-before-save flow and troubleshooting Describes the in-form Test connection button, the two-point testing flow from the registries list, the cached ECR token behavior during deploys, the per-registry warning Sencho emits when a stored secret cannot be decrypted, and adds a Troubleshooting section covering common 401 causes, ECR token handling, warning interpretation, and per-node credential scoping.
470 lines
19 KiB
TypeScript
470 lines
19 KiB
TypeScript
import https from 'https';
|
|
import http from 'http';
|
|
import { CryptoService } from './CryptoService';
|
|
import { DatabaseService, type Registry, type RegistryType } from './DatabaseService';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
export interface TestCredentialsInput {
|
|
type: RegistryType;
|
|
url: string;
|
|
username: string;
|
|
secret: string;
|
|
aws_region?: string | null;
|
|
}
|
|
|
|
export interface DockerConfigJson {
|
|
auths: Record<string, { auth: string }>;
|
|
}
|
|
|
|
export interface ResolvedDockerConfig {
|
|
config: DockerConfigJson;
|
|
warnings: string[];
|
|
}
|
|
|
|
interface HttpResult {
|
|
statusCode: number;
|
|
headers: Record<string, string | string[] | undefined>;
|
|
body: string;
|
|
}
|
|
|
|
interface EcrCacheEntry {
|
|
username: string;
|
|
password: string;
|
|
expiresAt: number; // epoch ms
|
|
}
|
|
|
|
const DOCKER_HUB_AUTHS_KEY = 'https://index.docker.io/v1/';
|
|
const ECR_CACHE_SAFETY_MS = 5 * 60 * 1000;
|
|
const ECR_DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
|
|
|
|
// ─── URL helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Canonical storage form for a registry URL.
|
|
*
|
|
* - Docker Hub: always the legacy v1 URL (what the Docker CLI expects in
|
|
* `~/.docker/config.json`).
|
|
* - All other types: protocol stripped, trailing slashes removed. This keeps
|
|
* stored URLs aligned with the `auths` key format Docker uses, and makes
|
|
* host matching unambiguous.
|
|
*/
|
|
export function normalizeRegistryUrl(url: string, type: RegistryType): string {
|
|
if (type === 'dockerhub') return DOCKER_HUB_AUTHS_KEY;
|
|
return url.trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
|
}
|
|
|
|
/** HTTP URL used to probe the registry's /v2/ endpoint. Always has a protocol. */
|
|
function toProbeUrl(url: string, type: RegistryType): string {
|
|
if (type === 'dockerhub') return 'https://index.docker.io';
|
|
const stripped = url.trim().replace(/\/+$/, '');
|
|
if (stripped.startsWith('http://') || stripped.startsWith('https://')) return stripped;
|
|
return `https://${stripped}`;
|
|
}
|
|
|
|
/** Canonical host for matching (image ref → stored credential). */
|
|
function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
|
|
if (reg.type === 'dockerhub') return 'index.docker.io';
|
|
try {
|
|
const withProtocol = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`;
|
|
return new URL(withProtocol).host.toLowerCase();
|
|
} catch {
|
|
return reg.url.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
|
}
|
|
}
|
|
|
|
/** Normalize an image reference's host (the thing ImageUpdateService passes in). */
|
|
function normalizeImageHost(host: string): string {
|
|
const lower = host.trim().toLowerCase();
|
|
// Docker Hub aliases resolve to the same credential.
|
|
if (lower === 'docker.io' || lower === 'registry-1.docker.io' || lower === '') {
|
|
return 'index.docker.io';
|
|
}
|
|
return lower;
|
|
}
|
|
|
|
// ─── HTTP helper (one-hop redirect) ──────────────────────────────────────────
|
|
|
|
function httpGet(
|
|
url: string,
|
|
headers: Record<string, string> = {},
|
|
timeoutMs = 10000,
|
|
allowRedirect = true,
|
|
): Promise<HttpResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const lib = url.startsWith('https:') ? https : http;
|
|
const req = lib.get(url, { headers }, (res) => {
|
|
const status = res.statusCode ?? 0;
|
|
const location = res.headers.location;
|
|
if (allowRedirect && location && (status === 301 || status === 302 || status === 307 || status === 308)) {
|
|
res.resume();
|
|
let nextUrl: URL;
|
|
try {
|
|
nextUrl = new URL(location, url);
|
|
} catch {
|
|
reject(new Error('Invalid redirect location'));
|
|
return;
|
|
}
|
|
// Strip Authorization on cross-host redirects to prevent leaking credentials
|
|
// to a registry-controlled Location header.
|
|
let nextHeaders = headers;
|
|
try {
|
|
const originalHost = new URL(url).host.toLowerCase();
|
|
if (nextUrl.host.toLowerCase() !== originalHost) {
|
|
const { Authorization: _drop, ...rest } = headers;
|
|
void _drop;
|
|
nextHeaders = rest;
|
|
}
|
|
} catch {
|
|
// If the original URL cannot be parsed, err on the safe side and strip.
|
|
const { Authorization: _drop, ...rest } = headers;
|
|
void _drop;
|
|
nextHeaders = rest;
|
|
}
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] redirect ${status} ${url} -> ${nextUrl.toString()} (auth ${nextHeaders === headers ? 'kept' : 'stripped'})`);
|
|
}
|
|
httpGet(nextUrl.toString(), nextHeaders, timeoutMs, false).then(resolve, reject);
|
|
return;
|
|
}
|
|
let body = '';
|
|
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
res.on('end', () => resolve({
|
|
statusCode: status,
|
|
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 ecrCache = new Map<number, EcrCacheEntry>();
|
|
|
|
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();
|
|
const id = db.addRegistry({
|
|
name: input.name,
|
|
url: normalizeRegistryUrl(input.url, input.type),
|
|
type: input.type,
|
|
username: input.username,
|
|
secret: this.crypto.encrypt(input.secret),
|
|
aws_region: input.aws_region ?? null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
console.info(`[RegistryService] Registry created: id=${id} type=${input.type} name="${input.name}"`);
|
|
return id;
|
|
}
|
|
|
|
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.type !== undefined) updates.type = input.type;
|
|
const effectiveType = input.type ?? existing.type;
|
|
if (input.url !== undefined) updates.url = normalizeRegistryUrl(input.url, effectiveType);
|
|
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);
|
|
this.ecrCache.delete(id);
|
|
console.info(`[RegistryService] Registry updated: id=${id} name="${existing.name}"`);
|
|
}
|
|
|
|
public delete(id: number): void {
|
|
const db = DatabaseService.getInstance();
|
|
const existing = db.getRegistry(id);
|
|
db.deleteRegistry(id);
|
|
this.ecrCache.delete(id);
|
|
if (existing) {
|
|
console.info(`[RegistryService] Registry deleted: id=${id} name="${existing.name}"`);
|
|
}
|
|
}
|
|
|
|
// ─── Test connectivity ───────────────────────────────────────────────────
|
|
|
|
/** Test an already-saved registry by id. Decrypts the stored secret. */
|
|
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' };
|
|
|
|
let password: string;
|
|
try {
|
|
password = this.crypto.decrypt(reg.secret);
|
|
} catch (e) {
|
|
return { success: false, error: `Could not decrypt stored secret: ${(e as Error).message}` };
|
|
}
|
|
|
|
return this.testWithCredentials({
|
|
type: reg.type,
|
|
url: reg.url,
|
|
username: reg.username,
|
|
secret: password,
|
|
aws_region: reg.aws_region,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Test credentials without persisting them. Powers the "Test before save"
|
|
* UX in the create/edit form.
|
|
*/
|
|
public async testWithCredentials(input: TestCredentialsInput): Promise<{ success: boolean; error?: string }> {
|
|
const t0 = Date.now();
|
|
try {
|
|
if (input.type === 'ecr') {
|
|
if (!input.aws_region) {
|
|
return { success: false, error: 'AWS region is required for ECR registries.' };
|
|
}
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] testWithCredentials ECR region=${input.aws_region}`);
|
|
}
|
|
await this.fetchEcrToken(input.username, input.secret, input.aws_region);
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] ECR test succeeded in ${Date.now() - t0}ms`);
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
const probeUrl = toProbeUrl(input.url, input.type);
|
|
const basicAuth = Buffer.from(`${input.username}:${input.secret}`).toString('base64');
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] testWithCredentials probing ${probeUrl}/v2/`);
|
|
}
|
|
const res = await httpGet(`${probeUrl}/v2/`, { Authorization: `Basic ${basicAuth}` });
|
|
|
|
if (res.statusCode === 200) {
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] test succeeded via 200 in ${Date.now() - t0}ms`);
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
if (res.statusCode === 401) {
|
|
const wwwAuth = res.headers['www-authenticate'] as string | undefined;
|
|
if (!wwwAuth) return { success: false, error: 'Registry returned 401 without an auth challenge.' };
|
|
|
|
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
|
|
if (!realmMatch) return { success: false, error: 'Could not parse registry auth challenge.' };
|
|
|
|
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
|
|
const params = new URLSearchParams();
|
|
if (serviceMatch) params.set('service', serviceMatch[1]);
|
|
const tokenUrl = `${realmMatch[1]}?${params.toString()}`;
|
|
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] exchanging Basic for Bearer at ${tokenUrl}`);
|
|
}
|
|
const tokenRes = await httpGet(tokenUrl, { Authorization: `Basic ${basicAuth}` });
|
|
if (tokenRes.statusCode !== 200) {
|
|
return { success: false, error: `Authentication failed (HTTP ${tokenRes.statusCode}).` };
|
|
}
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] test succeeded via bearer in ${Date.now() - t0}ms`);
|
|
}
|
|
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<ResolvedDockerConfig> {
|
|
const db = DatabaseService.getInstance();
|
|
const registries = db.getRegistries();
|
|
const auths: Record<string, { auth: string }> = {};
|
|
const warnings: string[] = [];
|
|
|
|
if (isDebugEnabled()) {
|
|
const summary = registries.map(r => `${r.type}:${r.name}`).join(', ');
|
|
console.debug(`[RegistryService][debug] resolveDockerConfig registries=[${summary}]`);
|
|
}
|
|
|
|
for (const reg of registries) {
|
|
try {
|
|
let username = reg.username;
|
|
let password: string;
|
|
|
|
if (reg.type === 'ecr') {
|
|
const creds = await this.getEcrCredentials(reg);
|
|
username = creds.username;
|
|
password = creds.password;
|
|
} else {
|
|
password = this.crypto.decrypt(reg.secret);
|
|
}
|
|
|
|
const authsKey = reg.type === 'dockerhub' ? DOCKER_HUB_AUTHS_KEY : reg.url;
|
|
auths[authsKey] = { auth: Buffer.from(`${username}:${password}`).toString('base64') };
|
|
} catch (e) {
|
|
const msg = `Registry "${reg.name}" credentials unavailable: ${(e as Error).message}`;
|
|
console.warn(`[RegistryService] ${msg}`);
|
|
warnings.push(msg);
|
|
}
|
|
}
|
|
|
|
return { config: { auths }, warnings };
|
|
}
|
|
|
|
// ─── Registry auth for ImageUpdateService ────────────────────────────────
|
|
|
|
public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> {
|
|
const db = DatabaseService.getInstance();
|
|
const registries = db.getRegistries();
|
|
const normalized = normalizeImageHost(registryHost);
|
|
|
|
const match = registries.find(r => hostFromStoredRegistry(r) === normalized);
|
|
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] getAuthForRegistry host="${registryHost}" normalized="${normalized}" matchId=${match?.id ?? 'none'}`);
|
|
}
|
|
|
|
if (!match) return null;
|
|
|
|
try {
|
|
if (match.type === 'ecr') {
|
|
return await this.getEcrCredentials(match);
|
|
}
|
|
return { username: match.username, password: this.crypto.decrypt(match.secret) };
|
|
} catch (e) {
|
|
console.warn(`[RegistryService] Could not resolve auth for ${registryHost}: ${(e as Error).message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── ECR token fetch + cache ─────────────────────────────────────────────
|
|
|
|
private async getEcrCredentials(reg: Registry): Promise<{ username: string; password: string }> {
|
|
if (!reg.aws_region) {
|
|
throw new Error(`ECR registry "${reg.name}" is missing aws_region. Re-save the registry with a region.`);
|
|
}
|
|
|
|
const now = Date.now();
|
|
const cached = this.ecrCache.get(reg.id);
|
|
if (cached && cached.expiresAt - ECR_CACHE_SAFETY_MS > now) {
|
|
if (isDebugEnabled()) {
|
|
const remainingMs = cached.expiresAt - now;
|
|
console.debug(`[RegistryService][debug] ECR cache hit id=${reg.id} remaining=${Math.round(remainingMs / 1000)}s`);
|
|
}
|
|
return { username: cached.username, password: cached.password };
|
|
}
|
|
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] ECR cache miss id=${reg.id}, fetching fresh token`);
|
|
}
|
|
|
|
const decryptedSecret = this.crypto.decrypt(reg.secret);
|
|
const t0 = Date.now();
|
|
const result = await this.fetchEcrToken(reg.username, decryptedSecret, reg.aws_region);
|
|
const elapsed = Date.now() - t0;
|
|
if (isDebugEnabled()) {
|
|
console.debug(`[RegistryService][debug] ECR STS fetch id=${reg.id} took=${elapsed}ms expiresAt=${new Date(result.expiresAt).toISOString()}`);
|
|
}
|
|
|
|
this.ecrCache.set(reg.id, result);
|
|
return { username: result.username, password: result.password };
|
|
}
|
|
|
|
private async fetchEcrToken(
|
|
accessKeyId: string,
|
|
secretAccessKey: string,
|
|
region: string,
|
|
): Promise<EcrCacheEntry> {
|
|
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 colonIdx = decoded.indexOf(':');
|
|
if (colonIdx <= 0 || colonIdx === decoded.length - 1) {
|
|
throw new Error('ECR returned a malformed authorization token');
|
|
}
|
|
const username = decoded.slice(0, colonIdx);
|
|
const password = decoded.slice(colonIdx + 1);
|
|
const expiresAt = authData.expiresAt instanceof Date
|
|
? authData.expiresAt.getTime()
|
|
: Date.now() + ECR_DEFAULT_TTL_MS;
|
|
|
|
return { username, password, expiresAt };
|
|
}
|
|
|
|
/** Exposed for tests and for admin-triggered cache busts. */
|
|
public invalidateEcrCache(id?: number): void {
|
|
if (id === undefined) this.ecrCache.clear();
|
|
else this.ecrCache.delete(id);
|
|
}
|
|
}
|