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);
}
}
+41 -5
View File
@@ -25,7 +25,8 @@ Sencho can store credentials for your private Docker registries and inject them
3. Select the registry type. When you switch types, the URL field auto-fills with the standard endpoint for Docker Hub and GHCR.
4. Enter a descriptive name, the registry URL, and your credentials.
5. For **ECR** registries, also provide the AWS region (e.g., `us-east-1`).
6. Click **Add**.
6. Click **Test connection** to verify the credentials work before saving. Sencho talks to the registry's `/v2/` endpoint (or the AWS STS API for ECR) and reports success or the specific failure reason.
7. Click **Add** to store the credentials.
<Frame>
<img src="/images/private-registries/registries-with-entry.png" alt="Private Registries management view in Settings Hub showing a configured Docker Hub registry" />
@@ -56,9 +57,12 @@ When editing a registry, you can leave the secret field blank to keep the existi
### Testing connectivity
Click the checkmark icon on any registry card to test the connection. Sencho authenticates against the registry's `/v2/` endpoint and reports success or failure.
You can test credentials at two points:
For ECR registries, the test verifies that the AWS credentials can successfully obtain an authorization token.
- **Before saving**, using the **Test connection** button inside the add or edit form. This is useful for confirming a token or password works without committing it to storage first.
- **After saving**, by clicking the checkmark icon on any registry card. This re-decrypts the stored secret and re-runs the same probe.
For standard registries, Sencho authenticates against `/v2/` using Basic auth and falls back to the registry's token endpoint if it receives a `401` with a `WWW-Authenticate` challenge. For ECR registries, the test verifies that the AWS credentials can successfully obtain an authorization token.
## How credentials are applied
@@ -67,7 +71,7 @@ For ECR registries, the test verifies that the AWS credentials can successfully
When you deploy or update a stack, Sencho:
1. Resolves credentials for all configured registries.
2. For ECR registries, fetches a fresh authorization token from AWS (ECR tokens are short-lived, lasting 12 hours).
2. For ECR registries, reuses a cached authorization token when one is still valid; otherwise fetches a fresh token from AWS. Cached tokens are refreshed a few minutes before AWS reports them as expired, so deploys never fail on a borderline token.
3. Writes a temporary Docker config file with all registry auth entries.
4. Sets the `DOCKER_CONFIG` environment variable so `docker compose` uses the temporary config.
5. Runs the compose operation (pull and/or up).
@@ -75,6 +79,8 @@ When you deploy or update a stack, Sencho:
This approach ensures credentials are never persisted on disk beyond the duration of the operation.
If a stored secret cannot be decrypted at deploy time (for example, because the encryption key file was replaced), Sencho skips that one registry, records a warning in the deploy log stream prefixed with `[Sencho] Warning:`, and continues. This lets public-image deploys succeed even when one private registry is misconfigured, while making the failure visible so you can re-save the credentials.
### Image update checks
Sencho's background image update checker also uses stored registry credentials. When checking for newer image versions, it passes your credentials to the registry's authentication endpoint so it can compare local and remote digests for private images.
@@ -85,7 +91,7 @@ AWS ECR uses short-lived authentication tokens (valid for 12 hours) derived from
1. Store your **AWS Access Key ID** and **Secret Access Key** as the username and secret.
2. Specify the **AWS Region** where your ECR registry lives.
3. On every deploy or pull, Sencho calls the AWS `GetAuthorizationToken` API to obtain a fresh token.
3. On every deploy or pull, Sencho reuses a cached authorization token when one is still valid, or calls the AWS `GetAuthorizationToken` API to obtain a fresh one.
<Warning>
Use an IAM user or role with only the `ecr:GetAuthorizationToken` and `ecr:BatchGetImage` permissions. Avoid using root account credentials.
@@ -131,3 +137,33 @@ AWS ECR uses short-lived authentication tokens (valid for 12 hours) derived from
## Multi-node behavior
Registry credentials are stored per Sencho instance. When managing remote nodes, each node runs its own Sencho instance with its own registry credentials. Configure private registries on each node that needs access to private images.
## Troubleshooting
### Pull fails with a 401 even though the credentials saved successfully
The most common cause is a URL format mismatch. Use the forms in the **Registry URL reference** table above, and note that the URL is case-sensitive for most registries. For Docker Hub, any URL you enter is stored as the canonical `https://index.docker.io/v1/` because that is the key the Docker CLI expects.
If the URL is correct, re-open the registry, re-enter the secret, and click **Test connection** inside the form. Some providers rotate tokens silently, and the card view cannot tell you that the stored token was revoked without attempting a live probe.
### An ECR registry works for a while and then stops pulling
AWS ECR authorization tokens are valid for 12 hours. Sencho caches the token in memory and refreshes it automatically a few minutes before the AWS-reported expiry, so no action is needed on your part.
If pulls still fail, confirm the IAM user still has the `ecr:GetAuthorizationToken` permission and that the AWS Access Key ID has not been rotated. Click **Test connection** on the registry card to force a fresh token fetch.
### The deploy log shows `[Sencho] Warning: Registry "X" credentials unavailable`
Sencho could not decrypt the stored secret for that registry. The deploy continues without that registry's credentials, which is fine if the stack pulls only public images.
To fix the registry, open it in the Registries tab, re-enter the secret, and save. If the same warning appears for every registry, the encryption key file at your data directory has been replaced or lost, and every stored secret needs to be re-entered.
### Test connection says it failed, but deploys still pull images successfully
Some registries (notably certain self-hosted mirrors and proxy caches) restrict access to the `/v2/` discovery endpoint while still allowing pulls. Sencho uses `/v2/` as the probe target, so a failure there does not always indicate broken credentials.
If your deploys succeed, you can safely ignore the test result. The test is a best-effort check, not a gate on saving.
### Credentials work on the local node but not on a remote node
Registry credentials are stored per Sencho instance, not shared across nodes. Open the remote node from the node switcher and configure the same registry on that node's Registries tab.
+132 -46
View File
@@ -4,14 +4,14 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react';
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock, Zap } from 'lucide-react';
type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
@@ -27,6 +27,19 @@ interface RegistryItem {
updated_at: number;
}
interface ApiError {
error?: string;
message?: string;
data?: { error?: string };
}
const TYPE_OPTIONS: { value: RegistryType; label: string }[] = [
{ value: 'dockerhub', label: 'Docker Hub' },
{ value: 'ghcr', label: 'GitHub Container Registry (GHCR)' },
{ value: 'ecr', label: 'AWS Elastic Container Registry (ECR)' },
{ value: 'custom', label: 'Custom / Self-hosted' },
];
const TYPE_LABELS: Record<RegistryType, string> = {
dockerhub: 'Docker Hub',
ghcr: 'GitHub (GHCR)',
@@ -62,6 +75,12 @@ const TYPE_SECRET_HINT: Record<RegistryType, string> = {
custom: 'Password or token',
};
/** Defensive toast chain per CLAUDE.md Directive 6. */
function toastError(e: unknown, fallback: string): void {
const err = e as ApiError | undefined;
toast.error(err?.message || err?.error || err?.data?.error || fallback);
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
@@ -73,6 +92,7 @@ export function RegistriesSection() {
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [testingForm, setTestingForm] = useState(false);
const [formName, setFormName] = useState('');
const [formUrl, setFormUrl] = useState('');
@@ -126,12 +146,58 @@ export function RegistriesSection() {
setShowForm(true);
};
const validateForm = (): boolean => {
if (!formName.trim()) { toast.error('Name is required.'); return false; }
if (!formUrl.trim()) { toast.error('URL is required.'); return false; }
if (!formUsername.trim()) { toast.error('Username is required.'); return false; }
if (!editingId && !formSecret.trim()) { toast.error('Secret/token is required.'); return false; }
if (formType === 'ecr' && !formAwsRegion.trim()) { toast.error('AWS region is required for ECR.'); return false; }
return true;
};
const handleTestForm = async () => {
// Stateless test requires a secret; on edit, user must re-enter it.
if (!formUrl.trim() || !formUsername.trim() || !formSecret.trim()) {
toast.error('Fill URL, username, and secret to test.');
return;
}
if (formType === 'ecr' && !formAwsRegion.trim()) {
toast.error('AWS region is required for ECR.');
return;
}
setTestingForm(true);
try {
const res = await apiFetch('/registries/test', {
method: 'POST',
localOnly: true,
body: JSON.stringify({
type: formType,
url: formUrl.trim(),
username: formUsername.trim(),
secret: formSecret.trim(),
aws_region: formType === 'ecr' ? formAwsRegion.trim() : null,
}),
});
if (res.ok) {
const data = await res.json();
if (data.success) {
toast.success('Connection successful.');
} else {
toast.error(data.error || 'Connection failed.');
}
} else {
const err = await res.json().catch(() => ({}));
toastError(err, 'Test failed.');
}
} catch (e) {
toastError(e, 'Network error.');
} finally {
setTestingForm(false);
}
};
const handleSave = async () => {
if (!formName.trim()) { toast.error('Name is required.'); return; }
if (!formUrl.trim()) { toast.error('URL is required.'); return; }
if (!formUsername.trim()) { toast.error('Username is required.'); return; }
if (!editingId && !formSecret.trim()) { toast.error('Secret/token is required.'); return; }
if (formType === 'ecr' && !formAwsRegion.trim()) { toast.error('AWS region is required for ECR.'); return; }
if (!validateForm()) return;
setSaving(true);
try {
@@ -159,10 +225,10 @@ export function RegistriesSection() {
fetchRegistries();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry.');
toastError(err, 'Failed to save registry.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} catch (e) {
toastError(e, 'Network error.');
} finally {
setSaving(false);
}
@@ -176,10 +242,10 @@ export function RegistriesSection() {
fetchRegistries();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to delete registry.');
toastError(err, 'Failed to delete registry.');
}
} catch {
toast.error('Network error.');
} catch (e) {
toastError(e, 'Network error.');
}
};
@@ -190,16 +256,16 @@ export function RegistriesSection() {
if (res.ok) {
const data = await res.json();
if (data.success) {
toast.success('Connection successful!');
toast.success('Connection successful.');
} else {
toast.error(data.error || 'Connection failed.');
}
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Test failed.');
toastError(err, 'Test failed.');
}
} catch {
toast.error('Network error.');
} catch (e) {
toastError(e, 'Network error.');
} finally {
setTestingId(null);
}
@@ -219,24 +285,22 @@ export function RegistriesSection() {
</p>
</div>
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1.5" /> Add Registry
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} /> Add Registry
</Button>
</div>
{/* Create / Edit form */}
{showForm && (
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
<div className="space-y-4 p-4 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<div className="space-y-2">
<Label>Registry Type</Label>
<Select value={formType} onValueChange={(v) => handleTypeChange(v as RegistryType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dockerhub">Docker Hub</SelectItem>
<SelectItem value="ghcr">GitHub Container Registry (GHCR)</SelectItem>
<SelectItem value="ecr">AWS Elastic Container Registry (ECR)</SelectItem>
<SelectItem value="custom">Custom / Self-hosted</SelectItem>
</SelectContent>
</Select>
<Combobox
options={TYPE_OPTIONS}
value={formType}
onValueChange={(v) => handleTypeChange(v as RegistryType)}
placeholder="Select a registry type"
searchPlaceholder="Search types..."
/>
</div>
<div className="space-y-2">
<Label>Name</Label>
@@ -254,6 +318,7 @@ export function RegistriesSection() {
value={formUrl}
onChange={e => setFormUrl(e.target.value)}
maxLength={500}
disabled={formType === 'dockerhub'}
/>
</div>
<div className="grid grid-cols-2 gap-4">
@@ -285,11 +350,27 @@ export function RegistriesSection() {
/>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Saving...</> : editingId ? 'Update' : 'Add'}
<div className="flex justify-between items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleTestForm}
disabled={testingForm || saving}
>
{testingForm ? (
<><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Testing...</>
) : (
<><Zap className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Test connection</>
)}
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? (
<><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Saving...</>
) : editingId ? 'Update' : 'Add'}
</Button>
</div>
</div>
</div>
)}
@@ -297,15 +378,15 @@ export function RegistriesSection() {
{/* Loading state */}
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
)}
{/* Empty state */}
{!loading && registries.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Database className="w-10 h-10 text-muted-foreground/50 mb-3" />
<Database className="w-10 h-10 text-muted-foreground/50 mb-3" strokeWidth={1.5} />
<p className="text-sm text-muted-foreground">No private registries configured.</p>
<p className="text-xs text-muted-foreground mt-1">Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries.</p>
</div>
@@ -313,10 +394,10 @@ export function RegistriesSection() {
{/* Registry list */}
{!loading && registries.map(reg => (
<div key={reg.id} className="border border-border rounded-xl p-4 space-y-3">
<div key={reg.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Database className="w-4 h-4 text-muted-foreground shrink-0" />
<Database className="w-4 h-4 text-stat-icon shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm truncate">{reg.name}</span>
<Badge variant={TYPE_BADGE_VARIANT[reg.type]} className="text-[10px] shrink-0">
{TYPE_LABELS[reg.type]}
@@ -331,18 +412,23 @@ export function RegistriesSection() {
title="Test connection"
>
{testingId === reg.id ? (
<RefreshCw className="w-4 h-4 animate-spin" />
<RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />
) : (
<CheckCircle className="w-4 h-4" />
<CheckCircle className="w-4 h-4" strokeWidth={1.5} />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => startEdit(reg)} title="Edit">
<Pencil className="w-4 h-4" />
<Pencil className="w-4 h-4" strokeWidth={1.5} />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" title="Delete">
<Trash2 className="w-4 h-4" />
<Button
variant="ghost"
size="sm"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
title="Delete"
>
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -362,19 +448,19 @@ export function RegistriesSection() {
</AlertDialog>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4 text-xs text-stat-subtitle">
<span className="font-mono truncate max-w-[200px]" title={reg.url}>{reg.url}</span>
<span>{reg.username}</span>
<span className="flex items-center gap-1">
{reg.has_secret ? (
<><CheckCircle className="w-3 h-3 text-success" /> Secret stored</>
<><CheckCircle className="w-3 h-3 text-success" strokeWidth={1.5} /> Secret stored</>
) : (
<><XCircle className="w-3 h-3 text-destructive" /> No secret</>
<><XCircle className="w-3 h-3 text-destructive" strokeWidth={1.5} /> No secret</>
)}
</span>
{reg.aws_region && <span>Region: {reg.aws_region}</span>}
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<Clock className="w-3 h-3" strokeWidth={1.5} />
{formatDate(reg.created_at)}
</span>
</div>