Files
big-brother/api/app/services/caddy_service.ts
T
RutraandClaude Opus 5.5 c106fcfc62 Édition du docker-compose.yml depuis la page d'un conteneur
Vérification par docker compose config avant écriture, application avec
docker compose up -d et retour à la version précédente en cas d'échec.
Historique des versions par fichier. Le Caddyfile et le compose gardent
un retour à la ligne final.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:31:33 +02:00

217 lines
6.1 KiB
TypeScript

import { readFile, writeFile } from 'node:fs/promises'
import vpsConfig from '#config/vps'
import dockerService from '#services/docker_service'
import CaddyfileVersion from '#models/caddyfile_version'
import { toFileContent } from '#services/file_content'
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<ReloadResult> {
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<ReloadResult> {
const previous = await this.read()
const normalized = toFileContent(content)
if (normalized === toFileContent(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