feat(registries): add private registry credential management (Team Pro) (#240)

Add centralized credential storage for private Docker registries with
support for Docker Hub, GHCR, AWS ECR, and self-hosted registries.

- New `registries` table with AES-256-GCM encrypted secrets
- RegistryService with CRUD, test connectivity, Docker config generation
- 5 API endpoints gated by requireTeamPro + requireAdmin
- ComposeService injects credentials via temp DOCKER_CONFIG on deploy/pull
- ImageUpdateService passes stored credentials for private registry checks
- AWS ECR just-in-time token refresh via @aws-sdk/client-ecr
- RegistriesSection UI in Settings Hub with type-aware form
- Documentation with screenshots
This commit is contained in:
Anso
2026-03-29 12:56:30 -04:00
committed by GitHub
parent 362b4a43d0
commit 244c83a0c3
16 changed files with 2317 additions and 20 deletions
+4
View File
@@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
* **registries:** add private registry credential management (Team Pro). Store encrypted credentials for Docker Hub, GHCR, AWS ECR, and self-hosted registries. Credentials are automatically injected during deploy/pull via temporary DOCKER_CONFIG. ECR short-lived tokens are refreshed on every operation. Image update checks now authenticate against private registries.
### Changed
* **ui:** redesign top bar into three-zone layout — node pill (left), animated navigation group (center), utilities (right). Replaces flat row of individual buttons with a cohesive tab-style navigation using animated sliding highlight. Includes responsive behavior: icon+text at xl, icons-only at md, sheet drawer on mobile.
+1271 -3
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -33,6 +33,7 @@
"vitest": "^4.1.0"
},
"dependencies": {
"@aws-sdk/client-ecr": "^3.1019.0",
"@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1",
"@types/express": "^5.0.6",
+124
View File
@@ -29,6 +29,7 @@ import { LicenseService } from './services/LicenseService';
import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
import YAML from 'yaml';
@@ -691,6 +692,9 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'PUT /scheduled-tasks': 'Updated scheduled task',
'DELETE /scheduled-tasks': 'Deleted scheduled task',
'PATCH /scheduled-tasks': 'Toggled scheduled task',
'POST /registries': 'Created registry credential',
'PUT /registries': 'Updated registry credential',
'DELETE /registries': 'Deleted registry credential',
};
function getAuditSummary(method: string, apiPath: string): string {
@@ -3664,6 +3668,126 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void =>
}
});
// --- Private Registry Routes (Team Pro, admin-only, local-only) ---
const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const;
app.get('/api/registries', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
res.json(RegistryService.getInstance().getAll());
} catch (error) {
console.error('[Registries] List error:', error);
res.status(500).json({ error: 'Failed to fetch registries' });
}
});
app.post('/api/registries', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const { name, url, type, username, secret, aws_region } = req.body;
if (!name || typeof name !== 'string' || name.length > 100) {
res.status(400).json({ error: 'Name is required (max 100 characters).' }); return;
}
if (!url || typeof url !== 'string' || url.length > 500) {
res.status(400).json({ error: 'URL is required (max 500 characters).' }); return;
}
if (!type || !VALID_REGISTRY_TYPES.includes(type)) {
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
}
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'Username is required.' }); return;
}
if (!secret || typeof secret !== 'string') {
res.status(400).json({ error: 'Secret/token is required.' }); return;
}
if (type === 'ecr' && (!aws_region || typeof aws_region !== 'string')) {
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
}
const id = RegistryService.getInstance().create({ name, url, type, username, secret, aws_region: aws_region ?? null });
res.status(201).json({ id });
} catch (error) {
console.error('[Registries] Create error:', error);
res.status(500).json({ error: 'Failed to create registry' });
}
});
app.put('/api/registries/:id', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const existing = RegistryService.getInstance().getById(id);
if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; }
const { name, url, type, username, secret, aws_region } = req.body;
if (name !== undefined && (typeof name !== 'string' || name.length > 100)) {
res.status(400).json({ error: 'Name must be a string (max 100 characters).' }); return;
}
if (url !== undefined && (typeof url !== 'string' || url.length > 500)) {
res.status(400).json({ error: 'URL must be a string (max 500 characters).' }); return;
}
if (type !== undefined && !VALID_REGISTRY_TYPES.includes(type)) {
res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return;
}
const effectiveType = type ?? existing.type;
if (effectiveType === 'ecr' && aws_region !== undefined && (typeof aws_region !== 'string' || !aws_region)) {
res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return;
}
RegistryService.getInstance().update(id, { name, url, type, username, secret, aws_region });
res.json({ success: true });
} catch (error) {
console.error('[Registries] Update error:', error);
res.status(500).json({ error: 'Failed to update registry' });
}
});
app.delete('/api/registries/:id', (req: Request, res: Response): void => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const existing = RegistryService.getInstance().getById(id);
if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; }
RegistryService.getInstance().delete(id);
res.json({ success: true });
} catch (error) {
console.error('[Registries] Delete error:', error);
res.status(500).json({ error: 'Failed to delete registry' });
}
});
app.post('/api/registries/:id/test', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; }
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; }
const result = await RegistryService.getInstance().testConnection(id);
res.json(result);
} catch (error) {
console.error('[Registries] Test error:', error);
res.status(500).json({ error: 'Failed to test registry connection' });
}
});
// --- System Maintenance Routes (The System Janitor) ---
app.get('/api/system/orphans', async (req: Request, res: Response) => {
+47 -9
View File
@@ -1,10 +1,14 @@
import { spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { RegistryService } from './RegistryService';
/**
* ComposeService - local docker compose CLI execution.
@@ -30,12 +34,13 @@ export class ComposeService {
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
throwOnError = true,
env?: Record<string, string | undefined>
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
env: env ?? {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
@@ -73,6 +78,31 @@ export class ComposeService {
});
}
private async withRegistryAuth<T>(fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T> {
const registries = DatabaseService.getInstance().getRegistries();
if (registries.length === 0) {
return fn({
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
});
}
const dockerConfig = await RegistryService.getInstance().resolveDockerConfig();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-docker-'));
const configPath = path.join(tmpDir, 'config.json');
try {
fs.writeFileSync(configPath, JSON.stringify(dockerConfig), { mode: 0o600 });
return await fn({
...process.env,
DOCKER_CONFIG: tmpDir,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
});
} finally {
try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch { /* best-effort cleanup */ }
}
}
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
await this.execute('docker', ['compose', action], stackDir, ws);
@@ -107,7 +137,9 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -138,7 +170,9 @@ export class ComposeService {
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.restoreStackFiles(stackName);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
@@ -289,11 +323,13 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
sendOutput('=== Pulling latest images ===\n');
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
sendOutput('=== Pulling latest images ===\n');
await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env);
sendOutput('=== Recreating containers ===\n');
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Recreating containers ===\n');
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
// Post-Update Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -326,7 +362,9 @@ export class ComposeService {
try {
const fsSvc = FileSystemService.getInstance(this.nodeId);
await fsSvc.restoreStackFiles(stackName);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.withRegistryAuth(async (env) => {
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env);
});
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
+70
View File
@@ -167,6 +167,20 @@ export interface ScheduledTaskRun {
triggered_by: 'scheduler' | 'manual';
}
export type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
export interface Registry {
id: number;
name: string;
url: string;
type: RegistryType;
username: string;
secret: string;
aws_region: string | null;
created_at: number;
updated_at: number;
}
export class DatabaseService {
private static instance: DatabaseService;
private db: Database.Database;
@@ -186,6 +200,7 @@ export class DatabaseService {
this.migrateAdminToUsersTable();
this.migrateEncryptNodeTokens();
this.migrateSSOColumns();
this.migrateRegistries();
}
public static getInstance(): DatabaseService {
@@ -498,6 +513,22 @@ export class DatabaseService {
`);
}
private migrateRegistries(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS registries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'custom',
username TEXT NOT NULL DEFAULT '',
secret TEXT NOT NULL DEFAULT '',
aws_region TEXT DEFAULT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
// --- Agents ---
public getAgents(): Agent[] {
@@ -1083,6 +1114,45 @@ export class DatabaseService {
this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
}
// --- Registries ---
public getRegistries(): Registry[] {
return this.db.prepare('SELECT * FROM registries ORDER BY name ASC').all() as Registry[];
}
public getRegistry(id: number): Registry | undefined {
return this.db.prepare('SELECT * FROM registries WHERE id = ?').get(id) as Registry | undefined;
}
public addRegistry(reg: Omit<Registry, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO registries (name, url, type, username, secret, aws_region, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(reg.name, reg.url, reg.type, reg.username, reg.secret, reg.aws_region, reg.created_at, reg.updated_at);
return result.lastInsertRowid as number;
}
public updateRegistry(id: number, updates: Partial<Omit<Registry, 'id' | 'created_at'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.url !== undefined) { fields.push('url = ?'); values.push(updates.url); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
if (updates.secret !== undefined) { fields.push('secret = ?'); values.push(updates.secret); }
if (updates.aws_region !== undefined) { fields.push('aws_region = ?'); values.push(updates.aws_region); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE registries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteRegistry(id: number): void {
this.db.prepare('DELETE FROM registries WHERE id = ?').run(id);
}
// --- Scheduled Tasks ---
public getScheduledTasks(): ScheduledTask[] {
+24 -6
View File
@@ -2,6 +2,7 @@ import https from 'https';
import http from 'http';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { RegistryService } from './RegistryService';
// ─── Image ref parsing ────────────────────────────────────────────────────────
@@ -73,15 +74,24 @@ function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs =
// ─── Registry auth ────────────────────────────────────────────────────────────
async function getAuthToken(registry: string, repo: string): Promise<string | null> {
async function getAuthToken(
registry: string,
repo: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
const basicHeaders: Record<string, string> = {};
if (credentials) {
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
// Ping /v2/ to get the WWW-Authenticate challenge
const ping = await httpGet(`https://${registry}/v2/`);
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
@@ -96,7 +106,7 @@ async function getAuthToken(registry: string, repo: string): Promise<string | nu
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl);
const tokenRes = await httpGet(tokenUrl, basicHeaders);
if (tokenRes.statusCode !== 200) return null;
const parsed = JSON.parse(tokenRes.body);
@@ -117,9 +127,14 @@ const MANIFEST_ACCEPT = [
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
async function getRemoteDigest(registry: string, repo: string, tag: string): Promise<string | null> {
async function getRemoteDigest(
registry: string,
repo: string,
tag: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
const token = await getAuthToken(registry, repo);
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
@@ -259,6 +274,9 @@ export class ImageUpdateService {
const parsed = parseImageRef(imageRef);
if (!parsed) return false;
// Look up stored credentials for this registry
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
// Get local digest from RepoDigests
let localDigest: string | null = null;
try {
@@ -281,7 +299,7 @@ export class ImageUpdateService {
if (!localDigest) return false; // Locally built or never pulled with a digest
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag);
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials);
if (!remoteDigest) return false; // Registry unreachable - no false positives
const hasUpdate = localDigest !== remoteDigest;
+263
View File
@@ -0,0 +1,263 @@
import https from 'https';
import http from 'http';
import { CryptoService } from './CryptoService';
import { DatabaseService, type Registry, type RegistryType } from './DatabaseService';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface RegistryCreateInput {
name: string;
url: string;
type: RegistryType;
username: string;
secret: string;
aws_region?: string | null;
}
export interface RegistryUpdateInput {
name?: string;
url?: string;
type?: RegistryType;
username?: string;
secret?: string;
aws_region?: string | null;
}
interface DockerConfigJson {
auths: Record<string, { auth: string }>;
}
interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
// ─── HTTP helper ─────────────────────────────────────────────────────────────
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http;
const req = lib.get(url, { headers }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers as Record<string, string | string[] | undefined>,
body,
}));
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
});
}
// ─── Service ─────────────────────────────────────────────────────────────────
export class RegistryService {
private static instance: RegistryService;
private crypto: CryptoService;
private constructor() {
this.crypto = CryptoService.getInstance();
}
public static getInstance(): RegistryService {
if (!RegistryService.instance) {
RegistryService.instance = new RegistryService();
}
return RegistryService.instance;
}
// ─── CRUD ────────────────────────────────────────────────────────────────
public getAll(): (Omit<Registry, 'secret'> & { has_secret: boolean })[] {
const db = DatabaseService.getInstance();
return db.getRegistries().map(r => {
const { secret, ...rest } = r;
return { ...rest, has_secret: !!secret };
});
}
public getById(id: number): (Omit<Registry, 'secret'> & { has_secret: boolean }) | undefined {
const db = DatabaseService.getInstance();
const r = db.getRegistry(id);
if (!r) return undefined;
const { secret, ...rest } = r;
return { ...rest, has_secret: !!secret };
}
public create(input: RegistryCreateInput): number {
const db = DatabaseService.getInstance();
const now = Date.now();
return db.addRegistry({
name: input.name,
url: input.url,
type: input.type,
username: input.username,
secret: this.crypto.encrypt(input.secret),
aws_region: input.aws_region ?? null,
created_at: now,
updated_at: now,
});
}
public update(id: number, input: RegistryUpdateInput): void {
const db = DatabaseService.getInstance();
const existing = db.getRegistry(id);
if (!existing) throw new Error('Registry not found');
const updates: Partial<Omit<Registry, 'id' | 'created_at'>> = {
updated_at: Date.now(),
};
if (input.name !== undefined) updates.name = input.name;
if (input.url !== undefined) updates.url = input.url;
if (input.type !== undefined) updates.type = input.type;
if (input.username !== undefined) updates.username = input.username;
if (input.secret !== undefined && input.secret !== '') {
updates.secret = this.crypto.encrypt(input.secret);
}
if (input.aws_region !== undefined) updates.aws_region = input.aws_region;
db.updateRegistry(id, updates);
}
public delete(id: number): void {
DatabaseService.getInstance().deleteRegistry(id);
}
// ─── Test connectivity ───────────────────────────────────────────────────
public async testConnection(id: number): Promise<{ success: boolean; error?: string }> {
const db = DatabaseService.getInstance();
const reg = db.getRegistry(id);
if (!reg) return { success: false, error: 'Registry not found' };
try {
const username = reg.username;
const password = this.crypto.decrypt(reg.secret);
if (reg.type === 'ecr') {
await this.getEcrToken(username, password, reg.aws_region!);
return { success: true };
}
// Standard registry: attempt /v2/ ping with Basic auth
const registryUrl = this.normalizeRegistryUrl(reg.url);
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
const res = await httpGet(`${registryUrl}/v2/`, { Authorization: `Basic ${basicAuth}` });
if (res.statusCode === 200 || res.statusCode === 401) {
// 401 with valid challenge means registry is reachable
// Try token-based auth if we got 401
if (res.statusCode === 401) {
const wwwAuth = res.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return { success: false, error: 'Registry returned 401 without auth challenge' };
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
if (!realmMatch) return { success: false, error: 'Could not parse auth challenge' };
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
const tokenUrl = `${realmMatch[1]}?${params.toString()}`;
const tokenRes = await httpGet(tokenUrl, { Authorization: `Basic ${basicAuth}` });
if (tokenRes.statusCode !== 200) {
return { success: false, error: `Authentication failed (${tokenRes.statusCode})` };
}
}
return { success: true };
}
return { success: false, error: `Registry returned HTTP ${res.statusCode}` };
} catch (e) {
return { success: false, error: (e as Error).message };
}
}
// ─── Docker config resolution (for ComposeService) ───────────────────────
public async resolveDockerConfig(): Promise<DockerConfigJson> {
const db = DatabaseService.getInstance();
const registries = db.getRegistries();
const auths: Record<string, { auth: string }> = {};
for (const reg of registries) {
try {
const decryptedSecret = this.crypto.decrypt(reg.secret);
let username = reg.username;
let password = decryptedSecret;
if (reg.type === 'ecr') {
const ecrCreds = await this.getEcrToken(reg.username, decryptedSecret, reg.aws_region!);
username = ecrCreds.username;
password = ecrCreds.password;
}
const auth = Buffer.from(`${username}:${password}`).toString('base64');
auths[reg.url] = { auth };
} catch (e) {
console.error(`[RegistryService] Failed to resolve credentials for ${reg.name}:`, e);
}
}
return { auths };
}
// ─── Registry auth for ImageUpdateService ────────────────────────────────
public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> {
const db = DatabaseService.getInstance();
const registries = db.getRegistries();
// Match by URL containing the registry host
const match = registries.find(r => {
const normalizedUrl = r.url.replace(/^https?:\/\//, '').replace(/\/$/, '');
return normalizedUrl === registryHost || normalizedUrl.includes(registryHost) || registryHost.includes(normalizedUrl);
});
if (!match) return null;
try {
const decryptedSecret = this.crypto.decrypt(match.secret);
if (match.type === 'ecr') {
return await this.getEcrToken(match.username, decryptedSecret, match.aws_region!);
}
return { username: match.username, password: decryptedSecret };
} catch (e) {
console.error(`[RegistryService] Failed to resolve auth for ${registryHost}:`, e);
return null;
}
}
// ─── ECR token fetch ─────────────────────────────────────────────────────
private async getEcrToken(accessKeyId: string, secretAccessKey: string, region: string): Promise<{ username: string; password: string }> {
const { ECRClient, GetAuthorizationTokenCommand } = await import('@aws-sdk/client-ecr');
const client = new ECRClient({
region,
credentials: { accessKeyId, secretAccessKey },
});
const response = await client.send(new GetAuthorizationTokenCommand({}));
const authData = response.authorizationData?.[0];
if (!authData?.authorizationToken) throw new Error('ECR returned no authorization token');
const decoded = Buffer.from(authData.authorizationToken, 'base64').toString();
const [username, ...passwordParts] = decoded.split(':');
return { username, password: passwordParts.join(':') };
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private normalizeRegistryUrl(url: string): string {
// Ensure URL has a protocol
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `https://${url}`;
}
return url.replace(/\/$/, '');
}
}
+1
View File
@@ -98,6 +98,7 @@
"features/fleet-backups",
"features/audit-log",
"features/api-tokens",
"features/private-registries",
"features/scheduled-operations",
"features/sso",
"features/licensing"
+4
View File
@@ -67,6 +67,10 @@ Pro users get automatic backup and rollback on every deployment. Before applying
Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy - even to remote nodes. [Learn more →](/features/fleet-backups)
## Private registries
Store credentials for private Docker registries - Docker Hub organizations, GHCR, AWS ECR, and self-hosted registries. Sencho injects them automatically during deploy and pull operations. ECR short-lived tokens are refreshed on every operation. Team Pro only. [Learn more →](/features/private-registries)
## Audit log
Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings - with timestamps, user attribution, and node context. Team Pro only. [Learn more →](/features/audit-log)
+114
View File
@@ -0,0 +1,114 @@
---
title: Private Registries
description: Store credentials for private Docker registries so Sencho can automatically authenticate during deploy, pull, and image update checks.
---
<Note>
Private Registry Management requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature.
</Note>
Sencho can store credentials for your private Docker registries and inject them automatically whenever it runs `docker compose pull` or `docker compose up`. This means your stacks can reference private images without needing to manually `docker login` on the host.
## Supported registry types
| Type | Description | Credentials |
|------|-------------|-------------|
| **Docker Hub** | Private Docker Hub organizations and repositories | Username + access token |
| **GHCR** | GitHub Container Registry (`ghcr.io`) | GitHub username + personal access token (PAT) |
| **AWS ECR** | Amazon Elastic Container Registry | AWS Access Key ID + Secret Access Key |
| **Custom** | Any self-hosted Docker V2 registry | Username + password or token |
## Adding a registry
1. Open **Settings Hub** and navigate to the **Registries** tab (visible to Team Pro admins only).
2. Click **Add Registry**.
3. Select the registry type. 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**.
<Frame>
<img src="/images/private-registries/registries-with-entry.png" alt="Private Registries management view in Settings Hub showing a configured Docker Hub registry" />
</Frame>
<Frame>
<img src="/images/private-registries/registries-add-form.png" alt="Add Registry form with type selector, credentials, and URL fields" />
</Frame>
## Testing connectivity
After adding a registry, click the checkmark icon on the registry card to test the connection. Sencho will attempt to authenticate against the registry's `/v2/` endpoint and report success or failure.
For ECR registries, the test verifies that the AWS credentials can successfully obtain an authorization token.
## How credentials are applied
### Deploy and pull operations
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).
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).
6. Cleans up the temporary config file immediately after.
This approach ensures credentials are never persisted on disk beyond the duration of the operation.
### 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.
## ECR setup
AWS ECR uses short-lived authentication tokens (valid for 12 hours) derived from your IAM credentials. Sencho handles this automatically:
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.
<Warning>
Use an IAM user or role with only the `ecr:GetAuthorizationToken` and `ecr:BatchGetImage` permissions. Avoid using root account credentials.
</Warning>
### IAM policy example
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchCheckLayerAvailability"
],
"Resource": "*"
}
]
}
```
## Registry URL reference
| Registry | URL to use |
|----------|-----------|
| Docker Hub | `https://index.docker.io/v1/` |
| GHCR | `ghcr.io` |
| AWS ECR | `{account_id}.dkr.ecr.{region}.amazonaws.com` |
| Self-hosted | Your registry hostname, e.g. `registry.example.com` |
## Security
- **Encrypted storage** - Registry secrets are encrypted at rest using AES-256-GCM, the same encryption used for remote node tokens and SSO secrets.
- **No persistent Docker login** - Credentials are written to a temporary file for the duration of each operation and immediately deleted afterward.
- **Secrets never exposed** - The API never returns decrypted secrets. The UI shows only whether a secret is stored.
- **Audit trail** - Registry create, update, and delete operations are recorded in the [Audit Log](/features/audit-log).
- **Admin-only access** - Only admin users with a Team Pro license can manage registry credentials.
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

