216 lines
6.0 KiB
TypeScript
216 lines
6.0 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'
|
|
|
|
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 = 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
|