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,34 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const bodySchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await ensureBootstrap()
|
||||
const body = bodySchema.parse(await readBody(event))
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username: body.username } })
|
||||
if (!user || !user.active) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(body.password, user.passwordHash)
|
||||
if (!ok) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const token = await createSessionToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
})
|
||||
setSessionCookie(event, token)
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
clearSessionCookie(event)
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await readSessionUser(event)
|
||||
if (!user) {
|
||||
return { user: null }
|
||||
}
|
||||
return { user }
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export default defineEventHandler(() => {
|
||||
return { ok: true, service: 'registry-aio' }
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const bodySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
tag: z.string().min(1),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireSession(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const body = bodySchema.parse(await readBody(event))
|
||||
|
||||
if (session.role !== 'ADMIN') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.id },
|
||||
include: { acls: true },
|
||||
})
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
const allowed = allowedActionsForRepo(user, body.name)
|
||||
if (!allowed.includes('delete') && !allowed.includes('*')) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Delete not allowed' })
|
||||
}
|
||||
}
|
||||
|
||||
const digest = await getManifestDigest(body.name, body.tag)
|
||||
if (!digest) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Tag not found' })
|
||||
}
|
||||
|
||||
await deleteManifest(body.name, digest)
|
||||
return { ok: true, name: body.name, tag: body.tag, digest }
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireSession(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const query = getQuery(event)
|
||||
const name = typeof query.name === 'string' ? query.name : ''
|
||||
if (!name) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing name query parameter' })
|
||||
}
|
||||
|
||||
const tags = await listTags(name)
|
||||
return { name, tags }
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireSession(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
try {
|
||||
const repositories = await listRepositories()
|
||||
return { repositories }
|
||||
} catch (err: any) {
|
||||
// Registry may be unreachable during local UI-only boot
|
||||
if (err?.statusCode) throw err
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: err?.message || 'Failed to reach registry',
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireSession(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const [userCount, repositories] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
listRepositories().catch(() => [] as string[]),
|
||||
])
|
||||
|
||||
return {
|
||||
users: userCount,
|
||||
repositories: repositories.length,
|
||||
authPublicUrl: process.env.AUTH_PUBLIC_URL || '',
|
||||
registryInternalUrl: process.env.REGISTRY_INTERNAL_URL || '',
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireAdmin(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
|
||||
}
|
||||
|
||||
if (session.id === id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot delete yourself' })
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||
}
|
||||
|
||||
if (existing.username === (process.env.ADMIN_USERNAME || 'admin')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot delete bootstrap admin' })
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id } })
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { acls: true },
|
||||
})
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
acls: user.acls,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const aclSchema = z.object({
|
||||
repository: z.string().min(1),
|
||||
canPull: z.boolean().default(true),
|
||||
canPush: z.boolean().default(false),
|
||||
canDelete: z.boolean().default(false),
|
||||
})
|
||||
|
||||
const bodySchema = z.object({
|
||||
password: z.string().min(6).optional(),
|
||||
role: z.enum(['ADMIN', 'USER']).optional(),
|
||||
active: z.boolean().optional(),
|
||||
acls: z.array(aclSchema).optional(),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireAdmin(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing id' })
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||
}
|
||||
|
||||
const body = bodySchema.parse(await readBody(event))
|
||||
|
||||
if (existing.username === (process.env.ADMIN_USERNAME || 'admin') && body.active === false) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot deactivate bootstrap admin' })
|
||||
}
|
||||
|
||||
if (session.id === id && body.role && body.role !== 'ADMIN') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot demote yourself' })
|
||||
}
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
if (body.acls) {
|
||||
await tx.acl.deleteMany({ where: { userId: id } })
|
||||
await tx.acl.createMany({
|
||||
data: body.acls.map((acl) => ({
|
||||
userId: id,
|
||||
repository: acl.repository,
|
||||
canPull: acl.canPull,
|
||||
canPush: acl.canPush,
|
||||
canDelete: acl.canDelete,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return tx.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.role ? { role: body.role } : {}),
|
||||
...(typeof body.active === 'boolean' ? { active: body.active } : {}),
|
||||
...(body.password ? { passwordHash: await hashPassword(body.password) } : {}),
|
||||
},
|
||||
include: { acls: true },
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
acls: user.acls,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { username: 'asc' },
|
||||
include: { acls: true },
|
||||
})
|
||||
|
||||
return {
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
active: u.active,
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
acls: u.acls,
|
||||
})),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const aclSchema = z.object({
|
||||
repository: z.string().min(1),
|
||||
canPull: z.boolean().default(true),
|
||||
canPush: z.boolean().default(false),
|
||||
canDelete: z.boolean().default(false),
|
||||
})
|
||||
|
||||
const bodySchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, numbers, . _ -'),
|
||||
password: z.string().min(6),
|
||||
role: z.enum(['ADMIN', 'USER']).default('USER'),
|
||||
active: z.boolean().default(true),
|
||||
acls: z.array(aclSchema).default([]),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
await ensureBootstrap()
|
||||
|
||||
const body = bodySchema.parse(await readBody(event))
|
||||
const existing = await prisma.user.findUnique({ where: { username: body.username } })
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Username already exists' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: body.username,
|
||||
passwordHash: await hashPassword(body.password),
|
||||
role: body.role,
|
||||
active: body.active,
|
||||
acls: {
|
||||
create: body.acls.map((acl) => ({
|
||||
repository: acl.repository,
|
||||
canPull: acl.canPull,
|
||||
canPush: acl.canPush,
|
||||
canDelete: acl.canDelete,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { acls: true },
|
||||
})
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
acls: user.acls,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineNitroPlugin(async () => {
|
||||
try {
|
||||
await ensureBootstrap()
|
||||
console.log('[bootstrap] admin ready')
|
||||
} catch (err) {
|
||||
console.error('[bootstrap] failed on startup', err)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await ensureBootstrap()
|
||||
|
||||
const query = getQuery(event)
|
||||
let scopes = parseScopes(query.scope as string | string[] | undefined)
|
||||
let account = typeof query.account === 'string' ? query.account : undefined
|
||||
const offline =
|
||||
query.offline_token === 'true' ||
|
||||
query.offline_token === true ||
|
||||
query.access_type === 'offline'
|
||||
|
||||
let username: string | undefined
|
||||
let password: string | undefined
|
||||
let refreshTokenIn: string | undefined
|
||||
|
||||
const basic = parseBasicAuth(getHeader(event, 'authorization'))
|
||||
if (basic) {
|
||||
username = basic.username
|
||||
password = basic.password
|
||||
}
|
||||
|
||||
if (event.method === 'POST') {
|
||||
const body = ((await readBody(event).catch(() => null)) || {}) as Record<string, any>
|
||||
username = username || body.username || body.account
|
||||
password = password || body.password
|
||||
refreshTokenIn = typeof body.refresh_token === 'string' ? body.refresh_token : undefined
|
||||
account = account || body.account || username
|
||||
if (body.scope) {
|
||||
scopes = parseScopes(body.scope)
|
||||
}
|
||||
}
|
||||
|
||||
let user =
|
||||
null as null | Awaited<ReturnType<typeof prisma.user.findUnique<{ include: { acls: true } }>>>
|
||||
|
||||
if (refreshTokenIn) {
|
||||
const refreshUser = await verifyRefreshToken(refreshTokenIn)
|
||||
if (!refreshUser) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid refresh token' })
|
||||
}
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: refreshUser },
|
||||
include: { acls: true },
|
||||
})
|
||||
} else {
|
||||
if (!username || !password) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||
}
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
include: { acls: true },
|
||||
})
|
||||
if (!user || !user.active) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
const ok = await verifyPassword(password, user.passwordHash)
|
||||
if (!ok) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
}
|
||||
|
||||
if (!user || !user.active) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
if (account && account !== user.username) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Account mismatch' })
|
||||
}
|
||||
|
||||
const access = authorizeScopes(user, scopes)
|
||||
const { token, expiresIn, issuedAt } = await signRegistryToken({
|
||||
subject: user.username,
|
||||
access,
|
||||
})
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
token,
|
||||
access_token: token,
|
||||
expires_in: expiresIn,
|
||||
issued_at: issuedAt,
|
||||
}
|
||||
|
||||
if (offline || refreshTokenIn) {
|
||||
response.refresh_token = await createRefreshToken(user.username)
|
||||
}
|
||||
|
||||
return response
|
||||
})
|
||||
@@ -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