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:
Anso
2026-04-14 17:58:57 -04:00
committed by GitHub
parent aeb8adc5f5
commit 6275adc6b3
7 changed files with 1088 additions and 136 deletions
+23 -3
View File
@@ -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);
});
});
+70
View File
@@ -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) => {
+21 -10
View File
@@ -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);
+278 -72
View File
@@ -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);
}
}