Add Docker Registry AIO with Nuxt Token Auth and admin UI.
Compose brings up registry:3 plus a Prisma/SQLite Nuxt app for docker login, ACL management, and image browsing with shadcn-vue. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
0fd00bfa37
120 files changed
+17094
No files matched your search
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Glob-like repository matching:
|
||||
* - exact match
|
||||
* - `*` matches a single path segment (no `/`)
|
||||
* - `**` matches any remaining path
|
||||
*/
|
||||
export function matchRepository(pattern: string, repository: string): boolean {
|
||||
if (pattern === '*' || pattern === '**') return true
|
||||
if (pattern === repository) return true
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
const regexSource = pattern
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
if (segment === '**') return '(.+)'
|
||||
if (segment === '*') return '([^/]+)'
|
||||
return escapeRegex(segment).replace(/\\\*/g, '[^/]*')
|
||||
})
|
||||
.join('/')
|
||||
|
||||
return new RegExp(`^${regexSource}$`).test(repository)
|
||||
}
|
||||
|
||||
export type Action = 'pull' | 'push' | 'delete' | '*'
|
||||
|
||||
export function actionsFromAcl(acl: {
|
||||
canPull: boolean
|
||||
canPush: boolean
|
||||
canDelete: boolean
|
||||
}): Action[] {
|
||||
const actions: Action[] = []
|
||||
if (acl.canPull) actions.push('pull')
|
||||
if (acl.canPush) actions.push('push')
|
||||
if (acl.canDelete) actions.push('delete')
|
||||
return actions
|
||||
}
|
||||
|
||||
export function intersectActions(requested: string[], allowed: string[]): string[] {
|
||||
if (allowed.includes('*')) return requested
|
||||
return requested.filter((a) => allowed.includes(a) || (a === '*' && allowed.length > 0))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Acl, Role, User } from '../../generated/prisma/client'
|
||||
import {
|
||||
actionsFromAcl,
|
||||
intersectActions,
|
||||
matchRepository,
|
||||
type Action,
|
||||
} from './acl'
|
||||
import type { RegistryAccess } from './token'
|
||||
|
||||
export type UserWithAcls = User & { acls: Acl[] }
|
||||
|
||||
export type ParsedScope = {
|
||||
type: string
|
||||
name: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
/** Parse Docker scope strings like `repository:library/nginx:pull,push` */
|
||||
export function parseScopes(scopeParam: string | string[] | undefined): ParsedScope[] {
|
||||
if (!scopeParam) return []
|
||||
const raw = Array.isArray(scopeParam) ? scopeParam : [scopeParam]
|
||||
const scopes: ParsedScope[] = []
|
||||
|
||||
for (const item of raw) {
|
||||
for (const part of item.split(' ')) {
|
||||
if (!part) continue
|
||||
const first = part.indexOf(':')
|
||||
const last = part.lastIndexOf(':')
|
||||
if (first < 0 || last <= first) continue
|
||||
const type = part.slice(0, first)
|
||||
const name = part.slice(first + 1, last)
|
||||
const actions = part
|
||||
.slice(last + 1)
|
||||
.split(',')
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
scopes.push({ type, name, actions })
|
||||
}
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
export function allowedActionsForRepo(user: UserWithAcls, repository: string): Action[] {
|
||||
if (user.role === 'ADMIN') {
|
||||
return ['pull', 'push', 'delete', '*']
|
||||
}
|
||||
|
||||
const allowed = new Set<Action>()
|
||||
for (const acl of user.acls) {
|
||||
if (!matchRepository(acl.repository, repository)) continue
|
||||
for (const action of actionsFromAcl(acl)) {
|
||||
allowed.add(action)
|
||||
}
|
||||
}
|
||||
return [...allowed]
|
||||
}
|
||||
|
||||
export function authorizeScopes(user: UserWithAcls, scopes: ParsedScope[]): RegistryAccess[] {
|
||||
const access: RegistryAccess[] = []
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (user.role === 'ADMIN') {
|
||||
access.push({
|
||||
type: scope.type,
|
||||
name: scope.name,
|
||||
actions: scope.actions,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (scope.type !== 'repository') {
|
||||
continue
|
||||
}
|
||||
|
||||
const allowed = allowedActionsForRepo(user, scope.name)
|
||||
const granted = intersectActions(scope.actions, allowed)
|
||||
if (granted.length > 0) {
|
||||
access.push({
|
||||
type: scope.type,
|
||||
name: scope.name,
|
||||
actions: granted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return access
|
||||
}
|
||||
|
||||
export function parseBasicAuth(header: string | undefined): { username: string; password: string } | null {
|
||||
if (!header?.startsWith('Basic ')) return null
|
||||
try {
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
|
||||
const idx = decoded.indexOf(':')
|
||||
if (idx < 0) return null
|
||||
return {
|
||||
username: decoded.slice(0, idx),
|
||||
password: decoded.slice(idx + 1),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRole(role: string): Role {
|
||||
if (role === 'ADMIN' || role === 'USER') return role
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ensureTokenCerts } from './certs'
|
||||
import { prisma } from './prisma'
|
||||
import { seedAdmin } from '../../prisma/seed'
|
||||
|
||||
let ready: Promise<void> | null = null
|
||||
|
||||
async function bootstrap() {
|
||||
ensureTokenCerts()
|
||||
await prisma.$connect()
|
||||
await seedAdmin(prisma)
|
||||
}
|
||||
|
||||
export async function ensureBootstrap() {
|
||||
if (!ready) {
|
||||
ready = bootstrap().catch((err) => {
|
||||
console.error('[bootstrap] failed', err)
|
||||
ready = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
await ready
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
function resolvePath(envName: string, fallback: string): string {
|
||||
return resolve(process.env[envName] || fallback)
|
||||
}
|
||||
|
||||
/** Token Auth용 RSA 키/인증서가 없으면 openssl로 생성합니다. */
|
||||
export function ensureTokenCerts(): { keyPath: string; certPath: string } {
|
||||
const keyPath = resolvePath('TOKEN_KEY_PATH', '../certs/token.key')
|
||||
const certPath = resolvePath('TOKEN_CERT_PATH', '../certs/token.crt')
|
||||
|
||||
if (existsSync(keyPath) && existsSync(certPath)) {
|
||||
return { keyPath, certPath }
|
||||
}
|
||||
|
||||
mkdirSync(dirname(keyPath), { recursive: true })
|
||||
mkdirSync(dirname(certPath), { recursive: true })
|
||||
|
||||
const result = spawnSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-new',
|
||||
'-newkey',
|
||||
'rsa:4096',
|
||||
'-days',
|
||||
'3650',
|
||||
'-nodes',
|
||||
'-x509',
|
||||
'-subj',
|
||||
'/CN=registry-aio-token',
|
||||
'-keyout',
|
||||
keyPath,
|
||||
'-out',
|
||||
certPath,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
const detail = result.error?.message || result.stderr || result.stdout || 'unknown error'
|
||||
throw new Error(
|
||||
`Failed to generate token certs at ${keyPath} / ${certPath}. ` +
|
||||
`Install openssl (Compose certs 서비스가 자동 생성합니다). (${detail})`,
|
||||
)
|
||||
}
|
||||
|
||||
console.info(`[certs] generated ${keyPath} and ${certPath}`)
|
||||
return { keyPath, certPath }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const ROUNDS = 10
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, ROUNDS)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||
import { PrismaClient } from '../../generated/prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
||||
|
||||
function createPrismaClient() {
|
||||
const url = process.env.DATABASE_URL || 'file:./dev.db'
|
||||
const adapter = new PrismaBetterSqlite3({ url })
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
})
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
function refreshSecret(): Uint8Array {
|
||||
const secret = process.env.SESSION_SECRET || 'dev-session-secret-change-me'
|
||||
return new TextEncoder().encode(`refresh:${secret}`)
|
||||
}
|
||||
|
||||
export async function createRefreshToken(username: string): Promise<string> {
|
||||
return new SignJWT({ typ: 'refresh', username })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setJti(randomUUID())
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('30d')
|
||||
.sign(refreshSecret())
|
||||
}
|
||||
|
||||
export async function verifyRefreshToken(token: string): Promise<string | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, refreshSecret())
|
||||
if (payload.typ !== 'refresh' || typeof payload.username !== 'string') return null
|
||||
return payload.username
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
const DEFAULT_REGISTRY_URL = 'http://registry:5000'
|
||||
|
||||
export function registryBaseUrl(): string {
|
||||
return (process.env.REGISTRY_INTERNAL_URL || DEFAULT_REGISTRY_URL).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
async function serviceToken(access: RegistryAccess[]): Promise<string> {
|
||||
const { token } = await signRegistryToken({
|
||||
subject: 'registry-aio-service',
|
||||
access,
|
||||
expiresInSeconds: 120,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
export async function registryFetch(path: string, init: RequestInit = {}, access?: RegistryAccess[]) {
|
||||
const url = `${registryBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`
|
||||
const headers = new Headers(init.headers)
|
||||
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set(
|
||||
'Accept',
|
||||
'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/json',
|
||||
)
|
||||
}
|
||||
|
||||
const granted =
|
||||
access ??
|
||||
([
|
||||
{ type: 'registry', name: 'catalog', actions: ['*'] },
|
||||
{ type: 'repository', name: '**', actions: ['*'] },
|
||||
] satisfies RegistryAccess[])
|
||||
|
||||
// Distribution matches exact repository names in token access — for service calls
|
||||
// we mint tokens with requested repo scopes per operation instead of '**'.
|
||||
const token = await serviceToken(granted)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
|
||||
export async function listRepositories(): Promise<string[]> {
|
||||
const res = await registryFetch(
|
||||
'/v2/_catalog?n=1000',
|
||||
{},
|
||||
[{ type: 'registry', name: 'catalog', actions: ['*'] }],
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: `Registry catalog failed: ${res.status}`,
|
||||
})
|
||||
}
|
||||
const data = (await res.json()) as { repositories?: string[] }
|
||||
return data.repositories ?? []
|
||||
}
|
||||
|
||||
export async function listTags(name: string): Promise<string[]> {
|
||||
const res = await registryFetch(
|
||||
`/v2/${encodeURI(name)}/tags/list`,
|
||||
{},
|
||||
[{ type: 'repository', name, actions: ['pull'] }],
|
||||
)
|
||||
if (res.status === 404) return []
|
||||
if (!res.ok) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: `Registry tags failed: ${res.status}`,
|
||||
})
|
||||
}
|
||||
const data = (await res.json()) as { tags?: string[] | null }
|
||||
return data.tags ?? []
|
||||
}
|
||||
|
||||
export async function getManifestDigest(name: string, reference: string): Promise<string | null> {
|
||||
const res = await registryFetch(
|
||||
`/v2/${encodeURI(name)}/manifests/${encodeURIComponent(reference)}`,
|
||||
{ method: 'HEAD' },
|
||||
[{ type: 'repository', name, actions: ['pull'] }],
|
||||
)
|
||||
if (!res.ok) return null
|
||||
return res.headers.get('docker-content-digest')
|
||||
}
|
||||
|
||||
export async function deleteManifest(name: string, digest: string): Promise<void> {
|
||||
const res = await registryFetch(
|
||||
`/v2/${encodeURI(name)}/manifests/${encodeURIComponent(digest)}`,
|
||||
{ method: 'DELETE' },
|
||||
[{ type: 'repository', name, actions: ['delete', 'pull'] }],
|
||||
)
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: `Registry delete failed: ${res.status}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import type { Role } from '../../generated/prisma/client'
|
||||
|
||||
export type SessionUser = {
|
||||
id: string
|
||||
username: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
const COOKIE_NAME = 'registry_aio_session'
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.SESSION_SECRET || 'dev-session-secret-change-me'
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
export async function createSessionToken(user: SessionUser): Promise<string> {
|
||||
return new SignJWT({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(getSecret())
|
||||
}
|
||||
|
||||
export async function readSessionUser(event: H3Event): Promise<SessionUser | null> {
|
||||
const token = getCookie(event, COOKIE_NAME)
|
||||
if (!token) return null
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecret())
|
||||
if (
|
||||
typeof payload.id !== 'string' ||
|
||||
typeof payload.username !== 'string' ||
|
||||
(payload.role !== 'ADMIN' && payload.role !== 'USER')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: payload.id,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionCookie(event: H3Event, token: string) {
|
||||
setCookie(event, COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
secure: process.env.AUTH_PUBLIC_URL?.startsWith('https://') ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearSessionCookie(event: H3Event) {
|
||||
deleteCookie(event, COOKIE_NAME, { path: '/' })
|
||||
}
|
||||
|
||||
export async function requireSession(event: H3Event): Promise<SessionUser> {
|
||||
const user = await readSessionUser(event)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function requireAdmin(event: H3Event): Promise<SessionUser> {
|
||||
const user = await requireSession(event)
|
||||
if (user.role !== 'ADMIN') {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
||||
}
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
createPrivateKey,
|
||||
randomUUID,
|
||||
X509Certificate,
|
||||
} from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SignJWT, importPKCS8 } from 'jose'
|
||||
import { ensureTokenCerts } from './certs'
|
||||
|
||||
export type RegistryAccess = {
|
||||
type: string
|
||||
name: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
type TokenMaterial = {
|
||||
privateKeyPem: string
|
||||
x5c: string
|
||||
}
|
||||
|
||||
let cached: TokenMaterial | null = null
|
||||
|
||||
function toPkcs8Pem(privateKeyPem: string): string {
|
||||
const key = createPrivateKey(privateKeyPem)
|
||||
return key.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
}
|
||||
|
||||
function pemToX5C(certPem: string): string {
|
||||
return certPem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
||||
.replace(/-----END CERTIFICATE-----/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function loadTokenMaterial(): TokenMaterial {
|
||||
if (cached) return cached
|
||||
|
||||
const { keyPath, certPath } = ensureTokenCerts()
|
||||
|
||||
const rawPrivateKeyPem = readFileSync(keyPath, 'utf8')
|
||||
const privateKeyPem = toPkcs8Pem(rawPrivateKeyPem)
|
||||
const certPem = readFileSync(certPath, 'utf8')
|
||||
|
||||
// Validate certificate parses
|
||||
void new X509Certificate(certPem)
|
||||
|
||||
cached = {
|
||||
privateKeyPem,
|
||||
x5c: pemToX5C(certPem),
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
export async function signRegistryToken(options: {
|
||||
subject: string
|
||||
access: RegistryAccess[]
|
||||
expiresInSeconds?: number
|
||||
}): Promise<{ token: string; expiresIn: number; issuedAt: string }> {
|
||||
const { privateKeyPem, x5c } = loadTokenMaterial()
|
||||
const privateKey = await importPKCS8(privateKeyPem, 'RS256')
|
||||
|
||||
const issuer = process.env.REGISTRY_AUTH_ISSUER || 'registry-aio'
|
||||
const audience = process.env.REGISTRY_AUTH_SERVICE || 'registry'
|
||||
const expiresIn = options.expiresInSeconds ?? 600
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const issuedAt = new Date(now * 1000).toISOString()
|
||||
|
||||
const token = await new SignJWT({
|
||||
access: options.access,
|
||||
})
|
||||
.setProtectedHeader({
|
||||
alg: 'RS256',
|
||||
typ: 'JWT',
|
||||
// Registry v3 trusts tokens via x5c against rootcertbundle
|
||||
x5c: [x5c],
|
||||
})
|
||||
.setIssuer(issuer)
|
||||
.setSubject(options.subject)
|
||||
.setAudience(audience)
|
||||
.setIssuedAt(now)
|
||||
.setNotBefore(now - 10)
|
||||
.setExpirationTime(now + expiresIn)
|
||||
.setJti(randomUUID())
|
||||
.sign(privateKey)
|
||||
|
||||
return { token, expiresIn, issuedAt }
|
||||
}
|
||||
Reference in new issue
Block a user