import { readFile, writeFile } from 'node:fs/promises' import vpsConfig from '#config/vps' import dockerService from '#services/docker_service' import CaddyfileVersion from '#models/caddyfile_version' export interface CaddySite { /** * Addresses as written in the Caddyfile ("a.fr, www.a.fr"). */ addresses: string[] /** * Domains that can be checked over HTTPS (no wildcard, no port-only address). */ domains: string[] kind: 'proxy' | 'static' | 'other' /** * "container:port" for a reverse proxy, the root folder for a static site. */ upstream: string | null line: number } export interface ReloadResult { ok: boolean output: string } const LINE_BREAK = /\r?\n/ /** * Extracts the site blocks of a Caddyfile. This is not a full parser: * it only needs the addresses and what each site serves. */ export function parseCaddyfile(content: string): CaddySite[] { const sites: CaddySite[] = [] let depth = 0 let current: CaddySite | null = null let currentDepth = 0 content.split(LINE_BREAK).forEach((rawLine, index) => { const line = rawLine.replace(/(^|\s)#.*$/, '').trim() if (!line) return const opens = (line.match(/{/g) ?? []).length const closes = (line.match(/}/g) ?? []).length /** * A site block starts at the top level, with the addresses before "{". * "{" alone is the global options block and "(name) {" a snippet. */ if (depth === 0 && line.endsWith('{') && line !== '{' && !line.startsWith('(')) { const addresses = line .slice(0, -1) .split(/[,\s]+/) .map((address) => address.trim()) .filter(Boolean) current = { addresses, domains: addresses.map(toDomain).filter((domain): domain is string => domain !== null), kind: 'other', upstream: null, line: index + 1, } currentDepth = depth + opens - closes sites.push(current) depth = currentDepth /** * Block opened and closed on the same line. */ if (currentDepth === 0) current = null return } if (current) { const [directive, ...args] = line.split(/\s+/) if (directive === 'reverse_proxy' && current.kind !== 'proxy') { const upstream = args.find( (arg) => !arg.startsWith('/') && !arg.startsWith('@') && arg !== '{' && arg !== '*' ) current.kind = 'proxy' current.upstream = upstream?.replace(/^https?:\/\//, '') ?? null } else if (directive === 'root' && current.kind === 'other') { current.kind = 'static' current.upstream = args.at(-1) ?? null } else if (directive === 'file_server' && current.kind === 'other') { current.kind = 'static' } } depth += opens - closes if (current && depth < currentDepth) current = null }) return sites } /** * "https://a.fr:443" -> "a.fr". Null when the address cannot be checked. */ function toDomain(address: string): string | null { const host = address .replace(/^https?:\/\//, '') .replace(/\/.*$/, '') .replace(/:\d+$/, '') if (!host || host.includes('*') || host.includes('{') || host === 'localhost') return null if (/^[\d.]+$/.test(host) || host.includes(':')) return null return host.toLowerCase() } /** * Caddy logs JSON lines: keep the level, the message and the error. */ function readableCaddyLog(output: string) { return output .split(LINE_BREAK) .filter((line) => line.trim()) .map((line) => { try { const entry = JSON.parse(line) as { level?: string; msg?: string; error?: string } return [entry.level?.toUpperCase(), entry.msg, entry.error].filter(Boolean).join(' ยท ') } catch { return line } }) .join('\n') } class CaddyService { read() { return readFile(vpsConfig.caddy.caddyfilePath, 'utf8') } async sites() { return parseCaddyfile(await this.read()) } /** * Writes in place: the Caddyfile is often mounted as a single file in * the Caddy container, and replacing the file (write + rename) would leave * the container on the old version. */ #write(content: string) { return writeFile(vpsConfig.caddy.caddyfilePath, content, 'utf8') } async reload(): Promise { const result = await dockerService.exec(vpsConfig.caddy.container, [ 'caddy', 'reload', '--config', vpsConfig.caddy.configPath, '--adapter', 'caddyfile', ]) return { ok: result.exitCode === 0, output: readableCaddyLog(`${result.stdout}${result.stderr}`), } } /** * Saves the new Caddyfile and reloads Caddy. When Caddy refuses the new * configuration, the previous file is put back (Caddy keeps running with * the previous configuration anyway). */ async saveAndReload( content: string, meta: { username: string | null; reason: string } ): Promise { const previous = await this.read() const normalized = content.replace(/\r\n/g, '\n') if (normalized === previous) return { ok: true, output: 'Aucun changement.' } await this.#write(normalized) let result: ReloadResult try { result = await this.reload() } catch (error) { result = { ok: false, output: error instanceof Error ? error.message : String(error) } } if (!result.ok) { await this.#write(previous) return result } await CaddyfileVersion.create({ content: previous, username: meta.username, reason: meta.reason, }) await this.#pruneVersions() return result } async appendBlock(block: string, meta: { username: string | null; reason: string }) { const current = await this.read() return this.saveAndReload(`${current.trimEnd()}\n\n${block.trim()}\n`, meta) } /** * Keeps the last 50 versions. */ async #pruneVersions() { const kept = await CaddyfileVersion.query().select('id').orderBy('id', 'desc').limit(50) if (kept.length < 50) return await CaddyfileVersion.query().where('id', '<', kept.at(-1)!.id).delete() } } const caddyService = new CaddyService() export default caddyService