mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
feat(registries): harden Private Registry Credentials feature (#597)
* 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.
This commit is contained in:
@@ -22,7 +22,7 @@ const {
|
||||
mockContainerInspect: vi.fn().mockResolvedValue({ State: { ExitCode: 0 } }),
|
||||
mockContainerLogs: vi.fn().mockResolvedValue(Buffer.from('')),
|
||||
mockGetRegistries: vi.fn().mockReturnValue([]),
|
||||
mockResolveDockerConfig: vi.fn().mockResolvedValue({ auths: {} }),
|
||||
mockResolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
|
||||
mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
|
||||
mockRestoreStackFiles: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdtempSync: vi.fn().mockReturnValue('/tmp/sencho-docker-test'),
|
||||
@@ -314,7 +314,7 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
|
||||
it('creates temp config dir when registries exist', async () => {
|
||||
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com', username: 'user', password: 'pass' }]);
|
||||
mockResolveDockerConfig.mockResolvedValue({ auths: { 'registry.example.com': { auth: 'dXNlcjpwYXNz' } } });
|
||||
mockResolveDockerConfig.mockResolvedValue({ config: { auths: { 'registry.example.com': { auth: 'dXNlcjpwYXNz' } } }, warnings: [] });
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
@@ -330,9 +330,29 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
expect(mockRmdirSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces resolveDockerConfig warnings to the WebSocket output', async () => {
|
||||
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
|
||||
mockResolveDockerConfig.mockResolvedValue({
|
||||
config: { auths: {} },
|
||||
warnings: ['Registry "broken" credentials unavailable: bad key'],
|
||||
});
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
const ws = createMockWs();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.deployStack('my-stack', ws as any);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
const sendCalls = ws.send.mock.calls.map(c => c[0]);
|
||||
expect(sendCalls.some((msg: string) => msg.includes('[Sencho] Warning') && msg.includes('bad key'))).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans up temp dir even on command failure', async () => {
|
||||
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
|
||||
mockResolveDockerConfig.mockResolvedValue({ auths: {} });
|
||||
mockResolveDockerConfig.mockResolvedValue({ config: { auths: {} }, warnings: [] });
|
||||
|
||||
// Make spawn fail
|
||||
mockSpawn.mockImplementation(() => {
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* Unit tests for RegistryService.
|
||||
*
|
||||
* Covers:
|
||||
* - URL normalization (Docker Hub legacy form, protocol stripping, trailing slash)
|
||||
* - Encrypt/decrypt round-trip via create + resolveDockerConfig
|
||||
* - Update preserving secret when empty
|
||||
* - Exact-host matching in getAuthForRegistry (no substring leaks)
|
||||
* - resolveDockerConfig warnings path on decryption failure
|
||||
* - ECR token cache hit/miss/TTL
|
||||
* - testWithCredentials: 200 direct, 401 with challenge, 401 without challenge,
|
||||
* network error, and ECR success/failure
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockHttpsGet, mockHttpGet, mockEcrSend } = vi.hoisted(() => ({
|
||||
mockHttpsGet: vi.fn(),
|
||||
mockHttpGet: vi.fn(),
|
||||
mockEcrSend: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('https', () => ({
|
||||
default: { get: mockHttpsGet },
|
||||
get: mockHttpsGet,
|
||||
}));
|
||||
|
||||
vi.mock('http', () => ({
|
||||
default: { get: mockHttpGet },
|
||||
get: mockHttpGet,
|
||||
}));
|
||||
|
||||
vi.mock('@aws-sdk/client-ecr', () => {
|
||||
class MockECRClient {
|
||||
send = mockEcrSend;
|
||||
}
|
||||
class MockGetAuthorizationTokenCommand {
|
||||
input: unknown;
|
||||
constructor(input: unknown) { this.input = input; }
|
||||
}
|
||||
return {
|
||||
ECRClient: MockECRClient,
|
||||
GetAuthorizationTokenCommand: MockGetAuthorizationTokenCommand,
|
||||
};
|
||||
});
|
||||
|
||||
let tmpDir: string;
|
||||
let RegistryService: typeof import('../services/RegistryService').RegistryService;
|
||||
let normalizeRegistryUrl: typeof import('../services/RegistryService').normalizeRegistryUrl;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ RegistryService, normalizeRegistryUrl } = await import('../services/RegistryService'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset HTTP mocks between tests
|
||||
mockHttpsGet.mockReset();
|
||||
mockHttpGet.mockReset();
|
||||
mockEcrSend.mockReset();
|
||||
// Wipe any persisted registries between tests
|
||||
const db = DatabaseService.getInstance();
|
||||
for (const r of db.getRegistries()) db.deleteRegistry(r.id);
|
||||
// Reset ECR cache
|
||||
RegistryService.getInstance().invalidateEcrCache();
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a fake IncomingMessage-like object that the real httpGet helper in
|
||||
* RegistryService can consume via `https.get(url, { headers }, cb)`.
|
||||
*/
|
||||
function mockHttpResponse(
|
||||
statusCode: number,
|
||||
headers: Record<string, string | undefined> = {},
|
||||
body = '',
|
||||
) {
|
||||
return (_url: string, _opts: unknown, cb: (res: EventEmitter & Record<string, unknown>) => void) => {
|
||||
const res = new EventEmitter() as EventEmitter & Record<string, unknown>;
|
||||
res.statusCode = statusCode;
|
||||
res.headers = headers;
|
||||
res.resume = () => undefined;
|
||||
setImmediate(() => {
|
||||
cb(res);
|
||||
if (body) res.emit('data', Buffer.from(body));
|
||||
res.emit('end');
|
||||
});
|
||||
const req = new EventEmitter() as EventEmitter & { setTimeout: (ms: number, cb: () => void) => void; destroy: (e?: Error) => void };
|
||||
req.setTimeout = () => undefined;
|
||||
req.destroy = () => undefined;
|
||||
return req;
|
||||
};
|
||||
}
|
||||
|
||||
function mockNetworkError(message: string) {
|
||||
return (_url: string, _opts: unknown, _cb: unknown) => {
|
||||
const req = new EventEmitter() as EventEmitter & { setTimeout: (ms: number, cb: () => void) => void; destroy: (e?: Error) => void };
|
||||
req.setTimeout = () => undefined;
|
||||
req.destroy = () => undefined;
|
||||
setImmediate(() => req.emit('error', new Error(message)));
|
||||
return req;
|
||||
};
|
||||
}
|
||||
|
||||
// ── normalizeRegistryUrl ───────────────────────────────────────────────
|
||||
|
||||
describe('normalizeRegistryUrl', () => {
|
||||
it('returns the legacy Docker Hub v1 URL regardless of input', () => {
|
||||
expect(normalizeRegistryUrl('anything', 'dockerhub')).toBe('https://index.docker.io/v1/');
|
||||
expect(normalizeRegistryUrl('https://hub.docker.com', 'dockerhub')).toBe('https://index.docker.io/v1/');
|
||||
expect(normalizeRegistryUrl('', 'dockerhub')).toBe('https://index.docker.io/v1/');
|
||||
});
|
||||
|
||||
it('strips protocol and trailing slashes for non-dockerhub types', () => {
|
||||
expect(normalizeRegistryUrl('https://ghcr.io', 'ghcr')).toBe('ghcr.io');
|
||||
expect(normalizeRegistryUrl('https://ghcr.io/', 'ghcr')).toBe('ghcr.io');
|
||||
expect(normalizeRegistryUrl('http://registry.local:5000/', 'custom')).toBe('registry.local:5000');
|
||||
expect(normalizeRegistryUrl('ghcr.io///', 'ghcr')).toBe('ghcr.io');
|
||||
});
|
||||
|
||||
it('normalizes ECR hostnames', () => {
|
||||
expect(normalizeRegistryUrl('https://123.dkr.ecr.us-east-1.amazonaws.com', 'ecr'))
|
||||
.toBe('123.dkr.ecr.us-east-1.amazonaws.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ── CRUD + encrypt round-trip ──────────────────────────────────────────
|
||||
|
||||
describe('RegistryService - CRUD', () => {
|
||||
it('encrypts secrets on create and round-trips via resolveDockerConfig', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
svc.create({
|
||||
name: 'ghcr',
|
||||
url: 'https://ghcr.io',
|
||||
type: 'ghcr',
|
||||
username: 'alice',
|
||||
secret: 'supersecrettoken',
|
||||
});
|
||||
|
||||
const raw = DatabaseService.getInstance().getRegistries()[0];
|
||||
expect(raw.secret).not.toBe('supersecrettoken');
|
||||
expect(raw.secret.startsWith('enc:')).toBe(true);
|
||||
expect(raw.url).toBe('ghcr.io');
|
||||
|
||||
const { config, warnings } = await svc.resolveDockerConfig();
|
||||
expect(warnings).toEqual([]);
|
||||
const expectedAuth = Buffer.from('alice:supersecrettoken').toString('base64');
|
||||
expect(config.auths['ghcr.io']).toEqual({ auth: expectedAuth });
|
||||
});
|
||||
|
||||
it('keys Docker Hub under the legacy v1 auths key', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
svc.create({
|
||||
name: 'hub',
|
||||
url: '',
|
||||
type: 'dockerhub',
|
||||
username: 'bob',
|
||||
secret: 'hubpass',
|
||||
});
|
||||
|
||||
const { config } = await svc.resolveDockerConfig();
|
||||
expect(config.auths['https://index.docker.io/v1/']).toBeDefined();
|
||||
});
|
||||
|
||||
it('update with empty secret preserves the existing secret', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
const id = svc.create({
|
||||
name: 'ghcr',
|
||||
url: 'ghcr.io',
|
||||
type: 'ghcr',
|
||||
username: 'alice',
|
||||
secret: 'original-secret',
|
||||
});
|
||||
|
||||
svc.update(id, { name: 'renamed', secret: '' });
|
||||
|
||||
const { config } = await svc.resolveDockerConfig();
|
||||
const expectedAuth = Buffer.from('alice:original-secret').toString('base64');
|
||||
expect(config.auths['ghcr.io']).toEqual({ auth: expectedAuth });
|
||||
expect(DatabaseService.getInstance().getRegistry(id)!.name).toBe('renamed');
|
||||
});
|
||||
|
||||
it('update with a new secret replaces the old one', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
const id = svc.create({
|
||||
name: 'ghcr',
|
||||
url: 'ghcr.io',
|
||||
type: 'ghcr',
|
||||
username: 'alice',
|
||||
secret: 'old',
|
||||
});
|
||||
|
||||
svc.update(id, { secret: 'new-secret' });
|
||||
|
||||
const { config } = await svc.resolveDockerConfig();
|
||||
const expectedAuth = Buffer.from('alice:new-secret').toString('base64');
|
||||
expect(config.auths['ghcr.io']).toEqual({ auth: expectedAuth });
|
||||
});
|
||||
|
||||
it('getAll omits the secret field and exposes has_secret', () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
svc.create({
|
||||
name: 'ghcr',
|
||||
url: 'ghcr.io',
|
||||
type: 'ghcr',
|
||||
username: 'alice',
|
||||
secret: 'x',
|
||||
});
|
||||
|
||||
const all = svc.getAll();
|
||||
expect(all[0].has_secret).toBe(true);
|
||||
expect((all[0] as unknown as { secret?: string }).secret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAuthForRegistry ─────────────────────────────────────────────────
|
||||
|
||||
describe('RegistryService - getAuthForRegistry', () => {
|
||||
it('matches on exact host (case-insensitive)', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
svc.create({ name: 'ghcr', url: 'ghcr.io', type: 'ghcr', username: 'alice', secret: 'x' });
|
||||
|
||||
const hit = await svc.getAuthForRegistry('GHCR.IO');
|
||||
expect(hit).toEqual({ username: 'alice', password: 'x' });
|
||||
});
|
||||
|
||||
it('maps docker.io and registry-1.docker.io to the Docker Hub credential', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
svc.create({ name: 'hub', url: '', type: 'dockerhub', username: 'bob', secret: 'pw' });
|
||||
|
||||
expect(await svc.getAuthForRegistry('docker.io')).toEqual({ username: 'bob', password: 'pw' });
|
||||
expect(await svc.getAuthForRegistry('registry-1.docker.io')).toEqual({ username: 'bob', password: 'pw' });
|
||||
expect(await svc.getAuthForRegistry('index.docker.io')).toEqual({ username: 'bob', password: 'pw' });
|
||||
});
|
||||
|
||||
it('does NOT match overlapping substrings (the old bidirectional-includes bug)', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
// Stored: my-ghcr.io.internal. A lookup for ghcr.io must NOT match.
|
||||
svc.create({
|
||||
name: 'internal',
|
||||
url: 'my-ghcr.io.internal',
|
||||
type: 'custom',
|
||||
username: 'alice',
|
||||
secret: 'x',
|
||||
});
|
||||
|
||||
expect(await svc.getAuthForRegistry('ghcr.io')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no registry matches', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
expect(await svc.getAuthForRegistry('ghcr.io')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveDockerConfig warnings ───────────────────────────────────────
|
||||
|
||||
describe('RegistryService - resolveDockerConfig warnings', () => {
|
||||
it('returns a warning (not a throw) when a stored secret cannot be decrypted', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
svc.create({ name: 'ghcr', url: 'ghcr.io', type: 'ghcr', username: 'alice', secret: 'x' });
|
||||
|
||||
// Corrupt the stored secret so decryption fails.
|
||||
DatabaseService.getInstance().updateRegistry(
|
||||
DatabaseService.getInstance().getRegistries()[0].id,
|
||||
{ secret: 'enc:not-real-ciphertext' },
|
||||
);
|
||||
|
||||
const { config, warnings } = await svc.resolveDockerConfig();
|
||||
expect(Object.keys(config.auths)).toHaveLength(0);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('credentials unavailable');
|
||||
});
|
||||
|
||||
it('returns empty warnings when all registries decrypt cleanly', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
svc.create({ name: 'ghcr', url: 'ghcr.io', type: 'ghcr', username: 'alice', secret: 'x' });
|
||||
svc.create({ name: 'custom', url: 'my.registry', type: 'custom', username: 'bob', secret: 'y' });
|
||||
|
||||
const { warnings } = await svc.resolveDockerConfig();
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── testWithCredentials (non-ECR) ──────────────────────────────────────
|
||||
|
||||
describe('RegistryService - testWithCredentials', () => {
|
||||
it('returns success on HTTP 200 from /v2/', async () => {
|
||||
mockHttpsGet.mockImplementation(mockHttpResponse(200, {}, 'ok'));
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'custom',
|
||||
url: 'registry.example.com',
|
||||
username: 'u',
|
||||
secret: 's',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
expect(mockHttpsGet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows a 401 + Bearer challenge to the realm URL', async () => {
|
||||
mockHttpsGet
|
||||
.mockImplementationOnce(mockHttpResponse(401, {
|
||||
'www-authenticate': 'Bearer realm="https://auth.example.com/token",service="registry"',
|
||||
}))
|
||||
.mockImplementationOnce(mockHttpResponse(200, {}, '{"token":"t"}'));
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'custom',
|
||||
url: 'registry.example.com',
|
||||
username: 'u',
|
||||
secret: 's',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
expect(mockHttpsGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails when 401 has no auth challenge header', async () => {
|
||||
mockHttpsGet.mockImplementation(mockHttpResponse(401, {}));
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'custom',
|
||||
url: 'registry.example.com',
|
||||
username: 'u',
|
||||
secret: 's',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain('401');
|
||||
});
|
||||
|
||||
it('fails when the token exchange returns non-200', async () => {
|
||||
mockHttpsGet
|
||||
.mockImplementationOnce(mockHttpResponse(401, {
|
||||
'www-authenticate': 'Bearer realm="https://auth.example.com/token"',
|
||||
}))
|
||||
.mockImplementationOnce(mockHttpResponse(403));
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'custom',
|
||||
url: 'registry.example.com',
|
||||
username: 'u',
|
||||
secret: 's',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain('403');
|
||||
});
|
||||
|
||||
it('surfaces transport errors cleanly', async () => {
|
||||
mockHttpsGet.mockImplementation(mockNetworkError('ENOTFOUND registry.example.com'));
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'custom',
|
||||
url: 'registry.example.com',
|
||||
username: 'u',
|
||||
secret: 's',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain('ENOTFOUND');
|
||||
});
|
||||
|
||||
it('rejects ECR credentials without an aws_region', async () => {
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'ecr',
|
||||
url: '123.dkr.ecr.us-east-1.amazonaws.com',
|
||||
username: 'AKIA',
|
||||
secret: 'secret',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/region/i);
|
||||
});
|
||||
|
||||
it('returns success on ECR when GetAuthorizationTokenCommand resolves', async () => {
|
||||
const token = Buffer.from('AWS:supersecretpassword').toString('base64');
|
||||
mockEcrSend.mockResolvedValue({
|
||||
authorizationData: [{ authorizationToken: token, expiresAt: new Date(Date.now() + 12 * 3600 * 1000) }],
|
||||
});
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'ecr',
|
||||
url: '123.dkr.ecr.us-east-1.amazonaws.com',
|
||||
username: 'AKIA',
|
||||
secret: 'secret',
|
||||
aws_region: 'us-east-1',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
expect(mockEcrSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed ECR authorization tokens', async () => {
|
||||
// Token with no colon separator
|
||||
const bad = Buffer.from('nocolonhere').toString('base64');
|
||||
mockEcrSend.mockResolvedValue({
|
||||
authorizationData: [{ authorizationToken: bad, expiresAt: new Date(Date.now() + 3600 * 1000) }],
|
||||
});
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'ecr',
|
||||
url: '123.dkr.ecr.us-east-1.amazonaws.com',
|
||||
username: 'AKIA',
|
||||
secret: 'secret',
|
||||
aws_region: 'us-east-1',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/malformed/i);
|
||||
});
|
||||
|
||||
it('propagates ECR SDK errors', async () => {
|
||||
mockEcrSend.mockRejectedValue(new Error('InvalidSignatureException'));
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
const res = await svc.testWithCredentials({
|
||||
type: 'ecr',
|
||||
url: '123.dkr.ecr.us-east-1.amazonaws.com',
|
||||
username: 'AKIA',
|
||||
secret: 'bad',
|
||||
aws_region: 'us-east-1',
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toContain('InvalidSignatureException');
|
||||
});
|
||||
});
|
||||
|
||||
// ── ECR token cache ────────────────────────────────────────────────────
|
||||
|
||||
describe('RegistryService - ECR token cache', () => {
|
||||
function seedEcrRegistry() {
|
||||
return RegistryService.getInstance().create({
|
||||
name: 'ecr',
|
||||
url: '123.dkr.ecr.us-east-1.amazonaws.com',
|
||||
type: 'ecr',
|
||||
username: 'AKIA',
|
||||
secret: 'secret',
|
||||
aws_region: 'us-east-1',
|
||||
});
|
||||
}
|
||||
|
||||
it('fetches on first resolveDockerConfig call and caches on subsequent ones', async () => {
|
||||
seedEcrRegistry();
|
||||
const token = Buffer.from('AWS:pw').toString('base64');
|
||||
mockEcrSend.mockResolvedValue({
|
||||
authorizationData: [{ authorizationToken: token, expiresAt: new Date(Date.now() + 12 * 3600 * 1000) }],
|
||||
});
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
await svc.resolveDockerConfig();
|
||||
await svc.resolveDockerConfig();
|
||||
|
||||
expect(mockEcrSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refetches when the cached token is within the safety window of expiry', async () => {
|
||||
seedEcrRegistry();
|
||||
const token = Buffer.from('AWS:pw').toString('base64');
|
||||
// Expires in 4 minutes; safety window is 5 minutes, so this counts as expired.
|
||||
mockEcrSend
|
||||
.mockResolvedValueOnce({ authorizationData: [{ authorizationToken: token, expiresAt: new Date(Date.now() + 4 * 60 * 1000) }] })
|
||||
.mockResolvedValueOnce({ authorizationData: [{ authorizationToken: token, expiresAt: new Date(Date.now() + 12 * 3600 * 1000) }] });
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
await svc.resolveDockerConfig();
|
||||
await svc.resolveDockerConfig();
|
||||
|
||||
expect(mockEcrSend).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidates the cache when the registry is updated', async () => {
|
||||
const id = seedEcrRegistry();
|
||||
const token = Buffer.from('AWS:pw').toString('base64');
|
||||
mockEcrSend.mockResolvedValue({
|
||||
authorizationData: [{ authorizationToken: token, expiresAt: new Date(Date.now() + 12 * 3600 * 1000) }],
|
||||
});
|
||||
|
||||
const svc = RegistryService.getInstance();
|
||||
await svc.resolveDockerConfig();
|
||||
svc.update(id, { username: 'AKIA2' });
|
||||
await svc.resolveDockerConfig();
|
||||
|
||||
expect(mockEcrSend).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns a warning when an ECR registry is missing aws_region', async () => {
|
||||
const id = RegistryService.getInstance().create({
|
||||
name: 'ecr',
|
||||
url: '123.dkr.ecr.us-east-1.amazonaws.com',
|
||||
type: 'ecr',
|
||||
username: 'AKIA',
|
||||
secret: 'secret',
|
||||
aws_region: 'us-east-1',
|
||||
});
|
||||
// Simulate a broken row where aws_region was lost.
|
||||
DatabaseService.getInstance().updateRegistry(id, { aws_region: null });
|
||||
|
||||
const { warnings, config } = await RegistryService.getInstance().resolveDockerConfig();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toMatch(/aws_region|region/i);
|
||||
expect(Object.keys(config.auths)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -5405,6 +5405,27 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void =>
|
||||
|
||||
const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const;
|
||||
|
||||
function isValidRegistryUrl(url: string, type: string): boolean {
|
||||
// Docker Hub is fixed server-side to the legacy URL; no validation needed.
|
||||
if (type === 'dockerhub') return true;
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return false;
|
||||
// Reject any non-http(s) scheme (file://, ftp://, javascript:, etc.).
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('file:') || lower.startsWith('ftp:')) {
|
||||
return false;
|
||||
}
|
||||
// Parse with a default https:// prefix so bare hosts validate.
|
||||
try {
|
||||
const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
||||
if (!parsed.hostname) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -5433,6 +5454,9 @@ app.post('/api/registries', (req: Request, res: Response): void => {
|
||||
if (!type || !VALID_REGISTRY_TYPES.includes(type)) {
|
||||
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
|
||||
}
|
||||
if (!isValidRegistryUrl(url, type)) {
|
||||
res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return;
|
||||
}
|
||||
if (!username || typeof username !== 'string') {
|
||||
res.status(400).json({ error: 'Username is required.' }); return;
|
||||
}
|
||||
@@ -5474,6 +5498,9 @@ app.put('/api/registries/:id', (req: Request, res: Response): void => {
|
||||
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
|
||||
}
|
||||
const effectiveType = type ?? existing.type;
|
||||
if (url !== undefined && !isValidRegistryUrl(url, effectiveType)) {
|
||||
res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -5521,6 +5548,49 @@ app.post('/api/registries/:id/test', async (req: Request, res: Response): Promis
|
||||
}
|
||||
});
|
||||
|
||||
// Stateless test: validate credentials without persisting. Powers the
|
||||
// "Test connection" button inside the create/edit form so users can verify
|
||||
// creds before saving.
|
||||
app.post('/api/registries/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 (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const { type, url, username, secret, aws_region } = req.body;
|
||||
|
||||
if (!type || !VALID_REGISTRY_TYPES.includes(type)) {
|
||||
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
|
||||
}
|
||||
if (typeof url !== 'string' || url.length === 0 || url.length > 500) {
|
||||
res.status(400).json({ error: 'URL is required (max 500 characters).' }); return;
|
||||
}
|
||||
if (!isValidRegistryUrl(url, type)) {
|
||||
res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return;
|
||||
}
|
||||
if (typeof username !== 'string' || username.length === 0) {
|
||||
res.status(400).json({ error: 'Username is required.' }); return;
|
||||
}
|
||||
if (typeof secret !== 'string' || secret.length === 0) {
|
||||
res.status(400).json({ error: 'Secret/token is required.' }); return;
|
||||
}
|
||||
if (type === 'ecr' && (typeof aws_region !== 'string' || !aws_region)) {
|
||||
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
|
||||
}
|
||||
|
||||
const result = await RegistryService.getInstance().testWithCredentials({
|
||||
type,
|
||||
url,
|
||||
username,
|
||||
secret,
|
||||
aws_region: aws_region ?? null,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Registries] Stateless 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) => {
|
||||
|
||||
@@ -80,7 +80,10 @@ export class ComposeService {
|
||||
});
|
||||
}
|
||||
|
||||
private async withRegistryAuth<T>(fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T> {
|
||||
private async withRegistryAuth<T>(
|
||||
fn: (env: Record<string, string | undefined>) => Promise<T>,
|
||||
sendOutput?: (data: string) => void,
|
||||
): Promise<T> {
|
||||
const registries = DatabaseService.getInstance().getRegistries();
|
||||
if (registries.length === 0) {
|
||||
return fn({
|
||||
@@ -89,21 +92,29 @@ export class ComposeService {
|
||||
});
|
||||
}
|
||||
|
||||
const dockerConfig = await RegistryService.getInstance().resolveDockerConfig();
|
||||
const { config, warnings } = await RegistryService.getInstance().resolveDockerConfig();
|
||||
if (warnings.length > 0 && sendOutput) {
|
||||
for (const warning of warnings) {
|
||||
sendOutput(`[Sencho] Warning: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config), { 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 (e) {
|
||||
// Best-effort cleanup: temp config dir may already be removed or locked
|
||||
console.warn('[ComposeService] Could not clean up temp Docker config dir:', (e as Error).message);
|
||||
// Best-effort cleanup; each step runs independently so a file that was never
|
||||
// written (e.g., writeFileSync threw) does not prevent the directory removal.
|
||||
try { fs.unlinkSync(configPath); } catch { /* file may not exist */ }
|
||||
try { fs.rmdirSync(tmpDir); } catch (e) {
|
||||
console.warn('[ComposeService] Could not remove temp Docker config dir:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +158,7 @@ export class ComposeService {
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Deploy Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -181,7 +192,7 @@ export class ComposeService {
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
@@ -341,7 +352,7 @@ export class ComposeService {
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Update Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -377,7 +388,7 @@ export class ComposeService {
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
|
||||
});
|
||||
}, sendOutput);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,26 +24,132 @@ export interface RegistryUpdateInput {
|
||||
aws_region?: string | null;
|
||||
}
|
||||
|
||||
interface DockerConfigJson {
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── HTTP helper ─────────────────────────────────────────────────────────────
|
||||
interface EcrCacheEntry {
|
||||
username: string;
|
||||
password: string;
|
||||
expiresAt: number; // epoch ms
|
||||
}
|
||||
|
||||
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
|
||||
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: res.statusCode ?? 0,
|
||||
statusCode: status,
|
||||
headers: res.headers as Record<string, string | string[] | undefined>,
|
||||
body,
|
||||
}));
|
||||
@@ -57,6 +164,7 @@ function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs =
|
||||
export class RegistryService {
|
||||
private static instance: RegistryService;
|
||||
private crypto: CryptoService;
|
||||
private ecrCache = new Map<number, EcrCacheEntry>();
|
||||
|
||||
private constructor() {
|
||||
this.crypto = CryptoService.getInstance();
|
||||
@@ -90,9 +198,9 @@ export class RegistryService {
|
||||
public create(input: RegistryCreateInput): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
return db.addRegistry({
|
||||
const id = db.addRegistry({
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
url: normalizeRegistryUrl(input.url, input.type),
|
||||
type: input.type,
|
||||
username: input.username,
|
||||
secret: this.crypto.encrypt(input.secret),
|
||||
@@ -100,6 +208,8 @@ export class RegistryService {
|
||||
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 {
|
||||
@@ -112,8 +222,9 @@ export class RegistryService {
|
||||
};
|
||||
|
||||
if (input.name !== undefined) updates.name = input.name;
|
||||
if (input.url !== undefined) updates.url = input.url;
|
||||
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);
|
||||
@@ -121,57 +232,105 @@ export class RegistryService {
|
||||
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 {
|
||||
DatabaseService.getInstance().deleteRegistry(id);
|
||||
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 {
|
||||
const username = reg.username;
|
||||
const password = this.crypto.decrypt(reg.secret);
|
||||
password = this.crypto.decrypt(reg.secret);
|
||||
} catch (e) {
|
||||
return { success: false, error: `Could not decrypt stored secret: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (reg.type === 'ecr') {
|
||||
await this.getEcrToken(username, password, reg.aws_region!);
|
||||
return { success: true };
|
||||
}
|
||||
return this.testWithCredentials({
|
||||
type: reg.type,
|
||||
url: reg.url,
|
||||
username: reg.username,
|
||||
secret: password,
|
||||
aws_region: reg.aws_region,
|
||||
});
|
||||
}
|
||||
|
||||
// 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})` };
|
||||
}
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
return { success: false, error: `Registry returned HTTP ${res.statusCode}` };
|
||||
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 };
|
||||
}
|
||||
@@ -179,31 +338,40 @@ export class RegistryService {
|
||||
|
||||
// ─── Docker config resolution (for ComposeService) ───────────────────────
|
||||
|
||||
public async resolveDockerConfig(): Promise<DockerConfigJson> {
|
||||
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 {
|
||||
const decryptedSecret = this.crypto.decrypt(reg.secret);
|
||||
let username = reg.username;
|
||||
let password = decryptedSecret;
|
||||
let password: string;
|
||||
|
||||
if (reg.type === 'ecr') {
|
||||
const ecrCreds = await this.getEcrToken(reg.username, decryptedSecret, reg.aws_region!);
|
||||
username = ecrCreds.username;
|
||||
password = ecrCreds.password;
|
||||
const creds = await this.getEcrCredentials(reg);
|
||||
username = creds.username;
|
||||
password = creds.password;
|
||||
} else {
|
||||
password = this.crypto.decrypt(reg.secret);
|
||||
}
|
||||
|
||||
const auth = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
auths[reg.url] = { auth };
|
||||
const authsKey = reg.type === 'dockerhub' ? DOCKER_HUB_AUTHS_KEY : reg.url;
|
||||
auths[authsKey] = { auth: Buffer.from(`${username}:${password}`).toString('base64') };
|
||||
} catch (e) {
|
||||
console.error(`[RegistryService] Failed to resolve credentials for ${reg.name}:`, e);
|
||||
const msg = `Registry "${reg.name}" credentials unavailable: ${(e as Error).message}`;
|
||||
console.warn(`[RegistryService] ${msg}`);
|
||||
warnings.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
return { auths };
|
||||
return { config: { auths }, warnings };
|
||||
}
|
||||
|
||||
// ─── Registry auth for ImageUpdateService ────────────────────────────────
|
||||
@@ -211,32 +379,65 @@ export class RegistryService {
|
||||
public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const registries = db.getRegistries();
|
||||
const normalized = normalizeImageHost(registryHost);
|
||||
|
||||
// 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);
|
||||
});
|
||||
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 {
|
||||
const decryptedSecret = this.crypto.decrypt(match.secret);
|
||||
|
||||
if (match.type === 'ecr') {
|
||||
return await this.getEcrToken(match.username, decryptedSecret, match.aws_region!);
|
||||
return await this.getEcrCredentials(match);
|
||||
}
|
||||
|
||||
return { username: match.username, password: decryptedSecret };
|
||||
return { username: match.username, password: this.crypto.decrypt(match.secret) };
|
||||
} catch (e) {
|
||||
console.error(`[RegistryService] Failed to resolve auth for ${registryHost}:`, e);
|
||||
console.warn(`[RegistryService] Could not resolve auth for ${registryHost}: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ECR token fetch ─────────────────────────────────────────────────────
|
||||
// ─── ECR token fetch + cache ─────────────────────────────────────────────
|
||||
|
||||
private async getEcrToken(accessKeyId: string, secretAccessKey: string, region: string): Promise<{ username: string; password: string }> {
|
||||
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,
|
||||
@@ -247,17 +448,22 @@ export class RegistryService {
|
||||
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(':') };
|
||||
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 };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private normalizeRegistryUrl(url: string): string {
|
||||
// Ensure URL has a protocol
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
return url.replace(/\/$/, '');
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user