@@ -0,0 +1,384 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { TeamProGate } from './TeamProGate';
import { TierBadge } from './TierBadge';
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react';
type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
interface RegistryItem {
id: number;
name: string;
url: string;
type: RegistryType;
username: string;
has_secret: boolean;
aws_region: string | null;
created_at: number;
updated_at: number;
}
const TYPE_LABELS: Record<RegistryType, string> = {
dockerhub: 'Docker Hub',
ghcr: 'GitHub (GHCR)',
ecr: 'AWS ECR',
custom: 'Custom',
};
const TYPE_BADGE_VARIANT: Record<RegistryType, 'default' | 'secondary' | 'outline'> = {
dockerhub: 'default',
ghcr: 'secondary',
ecr: 'secondary',
custom: 'outline',
};
const TYPE_URL_DEFAULTS: Record<RegistryType, string> = {
dockerhub: 'https://index.docker.io/v1/',
ghcr: 'ghcr.io',
ecr: '',
custom: '',
};
const TYPE_USERNAME_HINT: Record<RegistryType, string> = {
dockerhub: 'Docker Hub username',
ghcr: 'GitHub username',
ecr: 'AWS Access Key ID',
custom: 'Username',
};
const TYPE_SECRET_HINT: Record<RegistryType, string> = {
dockerhub: 'Access token or password',
ghcr: 'Personal access token (PAT)',
ecr: 'AWS Secret Access Key',
custom: 'Password or token',
};
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
export function RegistriesSection() {
const [registries, setRegistries] = useState<RegistryItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formUrl, setFormUrl] = useState('');
const [formType, setFormType] = useState<RegistryType>('dockerhub');
const [formUsername, setFormUsername] = useState('');
const [formSecret, setFormSecret] = useState('');
const [formAwsRegion, setFormAwsRegion] = useState('');
const fetchRegistries = async () => {
try {
const res = await apiFetch('/registries', { localOnly: true });
if (res.ok) {
setRegistries(await res.json());
}
} catch {
toast.error('Failed to load registries.');
} finally {
setLoading(false);
}
};
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchRegistries(); }, []);
const resetForm = () => {
setFormName('');
setFormUrl('');
setFormType('dockerhub');
setFormUsername('');
setFormSecret('');
setFormAwsRegion('');
setEditingId(null);
setShowForm(false);
};
const handleTypeChange = (type: RegistryType) => {
setFormType(type);
if (!editingId) {
setFormUrl(TYPE_URL_DEFAULTS[type]);
}
};
const startEdit = (reg: RegistryItem) => {
setEditingId(reg.id);
setFormName(reg.name);
setFormUrl(reg.url);
setFormType(reg.type);
setFormUsername(reg.username);
setFormSecret('');
setFormAwsRegion(reg.aws_region ?? '');
setShowForm(true);
};
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; }
setSaving(true);
try {
const body: Record<string, unknown> = {
name: formName.trim(),
url: formUrl.trim(),
type: formType,
username: formUsername.trim(),
aws_region: formType === 'ecr' ? formAwsRegion.trim() : null,
};
if (formSecret.trim()) body.secret = formSecret.trim();
const url = editingId ? `/registries/${editingId}` : '/registries';
const method = editingId ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
localOnly: true,
body: JSON.stringify(body),
});
if (res.ok) {
toast.success(editingId ? 'Registry updated.' : 'Registry added.');
resetForm();
fetchRegistries();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
try {
const res = await apiFetch(`/registries/${id}`, { method: 'DELETE', localOnly: true });
if (res.ok) {
toast.success('Registry deleted.');
fetchRegistries();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to delete registry.');
}
} catch {
toast.error('Network error.');
}
};
const handleTest = async (id: number) => {
setTestingId(id);
try {
const res = await apiFetch(`/registries/${id}/test`, { method: 'POST', localOnly: true });
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(() => ({}));
toast.error(err?.error || err?.message || 'Test failed.');
}
} catch {
toast.error('Network error.');
} finally {
setTestingId(null);
}
};
return (
<TeamProGate featureName="Private Registry Management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">
Private Registries <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Store credentials for private Docker registries. Sencho injects them automatically during deploy and pull operations.
</p>
</div>
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-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-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>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="My private registry"
value={formName}
onChange={e => setFormName(e.target.value)}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label>Registry URL</Label>
<Input
placeholder={formType === 'ecr' ? '123456789.dkr.ecr.us-east-1.amazonaws.com' : 'registry.example.com'}
value={formUrl}
onChange={e => setFormUrl(e.target.value)}
maxLength={500}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{formType === 'ecr' ? 'AWS Access Key ID' : 'Username'}</Label>
<Input
placeholder={TYPE_USERNAME_HINT[formType]}
value={formUsername}
onChange={e => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{formType === 'ecr' ? 'AWS Secret Access Key' : 'Secret / Token'}</Label>
<Input
type="password"
placeholder={editingId ? '(leave blank to keep current)' : TYPE_SECRET_HINT[formType]}
value={formSecret}
onChange={e => setFormSecret(e.target.value)}
/>
</div>
</div>
{formType === 'ecr' && (
<div className="space-y-2">
<Label>AWS Region</Label>
<Input
placeholder="us-east-1"
value={formAwsRegion}
onChange={e => setFormAwsRegion(e.target.value)}
/>
</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'}
</Button>
</div>
</div>
)}
{/* Loading state */}
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</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" />
<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>
)}
{/* Registry list */}
{!loading && registries.map(reg => (
<div key={reg.id} className="border border-border rounded-xl 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" />
<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]}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleTest(reg.id)}
disabled={testingId === reg.id}
title="Test connection"
>
{testingId === reg.id ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => startEdit(reg)} title="Edit">
<Pencil className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" title="Delete">
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete registry?</AlertDialogTitle>
<AlertDialogDescription>
Deleting <strong>{reg.name}</strong> will remove its stored credentials. Stacks using images from this registry will fail to pull until credentials are re-added.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(reg.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<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-emerald-500" /> Secret stored</>
) : (
<><XCircle className="w-3 h-3 text-destructive" /> 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" />
{formatDate(reg.created_at)}
</span>
</div>
</div>
))}
</div>
</TeamProGate>
);
}
+10 -2
View File
@@ -29,6 +29,7 @@ import { TierBadge } from './TierBadge';
import { ProGate } from './ProGate';
import { SSOSection } from './SSOSection';
import { ApiTokensSection } from './ApiTokensSection';
import { RegistriesSection } from './RegistriesSection';
interface Agent {
type: 'discord' | 'slack' | 'webhook';
@@ -50,7 +51,7 @@ interface PatchableSettings {
log_retention_days?: string;
}
type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about';
type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'registries' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about';
interface WebhookItem {
id: number;
@@ -658,7 +659,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
// When switching to a remote node, reset to a node-scoped section if on a global-only one
useEffect(() => {
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'registries' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
setActiveSection('system');
}
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -1005,6 +1006,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{!isRemote && isAdmin && isPro && license?.variant === 'team' && (
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" />
)}
{!isRemote && isAdmin && isPro && license?.variant === 'team' && (
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" />
)}
<NavButton
section="system"
icon={<Activity className="w-4 h-4 mr-2" />}
@@ -1468,6 +1472,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<ApiTokensSection />
)}
{activeSection === 'registries' && (
<RegistriesSection />
)}
{activeSection === 'developer' && (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">