first commit
This commit is contained in:
commit
3c0479faf0
169 files changed
+27617
No files matched your search
@@ -0,0 +1,215 @@
|
||||
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
|
||||
Reference in new issue
Block a user