Construire un dashboard Nuxt 4 pour piloter Caddy via son Admin API — authentification JWT, reverse proxy CRUD, import/export, et déploiement Docker Compose.
Le cœur d'OpenCaddyHost : le client qui parle à l'Admin API Caddy, et les server routes qui exposent les opérations CRUD au dashboard.
server/utils/caddy.ts encapsule tous les appels à l'Admin API. Les server routes l'importent sans se soucier de la mécanique HTTP.
interface CaddyProxy {
id: string
name: string
domain: string
target: string
ssl: boolean
headers?: Record<string, string>
createdAt?: string
}
export async function getCaddyConfig(): Promise<any> {
const config = useRuntimeConfig()
const response = await fetch(`${config.caddyAdminUrl}/config/`)
if (!response.ok) throw new Error(`Caddy API error: ${response.status}`)
return response.json()
}
export async function getCaddyProxies(): Promise<CaddyProxy[]> {
const caddyConfig = await getCaddyConfig()
if (!caddyConfig) return []
const routes = caddyConfig?.apps?.http?.servers?.srv0?.routes ?? []
return routes.map((route: any) => {
const id = route['@id'] ?? ''
const domain = route.match?.[0]?.host?.[0] ?? ''
const handler = route.handle?.find((h: any) => h.handler === 'reverse_proxy')
const target = handler?.upstreams?.[0]?.dial ?? ''
return {
id,
name: id.replace('proxy-', ''),
domain,
target,
ssl: route.match?.[0]?.host !== undefined,
createdAt: route.createdAt,
}
})
}
export async function createCaddyProxy(proxy: Omit<CaddyProxy, 'id'>): Promise<void> {
const config = useRuntimeConfig()
const id = `proxy-${proxy.name.toLowerCase().replace(/\s+/g, '-')}`
const route = buildRoute(id, proxy)
// Lire la config actuelle
const current = await getCaddyConfig() ?? {
apps: { http: { servers: { srv0: { listen: [':80'], routes: [] } } } }
}
// Ajouter la route
current.apps.http.servers.srv0.routes.push(route)
// Envoyer la config mise à jour
const response = await fetch(`${config.caddyAdminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current),
})
if (!response.ok) throw new Error(`Failed to create proxy: ${response.status}`)
}
export async function deleteCaddyProxy(id: string): Promise<void> {
const config = useRuntimeConfig()
const response = await fetch(`${config.caddyAdminUrl}/id/${id}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error(`Failed to delete proxy: ${response.status}`)
}function buildRoute(id: string, proxy: Omit<CaddyProxy, 'id'>): object {
const handler: any = {
handler: 'reverse_proxy',
upstreams: [{ dial: proxy.target }],
}
// Headers personnalisés
if (proxy.headers && Object.keys(proxy.headers).length > 0) {
handler.headers = {
request: {
set: Object.entries(proxy.headers).reduce((acc, [key, value]) => {
acc[key] = [value]
return acc
}, {} as Record<string, string[]>),
},
}
}
return {
'@id': id,
match: [{ host: [proxy.domain] }],
handle: [handler],
terminal: true,
createdAt: new Date().toISOString(),
}
}terminal: true dit à Caddy d'arrêter d'évaluer les routes suivantes si celle-ci correspond — comportement attendu pour un reverse proxy.
export default defineEventHandler(async (event) => {
authenticateToken(event)
const { search, status } = getQuery(event)
let proxies = await getCaddyProxies()
if (search) {
const q = (search as string).toLowerCase()
proxies = proxies.filter(p =>
p.name.toLowerCase().includes(q) ||
p.domain.toLowerCase().includes(q) ||
p.target.toLowerCase().includes(q)
)
}
return {
success: true,
data: proxies,
message: `${proxies.length} proxy${proxies.length > 1 ? 's' : ''} trouvé${proxies.length > 1 ? 's' : ''}`,
}
})export default defineEventHandler(async (event) => {
authenticateToken(event)
const body = await readBody(event)
const { name, domain, target, ssl, headers } = body
if (!name || !domain || !target) {
throw createError({ statusCode: 400, statusMessage: 'name, domain et target sont requis' })
}
await createCaddyProxy({ name, domain, target, ssl: ssl ?? false, headers })
return { success: true, message: `Proxy "${name}" créé` }
})export default defineEventHandler(async (event) => {
authenticateToken(event)
const config = useRuntimeConfig()
try {
const response = await fetch(`${config.caddyAdminUrl}/config/`, {
signal: AbortSignal.timeout(3000), // timeout 3s
})
return {
healthy: response.ok,
statusCode: response.status,
url: config.caddyAdminUrl,
}
} catch (error: any) {
return {
healthy: false,
error: error.message,
url: config.caddyAdminUrl,
}
}
})AbortSignal.timeout(3000) annule la requête si Caddy ne répond pas en 3 secondes — évite de bloquer le dashboard indéfiniment.
export function useProxies() {
const { getAuthHeaders } = useAuth()
const proxies = ref<Proxy[]>([])
const loading = ref(false)
async function fetchProxies(search?: string) {
loading.value = true
try {
const data = await $fetch('/api/proxies', {
headers: getAuthHeaders(),
query: search ? { search } : {},
})
proxies.value = data.data
} finally {
loading.value = false
}
}
async function createProxy(payload: CreateProxyPayload) {
await $fetch('/api/proxies', {
method: 'POST',
headers: getAuthHeaders(),
body: payload,
})
await fetchProxies() // rafraîchir la liste
}
async function deleteProxy(id: string) {
await $fetch(`/api/proxies/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
})
await fetchProxies()
}
return { proxies, loading, fetchProxies, createProxy, deleteProxy }
}$fetch est le client HTTP natif de Nuxt — il gère la sérialisation JSON et lance des erreurs typées sur les status 4xx/5xx.