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:
ysj7705andCursor committed 2026-07-30 16:12:12 +09:00
commit 0fd00bfa37
120 files changed
+17094

No files matched your search

+41
View File
@@ -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))
}
+107
View File
@@ -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' })
}
+22
View File
@@ -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
}
+52
View File
@@ -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 }
}
+11
View File
@@ -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)
}
+19
View File
@@ -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
}
+26
View File
@@ -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
}
}
+97
View File
@@ -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}`,
})
}
}
+80
View File
@@ -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
}
+87
View File
@@ -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 }
}