first commit

This commit is contained in:
Rutra committed 2026-10-12 14:25:22 +02:00
commit 3c0479faf0
169 files changed
+27617

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
import vine from '@vinejs/vine'
import db from '@adonisjs/lucid/services/db'
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog from '#models/audit_log'
import UserError from '#exceptions/user_error'
const indexValidator = vine.create({
page: vine.number().withoutDecimals().min(1).optional(),
status: vine.enum(['running', 'success', 'error'] as const).optional(),
})
export default class AuditController {
async index({ request }: HttpContext) {
const { page = 1, status } = await indexValidator.validate(request.qs())
const query = AuditLog.query()
.select('id', 'username', 'ip', 'action', 'target', 'status', 'created_at', 'finished_at')
.select(db.raw('length(output) > 0 as hasOutput'))
.orderBy('id', 'desc')
if (status) query.where('status', status)
const result = await query.paginate(page, 50)
return {
meta: { total: result.total, page: result.currentPage, lastPage: result.lastPage },
data: result.all().map((entry) => ({
id: entry.id,
username: entry.username,
ip: entry.ip,
action: entry.action,
target: entry.target,
status: entry.status,
createdAt: entry.createdAt.toISO(),
finishedAt: entry.finishedAt?.toISO() ?? null,
hasOutput: Boolean(entry.$extras.hasOutput),
})),
}
}
async show({ params }: HttpContext) {
const entry = await AuditLog.find(params.id)
if (!entry) throw new UserError('Entrée introuvable.', 404)
return {
id: entry.id,
action: entry.action,
target: entry.target,
status: entry.status,
output: entry.output,
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import User from '#models/user'
import limiter from '@adonisjs/limiter/services/main'
import type { HttpContext } from '@adonisjs/core/http'
import totpService from '#services/totp_service'
import { audit } from '#services/audit'
import { loginValidator } from '#validators/auth'
class InvalidLogin extends Error {}
/**
* 5 failed attempts per IP, then blocked for 15 minutes.
*/
const loginLimiter = () =>
limiter.use({ requests: 5, duration: '15 mins', blockDuration: '15 mins' })
export default class AuthController {
async login(ctx: HttpContext) {
const { request, response, auth } = ctx
const { username, password, code } = await request.validateUsing(loginValidator)
const penalize = () =>
loginLimiter().penalize(`login_${request.ip()}`, async (): Promise<User> => {
const user = await User.verifyCredentials(username, password).catch(() => null)
if (!user || !(await totpService.verify(user, code))) throw new InvalidLogin()
return user
})
let result: Awaited<ReturnType<typeof penalize>>
try {
result = await penalize()
} catch (error) {
if (!(error instanceof InvalidLogin)) throw error
await audit(ctx, { action: 'Connexion refusée', target: username, status: 'error' })
return response.badRequest({ message: 'Identifiant, mot de passe ou code incorrect.' })
}
const [throttled, user] = result
if (throttled) {
const minutes = Math.max(1, Math.ceil(throttled.response.availableIn / 60))
return response.tooManyRequests({
message: `Trop de tentatives. Réessaie dans ${minutes} min.`,
})
}
await auth.use('web').login(user!)
await audit(ctx, { action: 'Connexion', username: user!.username, status: 'success' })
return { username: user!.username }
}
async logout(ctx: HttpContext) {
await audit(ctx, { action: 'Déconnexion', status: 'success' })
await ctx.auth.use('web').logout()
return { ok: true }
}
async me({ auth }: HttpContext) {
return { username: auth.getUserOrFail().username }
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import backupService from '#services/backup_service'
import { actor, audit } from '#services/audit'
export default class BackupsController {
async index() {
return {
dir: vpsConfig.backups.dir,
schedule: backupService.schedule,
files: await backupService.list(),
}
}
async store(ctx: HttpContext) {
const job = await backupService.run(actor(ctx))
return { jobId: job.id }
}
async download(ctx: HttpContext) {
const path = backupService.path(ctx.params.name)
await audit(ctx, {
action: 'Téléchargement de sauvegarde',
target: ctx.params.name,
status: 'success',
})
return ctx.response.attachment(path, ctx.params.name)
}
async destroy(ctx: HttpContext) {
await backupService.delete(ctx.params.name)
await audit(ctx, {
action: 'Suppression de sauvegarde',
target: ctx.params.name,
status: 'success',
})
return { ok: true }
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import CaddyfileVersion from '#models/caddyfile_version'
import UserError from '#exceptions/user_error'
import caddyService, { parseCaddyfile, type ReloadResult } from '#services/caddy_service'
import siteMonitor from '#services/site_monitor'
import { actor, audit } from '#services/audit'
import { updateCaddyfileValidator } from '#validators/caddy'
export default class CaddyController {
async show() {
const content = await caddyService.read()
return {
path: vpsConfig.caddy.caddyfilePath,
container: vpsConfig.caddy.container,
content,
sites: parseCaddyfile(content),
}
}
async update(ctx: HttpContext) {
const { content, reason } = await ctx.request.validateUsing(updateCaddyfileValidator)
const result = await caddyService.saveAndReload(content, {
username: actor(ctx).username,
reason: reason || 'Modification manuelle',
})
return this.#respond(ctx, 'Modification du Caddyfile', result)
}
async reload(ctx: HttpContext) {
const result = await caddyService.reload()
return this.#respond(ctx, 'Rechargement de Caddy', result)
}
async versions() {
const versions = await CaddyfileVersion.query()
.select('id', 'username', 'reason', 'created_at')
.orderBy('id', 'desc')
return versions.map((version) => ({
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
}))
}
async version({ params }: HttpContext) {
const version = await CaddyfileVersion.find(params.id)
if (!version) throw new UserError('Version introuvable.', 404)
return {
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
content: version.content,
}
}
async restore(ctx: HttpContext) {
const version = await CaddyfileVersion.find(ctx.params.id)
if (!version) throw new UserError('Version introuvable.', 404)
const result = await caddyService.saveAndReload(version.content, {
username: actor(ctx).username,
reason: `Restauration de la version #${version.id}`,
})
return this.#respond(ctx, `Restauration du Caddyfile (#${version.id})`, result)
}
async #respond(ctx: HttpContext, action: string, result: ReloadResult) {
await audit(ctx, {
action,
target: vpsConfig.caddy.caddyfilePath,
status: result.ok ? 'success' : 'error',
output: result.output,
})
if (!result.ok) {
return ctx.response.unprocessableEntity({
message: 'Caddy a refusé la configuration : le Caddyfile est inchangé.',
output: result.output,
})
}
/**
* New or removed domains show up right away on the sites page.
*/
siteMonitor.sync().catch(() => {})
return { ok: true, output: result.output }
}
}
@@ -0,0 +1,73 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import dockerService from '#services/docker_service'
import { audit } from '#services/audit'
import { openSse } from '#services/sse'
const ACTION_LABELS = {
start: 'Démarrage du conteneur',
stop: 'Arrêt du conteneur',
restart: 'Redémarrage du conteneur',
} as const
const actionValidator = vine.create({
action: vine.enum(['start', 'stop', 'restart'] as const),
})
const logsValidator = vine.create({
tail: vine.number().withoutDecimals().range([0, 5000]).optional(),
})
export default class ContainersController {
async index() {
return dockerService.list()
}
async show({ params }: HttpContext) {
return dockerService.inspect(params.id)
}
async action(ctx: HttpContext) {
const { action } = await actionValidator.validate(ctx.params)
const info = await dockerService.inspect(ctx.params.id)
try {
await dockerService.action(info.id, action)
} catch (error) {
await audit(ctx, {
action: ACTION_LABELS[action],
target: info.name,
status: 'error',
output: error instanceof Error ? error.message : String(error),
})
throw error
}
await audit(ctx, { action: ACTION_LABELS[action], target: info.name, status: 'success' })
return dockerService.inspect(info.id)
}
/**
* Follows the logs of the container (Server-Sent Events).
*/
async logs(ctx: HttpContext) {
const { tail } = await logsValidator.validate(ctx.request.qs())
const channel = openSse(ctx)
try {
const stop = await dockerService.followLogs(
ctx.params.id,
{ tail: tail ?? 300 },
(line) => channel.send('line', line),
() => {
channel.send('end', {})
channel.close()
}
)
channel.onClose(stop)
} catch (error) {
channel.send('failure', { message: error instanceof Error ? error.message : String(error) })
channel.close()
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog from '#models/audit_log'
import UserError from '#exceptions/user_error'
import jobRunner, { type JobLine } from '#services/job_runner'
import { openSse } from '#services/sse'
export default class JobsController {
/**
* Replays the output of a job, then follows it until the end. Jobs
* finished a while ago are read from the action log.
*/
async stream(ctx: HttpContext) {
const id = Number(ctx.params.id)
const job = jobRunner.get(id)
const entry = job ? null : await AuditLog.find(id)
if (!job && !entry) throw new UserError('Action introuvable.', 404)
const channel = openSse(ctx)
if (!job) {
const lines = (entry!.output ?? '')
.split('\n')
.map((text) => ({ ts: 0, stream: 'out', text }))
channel.send('meta', { id, action: entry!.action, target: entry!.target })
channel.send('lines', lines)
channel.send('end', { status: entry!.status })
channel.close()
return
}
channel.send('meta', { id, action: job.meta.action, target: job.meta.target })
channel.send('lines', job.lines)
if (job.status !== 'running') {
channel.send('end', { status: job.status })
channel.close()
return
}
const onLine = (line: JobLine) => channel.send('lines', [line])
const onEnd = (status: string) => {
channel.send('end', { status })
channel.close()
}
job.on('line', onLine)
job.once('end', onEnd)
channel.onClose(() => {
job.off('line', onLine)
job.off('end', onEnd)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import metricsService, { type HostSample } from '#services/metrics_service'
import { openSse } from '#services/sse'
const historyValidator = vine.create({
range: vine.enum(['1h', '24h', '7d', '30d'] as const),
})
export default class MetricsController {
/**
* Live samples (every 5 s) for the charts of the overview.
*/
async live(ctx: HttpContext) {
const channel = openSse(ctx)
channel.send('init', metricsService.live)
const onSample = (sample: HostSample) => channel.send('sample', sample)
metricsService.on('sample', onSample)
channel.onClose(() => metricsService.off('sample', onSample))
}
async history({ request }: HttpContext) {
const { range } = await historyValidator.validate(request.qs())
return metricsService.history(range)
}
}
@@ -0,0 +1,71 @@
import os from 'node:os'
import Site from '#models/site'
import AuditLog from '#models/audit_log'
import backupService from '#services/backup_service'
import dockerService from '#services/docker_service'
import metricsService from '#services/metrics_service'
const CERT_WARNING_DAYS = 14
export default class OverviewController {
async index() {
const now = Math.floor(Date.now() / 1000)
const [containers, sites, backups, recent, uptime] = await Promise.all([
dockerService.list().catch((error: Error) => error),
Site.query().where('active', true).orderBy('domain'),
backupService.list(),
AuditLog.query().orderBy('id', 'desc').limit(8),
metricsService.uptime(),
])
const docker =
containers instanceof Error
? { ok: false as const, error: containers.message }
: {
ok: true as const,
total: containers.length,
running: containers.filter((c) => c.state === 'running').length,
unhealthy: containers.filter((c) => c.health === 'unhealthy').map((c) => c.name),
stopped: containers.filter((c) => c.state !== 'running').map((c) => c.name),
}
return {
host: {
latest: metricsService.latest,
uptime,
cpus: os.cpus().length,
},
docker,
sites: {
total: sites.length,
up: sites.filter((site) => site.lastOk === true).length,
down: sites
.filter((site) => site.lastOk === false)
.map((site) => ({ id: site.id, domain: site.domain, error: site.lastError })),
certSoon: sites
.filter(
(site) => site.certExpiresAt && site.certExpiresAt - now < CERT_WARNING_DAYS * 86_400
)
.map((site) => ({
id: site.id,
domain: site.domain,
days: Math.floor((site.certExpiresAt! - now) / 86_400),
})),
},
backups: {
last: backups[0] ?? null,
count: backups.length,
schedule: backupService.schedule,
},
recent: recent.map((entry) => ({
id: entry.id,
action: entry.action,
target: entry.target,
status: entry.status,
username: entry.username,
createdAt: entry.createdAt.toISO(),
})),
}
}
}
@@ -0,0 +1,51 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import projectService, { PROJECT_ACTIONS, type ProjectAction } from '#services/project_service'
import { DEFAULT_NODE_COMMAND } from '#services/templates'
import { actor } from '#services/audit'
import { createProjectValidator, projectActionValidator } from '#validators/project'
export default class ProjectsController {
async index() {
return projectService.list()
}
/**
* Values used by the "new project" form.
*/
async options() {
return {
baseDomain: vpsConfig.baseDomain,
categories: vpsConfig.projectCategories,
srvRoot: vpsConfig.srvRoot,
defaultNodeCommand: DEFAULT_NODE_COMMAND,
}
}
async show({ params }: HttpContext) {
const project = await projectService.find(params.category, params.name)
return projectService.details(project)
}
async fetch({ params }: HttpContext) {
const project = await projectService.find(params.category, params.name)
return projectService.fetch(project)
}
async action(ctx: HttpContext) {
const action = ctx.params.action as ProjectAction
if (!PROJECT_ACTIONS.includes(action)) throw new UserError('Action inconnue.', 404)
const { commit } = await ctx.request.validateUsing(projectActionValidator)
const project = await projectService.find(ctx.params.category, ctx.params.name)
const job = await projectService.runAction(project, action, { commit }, actor(ctx))
return { jobId: job.id }
}
async store(ctx: HttpContext) {
const input = await ctx.request.validateUsing(createProjectValidator)
const job = await projectService.create(input, actor(ctx))
return { jobId: job.id }
}
}
+77
View File
@@ -0,0 +1,77 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import Site from '#models/site'
import siteMonitor from '#services/site_monitor'
import UserError from '#exceptions/user_error'
const showValidator = vine.create({
range: vine.enum(['24h', '7d', '30d'] as const).optional(),
})
const RANGES = {
'24h': { seconds: 86_400, bucket: 300 },
'7d': { seconds: 7 * 86_400, bucket: 1800 },
'30d': { seconds: 30 * 86_400, bucket: 7200 },
} as const
export default class SitesController {
async index() {
const [sites, day, week] = await Promise.all([
Site.query().orderBy('active', 'desc').orderBy('domain'),
siteMonitor.stats(86_400),
siteMonitor.stats(7 * 86_400),
])
return sites.map((site) => ({
...serializeSite(site),
uptime24h: day.get(site.id)?.uptime ?? null,
uptime7d: week.get(site.id)?.uptime ?? null,
avgResponseMs24h: day.get(site.id)?.avgResponseMs ?? null,
}))
}
async show({ params, request }: HttpContext) {
const { range = '24h' } = await showValidator.validate(request.qs())
const site = await Site.find(params.id)
if (!site) throw new UserError('Site introuvable.', 404)
const { seconds, bucket } = RANGES[range]
const [history, stats] = await Promise.all([
siteMonitor.history(site.id, seconds, bucket),
siteMonitor.stats(seconds),
])
return {
site: serializeSite(site),
range,
bucket,
uptime: stats.get(site.id)?.uptime ?? null,
avgResponseMs: stats.get(site.id)?.avgResponseMs ?? null,
...history,
}
}
/**
* Runs the checks right away instead of waiting for the next minute.
*/
async check() {
await siteMonitor.checkAll()
return this.index()
}
}
function serializeSite(site: Site) {
return {
id: site.id,
domain: site.domain,
active: site.active,
kind: site.kind,
upstream: site.upstream,
lastOk: site.lastOk,
lastStatus: site.lastStatus,
lastResponseMs: site.lastResponseMs,
lastError: site.lastError,
lastCheckedAt: site.lastCheckedAt,
certExpiresAt: site.certExpiresAt,
}
}
+25
View File
@@ -0,0 +1,25 @@
import { existsSync } from 'node:fs'
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
/**
* Serves the Angular application for every URL that is neither a file
* of "public/" nor an API route (/containers, /projects/…).
*/
export default class SpaController {
async index({ request, response }: HttpContext) {
if (request.url().startsWith('/api/')) {
return response.notFound({ message: 'Route inconnue.' })
}
const index = app.publicPath('index.html')
if (!existsSync(index)) {
return response.notFound(
'Interface non compilée : lance "npm run build" dans web/ (ou "npm start" pour le développement).'
)
}
response.header('Cache-Control', 'no-cache')
return response.download(index)
}
}
+55
View File
@@ -0,0 +1,55 @@
import app from '@adonisjs/core/services/app'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import { isDockerUnreachable } from '#services/docker_service'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
/**
* Shield redirects back on a bad CSRF token, which means nothing for
* the API: answer with a JSON error instead.
*/
if ((error as { code?: string })?.code === 'E_BAD_CSRF_TOKEN') {
return ctx.response.forbidden({
message: 'Session expirée ou jeton de sécurité invalide : recharge la page.',
})
}
if (isDockerUnreachable(error)) {
return ctx.response.serviceUnavailable({
message: 'Docker est injoignable : le socket Docker est-il monté dans le conteneur ?',
})
}
/**
* Errors of the Docker API (unknown container, conflict…).
*/
const dockerError = error as { statusCode?: number; json?: { message?: string } }
if (typeof dockerError?.statusCode === 'number' && dockerError.json?.message) {
return ctx.response
.status(dockerError.statusCode === 404 ? 404 : 400)
.send({ message: `Docker : ${dockerError.json.message}` })
}
return super.handle(error, ctx)
}
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
/**
* An error whose message is meant for the user (shown as is in the
* dashboard). Defaults to a 400 status.
*/
export default class UserError extends Exception {
static status = 400
constructor(message: string, status?: number) {
super(message, { status: status ?? UserError.status })
}
async handle(error: this, ctx: HttpContext) {
ctx.response.status(error.status).send({ message: error.message })
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards)
return next()
}
}
@@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import { type NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}
@@ -0,0 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* The API always answers in JSON, including the errors raised before the
* route middleware (CSRF) and the EventSource requests: an unauthenticated
* stream gets a 401 instead of a redirect.
*/
export default class ForceJsonResponseMiddleware {
handle(ctx: HttpContext, next: NextFn) {
if (ctx.request.url().startsWith('/api/')) {
ctx.request.request.headers.accept = 'application/json'
}
return next()
}
}
@@ -0,0 +1,39 @@
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Only same-origin resources. Angular puts the component styles in
* <style> tags, hence "unsafe-inline" for styles (not for scripts).
*/
const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ')
/**
* Sets the security headers on every response, including the static
* files served before the router (which shield does not cover).
*/
export default class SecurityHeadersMiddleware {
handle({ response }: HttpContext, next: NextFn) {
response.header('Content-Security-Policy', CONTENT_SECURITY_POLICY)
response.header('X-Frame-Options', 'DENY')
response.header('X-Content-Type-Options', 'nosniff')
response.header('Referrer-Policy', 'no-referrer')
response.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
response.header('Cross-Origin-Opener-Policy', 'same-origin')
if (app.inProduction) {
response.header('Strict-Transport-Security', 'max-age=15552000')
}
return next()
}
}
+5
View File
@@ -0,0 +1,5 @@
import { AuditLogSchema } from '#database/schema'
export type AuditStatus = 'running' | 'success' | 'error'
export default class AuditLog extends AuditLogSchema {}
+3
View File
@@ -0,0 +1,3 @@
import { CaddyfileVersionSchema } from '#database/schema'
export default class CaddyfileVersion extends CaddyfileVersionSchema {}
+15
View File
@@ -0,0 +1,15 @@
import { SiteSchema } from '#database/schema'
import { column } from '@adonisjs/lucid/orm'
/**
* SQLite stores booleans as 0/1: convert them back when reading.
*/
const toBoolean = (value: unknown) => (value === null ? null : Boolean(value))
export default class Site extends SiteSchema {
@column({ consume: toBoolean })
declare active: boolean
@column({ consume: toBoolean })
declare lastOk: boolean | null
}
+9
View File
@@ -0,0 +1,9 @@
import { UserSchema } from '#database/schema'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
export default class User extends compose(
UserSchema,
withAuthFinder(hash, { uids: ['username'], passwordColumnName: 'password' })
) {}
+39
View File
@@ -0,0 +1,39 @@
import { DateTime } from 'luxon'
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog, { type AuditStatus } from '#models/audit_log'
/**
* Who is acting, for the action log and the jobs.
*/
export function actor({ auth, request }: HttpContext) {
return {
username: auth.user?.username ?? null,
ip: request.ip(),
}
}
/**
* Records a short action (container restart, Caddyfile change…) in the
* action log. Long actions go through the job runner instead.
*/
export async function audit(
ctx: HttpContext,
entry: {
action: string
target?: string | null
status: AuditStatus
output?: string | null
username?: string | null
}
) {
const { username, ip } = actor(ctx)
await AuditLog.create({
username: entry.username !== undefined ? entry.username : username,
ip,
action: entry.action,
target: entry.target ?? null,
status: entry.status,
output: entry.output ?? null,
finishedAt: DateTime.now(),
})
}
+171
View File
@@ -0,0 +1,171 @@
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import jobRunner, { type JobMeta } from '#services/job_runner'
/**
* db-2026-10-11_0300.sql.gz (all the MariaDB databases)
* srv-2026-10-11_0300.tar.gz (the /srv folder)
*/
const BACKUP_FILE = /^(db|srv)-\d{4}-\d{2}-\d{2}_\d{4}\.(sql\.gz|tar\.gz)$/
export interface BackupFile {
name: string
type: 'db' | 'srv'
size: number
createdAt: number
}
class BackupService {
#timer: NodeJS.Timeout | null = null
#lastScheduledDay: string | null = null
async list(): Promise<BackupFile[]> {
let names: string[] = []
try {
names = await readdir(vpsConfig.backups.dir)
} catch {
return []
}
const files = await Promise.all(
names
.filter((name) => BACKUP_FILE.test(name))
.map(async (name) => {
const info = await stat(join(vpsConfig.backups.dir, name))
return {
name,
type: name.startsWith('db-') ? 'db' : 'srv',
size: info.size,
createdAt: Math.floor(info.mtimeMs / 1000),
} satisfies BackupFile
})
)
return files.sort((a, b) => b.createdAt - a.createdAt)
}
/**
* Absolute path of a backup, after checking its name (no path traversal).
*/
path(name: string) {
if (!BACKUP_FILE.test(name)) throw new UserError('Sauvegarde introuvable.', 404)
return join(vpsConfig.backups.dir, name)
}
async delete(name: string) {
await rm(this.path(name))
}
run(meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>) {
return jobRunner.start(
{ ...meta, action: 'Sauvegarde', target: vpsConfig.backups.dir, lockKey: 'backup' },
async (ctx) => {
const dir = vpsConfig.backups.dir
const stamp = DateTime.now().toFormat('yyyy-LL-dd_HHmm')
await mkdir(dir, { recursive: true })
const dbFile = join(dir, `db-${stamp}.sql.gz`)
ctx.log('1/2 Export de toutes les bases MariaDB')
try {
await ctx.run(
'docker',
[
'exec',
vpsConfig.mariadb.container,
'sh',
'-c',
'exec "$(command -v mariadb-dump || command -v mysqldump)" -u root -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --all-databases --single-transaction --routines --events',
],
{
display: `docker exec ${vpsConfig.mariadb.container} mariadb-dump --all-databases > ${basename(dbFile)}`,
stdoutFile: dbFile,
gzipStdout: true,
}
)
} catch (error) {
await rm(dbFile, { force: true })
throw error
}
/**
* node_modules can be reinstalled and db_data holds the raw MariaDB
* files, already covered (consistently) by the export above.
*/
const srvFile = join(dir, `srv-${stamp}.tar.gz`)
ctx.log(`2/2 Archive de ${vpsConfig.srvRoot}`)
try {
await ctx.run(
'tar',
[
'czf',
srvFile,
'--exclude=node_modules',
'--exclude=db_data',
'-C',
dirname(vpsConfig.srvRoot),
basename(vpsConfig.srvRoot),
],
/**
* 1 = some files changed while being read (logs…): not an error.
*/
{ okCodes: [0, 1] }
)
} catch (error) {
await rm(srvFile, { force: true })
throw error
}
const removed = await this.#applyRetention()
if (removed.length) ctx.log(`Anciennes sauvegardes supprimées : ${removed.join(', ')}`)
}
)
}
/**
* Keeps the last N backups of each type.
*/
async #applyRetention() {
const files = await this.list()
const removed: string[] = []
for (const type of ['db', 'srv'] as const) {
const old = files.filter((file) => file.type === type).slice(vpsConfig.backups.keep)
for (const file of old) {
await rm(join(vpsConfig.backups.dir, file.name), { force: true })
removed.push(file.name)
}
}
return removed
}
get schedule() {
return { dailyAt: vpsConfig.backups.dailyAt, keep: vpsConfig.backups.keep }
}
/**
* Daily backup at BACKUP_DAILY_AT (server time).
*/
startSchedule() {
const dailyAt = vpsConfig.backups.dailyAt
if (!dailyAt) return
this.#timer = setInterval(() => {
const now = DateTime.now()
const today = now.toISODate()
if (now.toFormat('HH:mm') !== dailyAt || this.#lastScheduledDay === today) return
this.#lastScheduledDay = today
this.run({ username: 'automatique', ip: null }).catch((error) =>
logger.error({ err: error }, 'scheduled backup failed to start')
)
}, 30_000)
}
stopSchedule() {
if (this.#timer) clearInterval(this.#timer)
}
}
const backupService = new BackupService()
export default backupService
+215
View File
@@ -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
+360
View File
@@ -0,0 +1,360 @@
import Docker from 'dockerode'
import { hostname } from 'node:os'
import { PassThrough, type Readable, type Writable } from 'node:stream'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import { lineReader } from '#services/job_runner'
export interface ContainerStats {
/**
* Share of the whole host CPU (0-100).
*/
cpu: number
memUsed: number
memLimit: number
ts: number
}
export interface ContainerSummary {
id: string
name: string
image: string
state: string
status: string
health: string | null
createdAt: number
composeProject: string | null
composeWorkingDir: string | null
composeService: string | null
networks: string[]
publishedPorts: string[]
stats: ContainerStats | null
self: boolean
}
export type ContainerAction = 'start' | 'stop' | 'restart'
export interface LogLine {
ts: string | null
stream: 'out' | 'err'
text: string
}
interface CpuSample {
container: number
system: number
}
class DockerService {
readonly docker = new Docker(
vpsConfig.docker.socketPath ? { socketPath: vpsConfig.docker.socketPath } : undefined
)
#stats = new Map<string, ContainerStats>()
#previousCpu = new Map<string, CpuSample>()
#statsTimer: NodeJS.Timeout | null = null
#selfId: Promise<string | null> | null = null
/**
* Id of the container running the dashboard (null in development).
* Inside a container, the hostname is the short container id.
*/
selfId(): Promise<string | null> {
this.#selfId ??= this.docker
.getContainer(hostname())
.inspect()
.then((info) => info.Id)
.catch(() => null)
return this.#selfId
}
async isSelf(id: string) {
const selfId = await this.selfId()
return selfId !== null && (selfId === id || selfId.startsWith(id))
}
async list(): Promise<ContainerSummary[]> {
const [containers, selfId] = await Promise.all([
this.docker.listContainers({ all: true }),
this.selfId(),
])
return containers
.map((container) => {
const labels = container.Labels ?? {}
const health = /\((healthy|unhealthy|health: starting)\)/.exec(container.Status)?.[1]
return {
id: container.Id,
name: (container.Names[0] ?? container.Id).replace(/^\//, ''),
image: container.Image,
state: container.State,
status: container.Status,
health: health ? health.replace('health: ', '') : null,
createdAt: container.Created,
composeProject: labels['com.docker.compose.project'] ?? null,
composeWorkingDir: labels['com.docker.compose.project.working_dir'] ?? null,
composeService: labels['com.docker.compose.service'] ?? null,
networks: Object.keys(container.NetworkSettings?.Networks ?? {}),
publishedPorts: [
...new Set(
(container.Ports ?? [])
.filter((port) => port.PublicPort)
.map((port) =>
formatPort(port.IP, port.PublicPort, `${port.PrivatePort}/${port.Type}`)
)
),
],
stats: container.State === 'running' ? (this.#stats.get(container.Id) ?? null) : null,
self: selfId === container.Id,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Details shown on the container page. The environment variables are
* left out on purpose: they hold passwords.
*/
async inspect(id: string) {
const info = await this.docker.getContainer(id).inspect()
const labels = info.Config.Labels ?? {}
return {
id: info.Id,
name: info.Name.replace(/^\//, ''),
image: info.Config.Image,
state: info.State.Status,
health: info.State.Health?.Status ?? null,
startedAt: info.State.StartedAt,
finishedAt: info.State.FinishedAt,
exitCode: info.State.ExitCode,
restartCount: info.RestartCount,
restartPolicy: info.HostConfig.RestartPolicy?.Name ?? null,
createdAt: info.Created,
command: [...(info.Config.Entrypoint ?? []), ...(info.Config.Cmd ?? [])].join(' '),
workingDir: info.Config.WorkingDir,
composeProject: labels['com.docker.compose.project'] ?? null,
composeWorkingDir: labels['com.docker.compose.project.working_dir'] ?? null,
composeService: labels['com.docker.compose.service'] ?? null,
networks: Object.keys(info.NetworkSettings.Networks ?? {}),
mounts: (info.Mounts ?? []).map((mount) => ({
type: mount.Type,
source: mount.Name ?? mount.Source,
destination: mount.Destination,
readOnly: !mount.RW,
})),
publishedPorts: [
...new Set(
Object.entries(info.NetworkSettings.Ports ?? {}).flatMap(([containerPort, bindings]) =>
(bindings ?? []).map((binding) =>
formatPort(binding.HostIp, binding.HostPort, containerPort)
)
)
),
],
stats: this.#stats.get(info.Id) ?? null,
self: await this.isSelf(info.Id),
}
}
async action(id: string, action: ContainerAction) {
if (action === 'stop' && (await this.isSelf(id))) {
throw new UserError('Impossible d’arrêter le conteneur du tableau de bord depuis lui-même.')
}
const container = this.docker.getContainer(id)
try {
if (action === 'start') await container.start()
else if (action === 'stop') await container.stop()
else await container.restart()
} catch (error: any) {
/**
* 304: the container is already in the requested state.
*/
if (error?.statusCode !== 304) throw error
}
}
/**
* Follows the logs of a container. Returns a function stopping the stream.
*/
async followLogs(
id: string,
options: { tail: number },
onLine: (line: LogLine) => void,
onEnd: () => void
): Promise<() => void> {
const container = this.docker.getContainer(id)
const info = await container.inspect()
const stream = (await container.logs({
follow: true,
stdout: true,
stderr: true,
tail: options.tail,
timestamps: true,
})) as unknown as Readable
const parse = (source: LogLine['stream']) => (raw: string) => {
const space = raw.indexOf(' ')
const hasTs = space > 0 && /^\d{4}-\d{2}-\d{2}T/.test(raw)
onLine({
ts: hasTs ? raw.slice(0, space) : null,
stream: source,
text: hasTs ? raw.slice(space + 1) : raw,
})
}
/**
* Without a TTY, Docker multiplexes stdout and stderr in one stream.
*/
if (info.Config.Tty) {
lineReader(stream, parse('out'))
} else {
const stdout = new PassThrough()
const stderr = new PassThrough()
this.docker.modem.demuxStream(stream, stdout, stderr)
stream.on('end', () => {
stdout.end()
stderr.end()
})
lineReader(stdout, parse('out'))
lineReader(stderr, parse('err'))
}
let ended = false
const end = () => {
if (ended) return
ended = true
onEnd()
}
stream.on('end', end)
stream.on('error', end)
return () => {
stream.destroy()
}
}
/**
* Runs a command inside a container and waits for it.
*/
async exec(containerName: string, cmd: string[], stdoutTarget?: Writable) {
const container = this.docker.getContainer(containerName)
const exec = await container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true })
const stream = await exec.start({ hijack: true, stdin: false })
const stdoutChunks: Buffer[] = []
const stderrChunks: Buffer[] = []
const stdout = stdoutTarget ?? collect(stdoutChunks)
const stderr = collect(stderrChunks)
this.docker.modem.demuxStream(stream, stdout, stderr)
await new Promise<void>((resolve, reject) => {
stream.on('end', resolve)
stream.on('close', resolve)
stream.on('error', reject)
})
const result = await exec.inspect()
return {
exitCode: result.ExitCode ?? -1,
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
}
}
/**
* Collects the CPU and memory usage of the running containers. Uses
* "one-shot" stats (instant) and computes the CPU usage between two polls.
*/
async refreshStats() {
const running = await this.docker.listContainers()
const seen = new Set<string>()
await Promise.all(
running.map(async ({ Id: id }) => {
seen.add(id)
try {
const stats: any = await this.docker
.getContainer(id)
.stats({ 'stream': false, 'one-shot': true } as any)
const sample: CpuSample = {
container: Number(stats.cpu_stats?.cpu_usage?.total_usage ?? 0),
system: Number(stats.cpu_stats?.system_cpu_usage ?? 0),
}
const previous = this.#previousCpu.get(id)
this.#previousCpu.set(id, sample)
let cpu = 0
if (previous && sample.system > previous.system) {
cpu =
((sample.container - previous.container) / (sample.system - previous.system)) * 100
}
const memory = stats.memory_stats ?? {}
const cache =
memory.stats?.inactive_file ??
memory.stats?.total_inactive_file ??
memory.stats?.cache ??
0
this.#stats.set(id, {
cpu: Math.max(0, Math.min(100, cpu)),
memUsed: Math.max(0, Number(memory.usage ?? 0) - Number(cache)),
memLimit: Number(memory.limit ?? 0),
ts: Date.now(),
})
} catch {
// The container may have stopped between the list and the stats call.
}
})
)
for (const id of this.#stats.keys()) {
if (!seen.has(id)) {
this.#stats.delete(id)
this.#previousCpu.delete(id)
}
}
}
startStatsPolling(intervalMs = 10_000) {
const tick = () => this.refreshStats().catch(() => {})
tick()
this.#statsTimer = setInterval(tick, intervalMs)
}
stopStatsPolling() {
if (this.#statsTimer) clearInterval(this.#statsTimer)
}
}
/**
* "8080->80/tcp" for a port open on every interface (reachable from
* Internet), "127.0.0.1:8080->80/tcp" when bound to one address only.
*/
function formatPort(ip: string | undefined, hostPort: number | string, target: string) {
const everywhere = !ip || ip === '0.0.0.0' || ip === '::'
return `${everywhere ? '' : `${ip}:`}${hostPort}->${target}`
}
function collect(chunks: Buffer[]) {
const stream = new PassThrough()
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
return stream
}
/**
* True when the error comes from the Docker socket being unreachable.
*/
export function isDockerUnreachable(error: unknown) {
const code = (error as NodeJS.ErrnoException)?.code
return (
code === 'ECONNREFUSED' ||
code === 'EACCES' ||
(code === 'ENOENT' && (error as any)?.syscall === 'connect')
)
}
const dockerService = new DockerService()
export default dockerService
+278
View File
@@ -0,0 +1,278 @@
import { spawn } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { EventEmitter } from 'node:events'
import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import AuditLog, { type AuditStatus } from '#models/audit_log'
import UserError from '#exceptions/user_error'
export interface JobLine {
ts: number
stream: 'out' | 'err' | 'info'
text: string
}
export interface RunOptions {
cwd?: string
env?: Record<string, string>
/**
* Exit codes considered as a success (default: [0]).
*/
okCodes?: number[]
/**
* Text shown in the log instead of the real command (to hide secrets).
*/
display?: string
/**
* Writes stdout to this file instead of the log.
*/
stdoutFile?: string
gzipStdout?: boolean
timeoutMs?: number
}
export interface JobContext {
log(text: string, stream?: JobLine['stream']): void
run(command: string, args: string[], options?: RunOptions): Promise<void>
}
export interface JobMeta {
action: string
target: string | null
username: string | null
ip: string | null
/**
* Two jobs with the same lock key cannot run at the same time
* (for example two deployments of the same project).
*/
lockKey?: string
}
/**
* Output kept in the action log, to keep the SQLite file small.
*/
const MAX_STORED_OUTPUT = 200_000
/**
* Finished jobs stay in memory for a while so that a page opened late
* can still replay the whole output.
*/
const KEEP_FINISHED_MS = 15 * 60_000
export class Job extends EventEmitter {
lines: JobLine[] = []
status: AuditStatus = 'running'
constructor(
readonly id: number,
readonly meta: JobMeta
) {
super()
this.setMaxListeners(50)
}
push(line: JobLine) {
this.lines.push(line)
this.emit('line', line)
}
get output() {
return this.lines.map((line) => line.text).join('\n')
}
}
/**
* Runs the long actions (deployments, backups, project creation) in the
* background. Each job is recorded in the action log and its output is
* streamed to the browser.
*/
class JobRunner {
#jobs = new Map<number, Job>()
#locks = new Set<string>()
get(id: number) {
return this.#jobs.get(id)
}
isLocked(lockKey: string) {
return this.#locks.has(lockKey)
}
async start(meta: JobMeta, task: (ctx: JobContext) => Promise<void>): Promise<Job> {
if (meta.lockKey && this.#locks.has(meta.lockKey)) {
throw new UserError('Une action est déjà en cours sur cet élément, attends la fin.', 409)
}
if (meta.lockKey) this.#locks.add(meta.lockKey)
const entry = await AuditLog.create({
action: meta.action,
target: meta.target,
username: meta.username,
ip: meta.ip,
status: 'running',
})
const job = new Job(entry.id, meta)
this.#jobs.set(job.id, job)
const ctx: JobContext = {
log: (text, stream = 'info') => {
for (const line of text.split('\n')) job.push({ ts: Date.now(), stream, text: line })
},
run: (command, args, options) => this.#run(job, command, args, options),
}
/**
* Not awaited: the HTTP request returns the job id right away.
*/
void (async () => {
try {
await task(ctx)
job.status = 'success'
ctx.log('✔ Terminé')
} catch (error) {
job.status = 'error'
ctx.log(`✖ ${error instanceof Error ? error.message : String(error)}`, 'err')
} finally {
if (meta.lockKey) this.#locks.delete(meta.lockKey)
await this.#finish(job, entry)
job.emit('end', job.status)
setTimeout(() => this.#jobs.delete(job.id), KEEP_FINISHED_MS).unref()
}
})()
return job
}
async #finish(job: Job, entry: AuditLog) {
try {
const output = job.output
entry.status = job.status
entry.output =
output.length > MAX_STORED_OUTPUT
? `[…] ${output.slice(output.length - MAX_STORED_OUTPUT)}`
: output
entry.finishedAt = DateTime.now()
await entry.save()
} catch (error) {
logger.error({ err: error }, 'cannot save the job output')
}
}
#run(job: Job, command: string, args: string[], options: RunOptions = {}) {
const okCodes = options.okCodes ?? [0]
job.push({
ts: Date.now(),
stream: 'info',
text: `$ ${options.display ?? [command, ...args.map(quote)].join(' ')}`,
})
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: cleanEnv(options.env),
stdio: ['ignore', 'pipe', 'pipe'],
})
const timeout = setTimeout(
() => {
job.push({ ts: Date.now(), stream: 'err', text: 'Délai dépassé, commande arrêtée.' })
child.kill('SIGKILL')
},
options.timeoutMs ?? 15 * 60_000
)
let fileWritten: Promise<void> = Promise.resolve()
if (options.stdoutFile) {
const file = createWriteStream(options.stdoutFile)
fileWritten = options.gzipStdout
? pipeline(child.stdout, createGzip(), file)
: pipeline(child.stdout, file)
} else {
lineReader(child.stdout, (text) => job.push({ ts: Date.now(), stream: 'out', text }))
}
lineReader(child.stderr, (text) => job.push({ ts: Date.now(), stream: 'err', text }))
child.on('error', (error) => {
clearTimeout(timeout)
reject(new Error(`Impossible de lancer "${command}" : ${error.message}`))
})
child.on('close', async (code) => {
clearTimeout(timeout)
try {
await fileWritten
} catch (error) {
return reject(error)
}
if (code !== null && okCodes.includes(code)) return resolve()
reject(new Error(`La commande "${command}" a échoué (code ${code ?? 'tué'}).`))
})
})
}
}
/**
* Variables passed to the commands. The dashboard's own variables (PORT,
* APP_KEY…) are left out: "docker compose" would use them to fill the
* ${VAR} of the projects' compose files.
*/
const INHERITED_ENV = [
'PATH',
'HOME',
'TZ',
'DOCKER_HOST',
'DOCKER_CONFIG',
// Windows (local development)
'USERPROFILE',
'APPDATA',
'LOCALAPPDATA',
'ProgramData',
'ProgramFiles',
'SystemRoot',
'TEMP',
'TMP',
]
export function cleanEnv(extra: Record<string, string> = {}) {
const env: Record<string, string> = {}
for (const key of INHERITED_ENV) {
if (process.env[key] !== undefined) env[key] = process.env[key]!
}
/**
* Never wait for a password: fail right away instead.
*/
env.GIT_TERMINAL_PROMPT = '0'
return { ...env, ...extra }
}
/**
* Calls the callback for each complete line of the stream.
*/
export function lineReader(stream: NodeJS.ReadableStream, onLine: (line: string) => void) {
let rest = ''
stream.setEncoding('utf8')
stream.on('data', (chunk: string) => {
const lines = (rest + chunk).split(/\r?\n/)
rest = lines.pop() ?? ''
for (const line of lines) onLine(line)
})
stream.on('end', () => {
if (rest) onLine(rest)
rest = ''
})
}
function quote(arg: string) {
return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`
}
const jobRunner = new JobRunner()
export default jobRunner
+262
View File
@@ -0,0 +1,262 @@
import os from 'node:os'
import { EventEmitter } from 'node:events'
import { readFile, statfs } from 'node:fs/promises'
import { join } from 'node:path'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import vpsConfig from '#config/vps'
export interface HostSample {
ts: number
cpu: number
load1: number | null
memUsed: number
memTotal: number
diskUsed: number
diskTotal: number
/**
* Bytes per second (null when not available, e.g. on Windows).
*/
netRx: number | null
netTx: number | null
}
export type HistoryRange = '1h' | '24h' | '7d' | '30d'
/**
* Size of the buckets used to average the history, per range.
*/
const BUCKETS: Record<HistoryRange, { seconds: number; bucket: number }> = {
'1h': { seconds: 3600, bucket: 60 },
'24h': { seconds: 86_400, bucket: 300 },
'7d': { seconds: 7 * 86_400, bucket: 1800 },
'30d': { seconds: 30 * 86_400, bucket: 7200 },
}
const SAMPLE_EVERY_MS = 5000
const LIVE_SAMPLES = 120
const RETENTION_SECONDS = 31 * 86_400
/**
* Virtual interfaces (Docker bridges, veth pairs, loopback) are left out of
* the network traffic so that only the real traffic of the VPS is counted.
*/
const VIRTUAL_INTERFACE = /^(lo|docker\d*|br-|veth|virbr|tun|tap|wg|tailscale)/
/**
* Samples the host every 5 seconds, keeps the last 10 minutes in memory
* for the live charts and stores one averaged row per minute.
*/
class MetricsService extends EventEmitter {
#live: HostSample[] = []
#minute: HostSample[] = []
#previousCpu: { idle: number; total: number } | null = null
#previousNet: { rx: number; tx: number; ts: number } | null = null
#timers: NodeJS.Timeout[] = []
constructor() {
super()
this.setMaxListeners(100)
}
get latest(): HostSample | null {
return this.#live.at(-1) ?? null
}
get live() {
return this.#live
}
start() {
const tick = () =>
this.#sample().catch((error) => logger.warn({ err: error }, 'cannot sample host metrics'))
tick()
this.#timers.push(setInterval(tick, SAMPLE_EVERY_MS))
this.#timers.push(setInterval(() => this.#persist(), 60_000))
this.#timers.push(setInterval(() => this.#prune(), 6 * 3600_000))
}
stop() {
this.#timers.forEach(clearInterval)
}
async uptime(): Promise<number> {
const content = await this.#readProc('uptime')
return content ? Number.parseFloat(content) : os.uptime()
}
async history(range: HistoryRange) {
const { seconds, bucket } = BUCKETS[range]
const since = Math.floor(Date.now() / 1000) - seconds
const rows = await db
.from('metrics')
.select(db.raw('(ts / ?) * ? as t', [bucket, bucket]))
.select(
db.raw(`avg(cpu) as cpu, avg(load1) as load1,
avg(mem_used) as memUsed, max(mem_total) as memTotal,
avg(disk_used) as diskUsed, max(disk_total) as diskTotal,
avg(net_rx) as netRx, avg(net_tx) as netTx`)
)
.where('ts', '>=', since)
.groupBy('t')
.orderBy('t')
return { range, bucket, points: rows }
}
async #sample() {
const [cpu, memory, disk, net, load1] = await Promise.all([
this.#cpu(),
this.#memory(),
this.#disk(),
this.#network(),
this.#load(),
])
const sample: HostSample = { ts: Date.now(), cpu, load1, ...memory, ...disk, ...net }
this.#live.push(sample)
if (this.#live.length > LIVE_SAMPLES) this.#live.shift()
this.#minute.push(sample)
this.emit('sample', sample)
}
async #persist() {
const samples = this.#minute
this.#minute = []
if (!samples.length) return
const average = (pick: (sample: HostSample) => number | null) => {
const values = samples.map(pick).filter((value): value is number => value !== null)
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
}
const last = samples.at(-1)!
try {
await db.table('metrics').insert({
ts: Math.floor(last.ts / 1000),
cpu: average((sample) => sample.cpu) ?? 0,
load1: average((sample) => sample.load1),
mem_used: Math.round(average((sample) => sample.memUsed) ?? 0),
mem_total: last.memTotal,
disk_used: last.diskUsed,
disk_total: last.diskTotal,
net_rx: average((sample) => sample.netRx),
net_tx: average((sample) => sample.netTx),
})
} catch (error) {
logger.error({ err: error }, 'cannot store host metrics')
}
}
async #prune() {
const limit = Math.floor(Date.now() / 1000) - RETENTION_SECONDS
await db
.from('metrics')
.where('ts', '<', limit)
.delete()
.catch(() => {})
}
async #readProc(file: string) {
const root = vpsConfig.hostProc ?? (process.platform === 'linux' ? '/proc' : null)
if (!root) return null
try {
return await readFile(join(root, file), 'utf8')
} catch {
return null
}
}
/**
* CPU usage between two samples, from /proc/stat (or os.cpus()).
*/
async #cpu() {
let idle = 0
let total = 0
const stat = await this.#readProc('stat')
if (stat) {
const fields = stat.split('\n')[0].trim().split(/\s+/).slice(1).map(Number)
/**
* user nice system idle iowait irq softirq steal (guest is already in user)
*/
const [user, nice, system, idleTime, iowait, irq, softirq, steal] = fields
idle = idleTime + (iowait ?? 0)
total =
user + nice + system + idleTime + (iowait ?? 0) + (irq ?? 0) + (softirq ?? 0) + (steal ?? 0)
} else {
for (const cpu of os.cpus()) {
idle += cpu.times.idle
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq
}
}
const previous = this.#previousCpu
this.#previousCpu = { idle, total }
if (!previous || total <= previous.total) return 0
const usage = (1 - (idle - previous.idle) / (total - previous.total)) * 100
return Math.max(0, Math.min(100, usage))
}
async #memory() {
const meminfo = await this.#readProc('meminfo')
if (meminfo) {
const read = (key: string) => {
const match = new RegExp(`^${key}:\\s+(\\d+)`, 'm').exec(meminfo)
return match ? Number(match[1]) * 1024 : 0
}
const memTotal = read('MemTotal')
return { memTotal, memUsed: memTotal - read('MemAvailable') }
}
return { memTotal: os.totalmem(), memUsed: os.totalmem() - os.freemem() }
}
async #disk() {
try {
const stats = await statfs(vpsConfig.diskPath)
const used = (stats.blocks - stats.bfree) * stats.bsize
const available = stats.bavail * stats.bsize
/**
* Same computation as "df": the space reserved for root is left out.
*/
return { diskUsed: used, diskTotal: used + available }
} catch {
return { diskUsed: 0, diskTotal: 0 }
}
}
/**
* Network traffic of the host. With HOST_PROC, /proc/1/net/dev is the
* network namespace of the host (not the one of the container).
*/
async #network(): Promise<{ netRx: number | null; netTx: number | null }> {
const content = await this.#readProc(vpsConfig.hostProc ? '1/net/dev' : 'net/dev')
if (!content) return { netRx: null, netTx: null }
let rx = 0
let tx = 0
for (const line of content.split('\n').slice(2)) {
const [name, data] = line.split(':')
if (!data || VIRTUAL_INTERFACE.test(name.trim())) continue
const fields = data.trim().split(/\s+/).map(Number)
rx += fields[0]
tx += fields[8]
}
const now = Date.now()
const previous = this.#previousNet
this.#previousNet = { rx, tx, ts: now }
if (!previous || rx < previous.rx || tx < previous.tx) return { netRx: null, netTx: null }
const seconds = (now - previous.ts) / 1000
return { netRx: (rx - previous.rx) / seconds, netTx: (tx - previous.tx) / seconds }
}
async #load() {
const content = await this.#readProc('loadavg')
if (content) return Number.parseFloat(content)
return process.platform === 'win32' ? null : os.loadavg()[0]
}
}
const metricsService = new MetricsService()
export default metricsService
+629
View File
@@ -0,0 +1,629 @@
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { promisify } from 'node:util'
import { join, resolve } from 'node:path'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import caddyService from '#services/caddy_service'
import dockerService, { type ContainerSummary } from '#services/docker_service'
import jobRunner, { cleanEnv, type JobContext, type JobMeta } from '#services/job_runner'
import {
composeFile,
placeholderPage,
proxyBlock,
staticBlock,
type ProjectType,
} from '#services/templates'
const execFileAsync = promisify(execFile)
const COMPOSE_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']
const SEGMENT = /^[\w][\w.-]*$/
export type ProjectKind = 'compose' | 'static' | 'infra'
export interface GitCommit {
hash: string
short: string
subject: string
author: string
date: string
}
export interface GitInfo {
repoPath: string
branch: string | null
detached: boolean
upstream: string | null
ahead: number
behind: number
/**
* Number of tracked files modified on the server.
*/
dirty: number
commit: GitCommit | null
}
export interface ProjectContainer {
id: string
name: string
state: string
status: string
health: string | null
}
export interface Project {
category: string
name: string
kind: ProjectKind
path: string
composeFile: string | null
git: GitInfo | null
containers: ProjectContainer[]
/**
* The project of the dashboard itself.
*/
self: boolean
updatedAt: number | null
}
export const PROJECT_ACTIONS = [
'deploy',
'restart',
'stop',
'start',
'recreate',
'pull',
'checkout',
'checkout-branch',
] as const
export type ProjectAction = (typeof PROJECT_ACTIONS)[number]
export const ACTION_LABELS: Record<ProjectAction, string> = {
'deploy': 'Mise à jour (git pull + redémarrage)',
'restart': 'Redémarrage',
'stop': 'Arrêt',
'start': 'Démarrage',
'recreate': 'Recréation des conteneurs',
'pull': 'Mise à jour des images Docker',
'checkout': 'Retour à un commit',
'checkout-branch': 'Retour sur la branche',
}
export interface CreateProjectInput {
type: ProjectType
name: string
category: string
gitUrl?: string
port?: number
command?: string
env?: string
createDatabase: boolean
domain: string
addCaddy: boolean
spa: boolean
}
async function exists(path: string) {
try {
await access(path)
return true
} catch {
return false
}
}
async function directories(path: string) {
try {
const entries = await readdir(path, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b))
} catch {
return []
}
}
async function findComposeFile(dir: string) {
for (const file of COMPOSE_FILES) {
if (await exists(join(dir, file))) return file
}
return null
}
function git(repo: string, args: string[], timeout = 15_000) {
return execFileAsync('git', ['-C', repo, ...args], {
timeout,
env: cleanEnv(),
maxBuffer: 10 * 1024 * 1024,
}).then(({ stdout }) => stdout.trim())
}
/**
* Hides the credentials of a git URL ("https://user:token@host/…").
*/
export function hideCredentials(url: string) {
return url.replace(/\/\/[^/@\s]+@/, '//***@')
}
function samePath(a: string, b: string) {
const normalize = (path: string) =>
resolve(path)
.replace(/[\\/]+$/, '')
.toLowerCase()
return normalize(a) === normalize(b)
}
function randomSecret(length: number) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
return Array.from(randomBytes(length), (byte) => alphabet[byte % alphabet.length]).join('')
}
class ProjectService {
/**
* Scans /srv: "html/*" are static sites, folders with their own compose
* file are infrastructure, the others hold one project per sub-folder.
*/
async list(): Promise<Project[]> {
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
const root = vpsConfig.srvRoot
const found: { category: string; name: string; path: string; kind: ProjectKind }[] = []
for (const folder of await directories(root)) {
const path = join(root, folder)
if (folder === vpsConfig.staticCategory) {
for (const name of await directories(path)) {
found.push({ category: folder, name, path: join(path, name), kind: 'static' })
}
} else if (await findComposeFile(path)) {
found.push({ category: 'infra', name: folder, path, kind: 'infra' })
} else {
for (const name of await directories(path)) {
if (await findComposeFile(join(path, name))) {
found.push({ category: folder, name, path: join(path, name), kind: 'compose' })
}
}
}
}
return Promise.all(found.map((entry) => this.#build(entry, containers)))
}
async find(category: string, name: string): Promise<Project> {
if (!SEGMENT.test(category) || !SEGMENT.test(name)) {
throw new UserError('Projet introuvable.', 404)
}
const root = vpsConfig.srvRoot
let path: string
let kind: ProjectKind
if (category === 'infra') {
path = join(root, name)
kind = 'infra'
} else if (category === vpsConfig.staticCategory) {
path = join(root, category, name)
kind = 'static'
} else {
path = join(root, category, name)
kind = 'compose'
}
const valid = kind === 'static' ? await exists(path) : (await findComposeFile(path)) !== null
if (!valid) throw new UserError('Projet introuvable.', 404)
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
return this.#build({ category, name, path, kind }, containers)
}
async #build(
entry: { category: string; name: string; path: string; kind: ProjectKind },
containers: ContainerSummary[]
): Promise<Project> {
const selfDir = containers.find((container) => container.self)?.composeWorkingDir ?? null
const project: Project = {
...entry,
composeFile: entry.kind === 'static' ? null : await findComposeFile(entry.path),
git: null,
containers: containers
.filter((c) => c.composeWorkingDir && samePath(c.composeWorkingDir, entry.path))
.map(({ id, name, state, status, health }) => ({ id, name, state, status, health })),
self: selfDir !== null && samePath(selfDir, entry.path),
updatedAt: null,
}
if (entry.kind === 'static') {
const index = join(entry.path, 'index.html')
const info = await stat((await exists(index)) ? index : entry.path).catch(() => null)
project.updatedAt = info ? Math.floor(info.mtimeMs / 1000) : null
} else {
const repoPath = (await exists(join(entry.path, 'project', '.git')))
? join(entry.path, 'project')
: (await exists(join(entry.path, '.git')))
? entry.path
: null
if (repoPath) project.git = await this.#gitInfo(repoPath).catch(() => null)
}
return project
}
async #gitInfo(repoPath: string): Promise<GitInfo> {
const [status, log] = await Promise.all([
git(repoPath, ['status', '--porcelain=v2', '--branch']),
git(repoPath, ['log', '-1', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI']).catch(() => ''),
])
const info: GitInfo = {
repoPath,
branch: null,
detached: false,
upstream: null,
ahead: 0,
behind: 0,
dirty: 0,
commit: parseCommit(log),
}
for (const line of status.split('\n')) {
if (line.startsWith('# branch.head ')) {
const head = line.slice('# branch.head '.length)
info.detached = head === '(detached)'
info.branch = info.detached ? null : head
} else if (line.startsWith('# branch.upstream ')) {
info.upstream = line.slice('# branch.upstream '.length)
} else if (line.startsWith('# branch.ab ')) {
const [ahead, behind] = line.slice('# branch.ab '.length).split(' ')
info.ahead = Math.abs(Number(ahead))
info.behind = Math.abs(Number(behind))
} else if (/^[12u] /.test(line)) {
info.dirty++
}
}
return info
}
/**
* Branch followed by the remote ("main" most of the time).
*/
async #defaultBranch(repoPath: string) {
const head = await git(repoPath, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).catch(
() => ''
)
if (head) return head.replace(/^origin\//, '')
for (const branch of ['main', 'master']) {
const ok = await git(repoPath, ['rev-parse', '--verify', '--quiet', `origin/${branch}`])
.then(() => true)
.catch(() => false)
if (ok) return branch
}
return null
}
/**
* Everything shown on the project page.
*/
async details(project: Project) {
let compose: string | null = null
if (project.composeFile) {
compose = await readFile(join(project.path, project.composeFile), 'utf8').catch(() => null)
}
if (!project.git) return { ...project, compose, remote: null, defaultBranch: null, commits: [] }
const repo = project.git.repoPath
const [remote, defaultBranch] = await Promise.all([
git(repo, ['remote', 'get-url', 'origin']).catch(() => null),
this.#defaultBranch(repo),
])
/**
* When the project is on an older commit (detached), the commits of the
* remote branch are listed, to be able to come back to a newer one.
*/
const ref =
!project.git.detached && project.git.upstream
? project.git.upstream
: defaultBranch
? `origin/${defaultBranch}`
: 'HEAD'
const log = await git(repo, ['log', '-30', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI', ref]).catch(
() => ''
)
return {
...project,
compose,
remote: remote ? hideCredentials(remote) : null,
defaultBranch,
commits: log
.split('\n')
.map(parseCommit)
.filter((commit): commit is GitCommit => commit !== null),
}
}
/**
* "git fetch" to know if new commits are waiting.
*/
async fetch(project: Project) {
if (!project.git) throw new UserError('Ce projet n’a pas de dépôt git.')
try {
await git(project.git.repoPath, ['fetch', '--prune', 'origin'], 60_000)
} catch (error: any) {
throw new UserError(`git fetch a échoué : ${(error?.stderr || error?.message || '').trim()}`)
}
return this.#gitInfo(project.git.repoPath)
}
async runAction(
project: Project,
action: ProjectAction,
params: { commit?: string },
meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>
) {
if (project.kind === 'static') {
throw new UserError(
'Un site statique se met à jour en envoyant son build (voir la procédure).'
)
}
if (['deploy', 'checkout', 'checkout-branch'].includes(action) && !project.git) {
throw new UserError('Ce projet n’a pas de dépôt git.')
}
if (action === 'stop' && project.self) {
throw new UserError('Impossible d’arrêter le tableau de bord depuis lui-même.')
}
if (action === 'checkout' && !/^[0-9a-f]{7,40}$/.test(params.commit ?? '')) {
throw new UserError('Commit invalide.')
}
const repo = project.git?.repoPath ?? project.path
const compose = (ctx: JobContext, args: string[]) => this.#compose(ctx, project, args)
const recreate = (ctx: JobContext) => compose(ctx, ['up', '-d', '--build', '--force-recreate'])
return jobRunner.start(
{
...meta,
action: ACTION_LABELS[action],
target: `${project.category}/${project.name}`,
lockKey: `project:${project.path}`,
},
async (ctx) => {
switch (action) {
case 'deploy':
if (project.git?.detached) {
throw new Error(
'Le projet est sur un ancien commit : utilise d’abord « Revenir sur la branche ».'
)
}
await ctx.run('git', ['pull', '--ff-only'], { cwd: repo })
await recreate(ctx)
break
case 'restart':
await compose(ctx, ['restart'])
break
case 'stop':
await compose(ctx, ['stop'])
break
case 'start':
await compose(ctx, ['up', '-d'])
break
case 'recreate':
await recreate(ctx)
break
case 'pull':
await ctx.run('docker', ['compose', 'pull'], { cwd: project.path })
await compose(ctx, ['up', '-d'])
break
case 'checkout':
await ctx.run('git', ['checkout', '--detach', params.commit!], { cwd: repo })
await recreate(ctx)
break
case 'checkout-branch': {
const branch = await this.#defaultBranch(repo)
if (!branch) throw new Error('Branche distante introuvable.')
await ctx.run('git', ['checkout', branch], { cwd: repo })
await ctx.run('git', ['pull', '--ff-only'], { cwd: repo })
await recreate(ctx)
break
}
}
}
)
}
/**
* Runs "docker compose" in the project folder. For the dashboard itself,
* the command runs in a separate container: recreating the dashboard
* container would otherwise kill the command half-way.
*/
async #compose(ctx: JobContext, project: Project, args: string[]) {
if (!project.self) {
await ctx.run('docker', ['compose', ...args], { cwd: project.path })
return
}
ctx.log('Projet du tableau de bord : la commande est lancée dans un conteneur à part.')
await ctx.run('docker', [
'run',
'--rm',
'-d',
'--name',
`big-brother-updater-${Date.now()}`,
'-v',
'/var/run/docker.sock:/var/run/docker.sock',
'-v',
`${project.path}:${project.path}`,
'-w',
project.path,
'docker:cli',
'docker',
'compose',
...args,
])
ctx.log('Le tableau de bord va redémarrer : la page se reconnectera toute seule.')
}
/**
* Creates a project the way the procedure does it: folder, git clone,
* .env, database, docker-compose.yml, start, then the Caddy block.
*/
async create(input: CreateProjectInput, meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>) {
const isStatic = input.type === 'static'
const category = isStatic ? vpsConfig.staticCategory : input.category
const categoryPath = join(vpsConfig.srvRoot, category)
const path = join(categoryPath, input.name)
if (await exists(path)) throw new UserError(`Le dossier ${path} existe déjà.`)
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
if (!isStatic && containers.some((container) => container.name === input.name)) {
throw new UserError(`Un conteneur s’appelle déjà « ${input.name} ».`)
}
if (input.addCaddy) {
const sites = await caddyService.sites()
if (sites.some((site) => site.domains.includes(input.domain))) {
throw new UserError(`${input.domain} est déjà dans le Caddyfile.`)
}
}
return jobRunner.start(
{
...meta,
action: 'Création de projet',
target: `${category}/${input.name}`,
lockKey: `create:${path}`,
},
async (ctx) => {
await mkdir(categoryPath, { recursive: true })
await mkdir(path)
ctx.log(`Dossier créé : ${path}`)
if (isStatic) {
await writeFile(join(path, 'index.html'), placeholderPage(input.name))
ctx.log('Page d’attente créée : envoie ensuite le build du site dans ce dossier.')
} else {
await this.#createApp(ctx, input, path)
}
if (input.addCaddy) {
const block = isStatic
? staticBlock(input.domain, `${vpsConfig.srvRoot}/${category}/${input.name}`, input.spa)
: proxyBlock(
input.domain,
`${input.name}:${input.type === 'wordpress' ? 80 : input.port}`
)
ctx.log(`Ajout au Caddyfile :\n${block}`)
const result = await caddyService.appendBlock(block, {
username: meta.username,
reason: `Création de ${input.name}`,
})
if (result.output) ctx.log(result.output, result.ok ? 'out' : 'err')
if (!result.ok)
throw new Error('Caddy a refusé la configuration, le Caddyfile est inchangé.')
ctx.log(`Caddy rechargé : https://${input.domain}`)
}
}
)
}
async #createApp(ctx: JobContext, input: CreateProjectInput, path: string) {
const type = input.type as Exclude<ProjectType, 'static'>
const isWordpress = type === 'wordpress'
if (!isWordpress) {
await ctx.run('git', ['clone', input.gitUrl!, 'project'], {
cwd: path,
display: `git clone ${hideCredentials(input.gitUrl!)} project`,
})
}
let database: { name: string; user: string; password: string } | null = null
if (isWordpress || input.createDatabase) {
const dbName = input.name.replace(/-/g, '_')
database = { name: dbName, user: dbName, password: randomSecret(24) }
await this.#createDatabase(ctx, database)
}
const envLines: string[] = []
if (isWordpress) {
envLines.push(
`WP_DB_NAME=${database!.name}`,
`WP_DB_USER=${database!.user}`,
`WP_DB_PASSWORD=${database!.password}`
)
await writeFile(join(path, '.env'), `${envLines.join('\n')}\n`, { mode: 0o600 })
} else {
const env = (input.env ?? '').replace(/\r\n/g, '\n').trim()
const has = (key: string) => new RegExp(`^${key}=`, 'm').test(env)
if (env) envLines.push(env)
if (type === 'adonis') {
if (!has('APP_KEY')) envLines.push(`APP_KEY=${randomSecret(32)}`)
if (!has('LOG_LEVEL')) envLines.push('LOG_LEVEL=info')
if (!has('APP_URL')) envLines.push(`APP_URL=https://${input.domain}`)
}
if (database) {
envLines.push(
'DB_HOST=mariadb',
'DB_PORT=3306',
`DB_USER=${database.user}`,
`DB_PASSWORD=${database.password}`,
`DB_DATABASE=${database.name}`
)
}
await writeFile(join(path, 'project', '.env'), `${envLines.join('\n')}\n`, { mode: 0o600 })
}
ctx.log('Fichier .env écrit.')
await writeFile(
join(path, 'docker-compose.yml'),
composeFile(type, input.name, input.port ?? 80, input.command)
)
ctx.log('docker-compose.yml écrit.')
await ctx.run('docker', ['compose', 'up', '-d'], { cwd: path })
}
/**
* One database and one user per project, created with the root password
* of the MariaDB container (read inside the container, never here).
*/
async #createDatabase(
ctx: JobContext,
database: { name: string; user: string; password: string }
) {
const sql = [
`CREATE DATABASE \`${database.name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`,
`CREATE USER '${database.user}'@'%' IDENTIFIED BY '${database.password}';`,
`GRANT ALL PRIVILEGES ON \`${database.name}\`.* TO '${database.user}'@'%';`,
].join(' ')
await ctx.run(
'docker',
[
'exec',
vpsConfig.mariadb.container,
'sh',
'-c',
'exec "$(command -v mariadb || command -v mysql)" -u root -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" -e "$0"',
sql,
],
{
display: `docker exec ${vpsConfig.mariadb.container} mariadb -e "CREATE DATABASE ${database.name} … (utilisateur ${database.user})"`,
}
)
ctx.log(`Base « ${database.name} » et utilisateur créés (mot de passe dans le .env).`)
}
}
function parseCommit(line: string): GitCommit | null {
const [hash, short, subject, author, date] = line.split('\x1f')
if (!hash || !short) return null
return { hash, short, subject, author, date }
}
const projectService = new ProjectService()
export default projectService
+277
View File
@@ -0,0 +1,277 @@
import tls from 'node:tls'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import Site from '#models/site'
import caddyService from '#services/caddy_service'
const CHECK_EVERY_MS = 60_000
const REQUEST_TIMEOUT_MS = 10_000
const CERT_CHECK_EVERY_S = 6 * 3600
const RETENTION_SECONDS = 31 * 86_400
const CONCURRENCY = 5
export interface SiteCheckResult {
ok: boolean
status: number | null
responseMs: number | null
error: string | null
}
/**
* Checks every minute that each domain of the Caddyfile answers, and
* twice a day when its HTTPS certificate expires.
*
* The checks run from the VPS itself: they spot a stopped container or a
* broken configuration, not the VPS being down.
*/
class SiteMonitor {
#timers: NodeJS.Timeout[] = []
#running = false
start() {
const tick = () =>
this.checkAll().catch((error) => logger.warn({ err: error }, 'site checks failed'))
setTimeout(tick, 3000)
this.#timers.push(setInterval(tick, CHECK_EVERY_MS))
this.#timers.push(setInterval(() => this.#prune(), 6 * 3600_000))
}
stop() {
this.#timers.forEach(clearInterval)
}
/**
* Adds the new domains of the Caddyfile and flags the removed ones.
*/
async sync() {
const caddySites = await caddyService.sites()
const seen = new Set<string>()
for (const caddySite of caddySites) {
for (const domain of caddySite.domains) {
if (seen.has(domain)) continue
seen.add(domain)
await Site.updateOrCreate(
{ domain },
{ active: true, kind: caddySite.kind, upstream: caddySite.upstream }
)
}
}
const query = Site.query().where('active', true)
if (seen.size) query.whereNotIn('domain', [...seen])
await query.update({ active: false })
}
async checkAll() {
if (this.#running) return
this.#running = true
try {
try {
await this.sync()
} catch (error) {
logger.warn({ err: error }, 'cannot read the Caddyfile')
}
const sites = await Site.query().where('active', true)
const queue = [...sites]
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
let site: Site | undefined
while ((site = queue.shift())) await this.check(site)
})
)
} finally {
this.#running = false
}
}
async check(site: Site) {
const result = await probe(site.domain)
const now = Math.floor(Date.now() / 1000)
await db.table('site_checks').insert({
site_id: site.id,
ts: now,
ok: result.ok,
status: result.status,
response_ms: result.responseMs,
error: result.error,
})
site.lastOk = result.ok
site.lastStatus = result.status
site.lastResponseMs = result.responseMs
site.lastError = result.error
site.lastCheckedAt = now
if (!site.certCheckedAt || now - site.certCheckedAt > CERT_CHECK_EVERY_S) {
site.certExpiresAt = await certificateExpiry(site.domain)
site.certCheckedAt = now
}
await site.save()
return site
}
/**
* Uptime (%) and average response time of every site over a period.
*/
async stats(sinceSeconds: number) {
const since = Math.floor(Date.now() / 1000) - sinceSeconds
const rows = await db
.from('site_checks')
.select('site_id')
.select(
db.raw('avg(ok) * 100 as uptime, avg(response_ms) as avgResponseMs, count(*) as checks')
)
.where('ts', '>=', since)
.groupBy('site_id')
return new Map<number, { uptime: number; avgResponseMs: number | null; checks: number }>(
rows.map((row) => [
row.site_id,
{
uptime: Number(row.uptime),
avgResponseMs: row.avgResponseMs,
checks: Number(row.checks),
},
])
)
}
/**
* Response times over a period (averaged per bucket) and the incidents
* (consecutive failed checks).
*/
async history(siteId: number, sinceSeconds: number, bucket: number) {
const since = Math.floor(Date.now() / 1000) - sinceSeconds
const points = await db
.from('site_checks')
.select(db.raw('(ts / ?) * ? as t', [bucket, bucket]))
.select(db.raw('avg(response_ms) as responseMs, min(ok) as ok, count(*) as checks'))
.where('site_id', siteId)
.where('ts', '>=', since)
.groupBy('t')
.orderBy('t')
const checks = await db
.from('site_checks')
.select('ts', 'ok', 'status', 'error')
.where('site_id', siteId)
.where('ts', '>=', since)
.orderBy('ts')
const incidents: { start: number; end: number | null; error: string | null; checks: number }[] =
[]
let open: (typeof incidents)[number] | null = null
for (const check of checks) {
if (!check.ok) {
if (!open) {
open = {
start: check.ts,
end: null,
error: check.error ?? `HTTP ${check.status}`,
checks: 0,
}
incidents.push(open)
}
open.checks++
} else if (open) {
open.end = check.ts
open = null
}
}
return {
points: points.map((point) => ({ ...point, ok: Boolean(point.ok) })),
incidents: incidents.reverse(),
}
}
async #prune() {
const limit = Math.floor(Date.now() / 1000) - RETENTION_SECONDS
await db
.from('site_checks')
.where('ts', '<', limit)
.delete()
.catch(() => {})
}
}
/**
* A site is "up" when it answers with a status below 500: an API without
* route on "/" answers 404 but works, while a stopped container gives 502.
*/
async function probe(domain: string): Promise<SiteCheckResult> {
const started = performance.now()
try {
const response = await fetch(`https://${domain}/`, {
redirect: 'manual',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
headers: { 'user-agent': 'big-brother-monitor' },
})
const responseMs = Math.round(performance.now() - started)
await response.body?.cancel()
return {
ok: response.status < 500,
status: response.status,
responseMs,
error: response.status < 500 ? null : `HTTP ${response.status}`,
}
} catch (error: any) {
return { ok: false, status: null, responseMs: null, error: describeFetchError(error) }
}
}
function describeFetchError(error: any): string {
if (error?.name === 'TimeoutError') return `Pas de réponse en ${REQUEST_TIMEOUT_MS / 1000} s`
const code = error?.cause?.code ?? error?.code
switch (code) {
case 'ENOTFOUND':
return 'Domaine introuvable (DNS)'
case 'ECONNREFUSED':
return 'Connexion refusée'
case 'ECONNRESET':
return 'Connexion interrompue'
case 'CERT_HAS_EXPIRED':
return 'Certificat HTTPS expiré'
case 'ERR_TLS_CERT_ALTNAME_INVALID':
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
return 'Certificat HTTPS invalide'
case 'UND_ERR_CONNECT_TIMEOUT':
return 'Délai de connexion dépassé'
default:
return error?.cause?.message ?? error?.message ?? 'Erreur inconnue'
}
}
/**
* Expiry date (unix seconds) of the certificate served for the domain.
*/
function certificateExpiry(domain: string): Promise<number | null> {
return new Promise((resolve) => {
const socket = tls.connect({
host: domain,
port: 443,
servername: domain,
rejectUnauthorized: false,
timeout: REQUEST_TIMEOUT_MS,
})
const done = (value: number | null) => {
socket.destroy()
resolve(value)
}
socket.once('secureConnect', () => {
const validTo = socket.getPeerCertificate()?.valid_to
done(validTo ? Math.floor(Date.parse(validTo) / 1000) : null)
})
socket.once('timeout', () => done(null))
socket.once('error', () => done(null))
})
}
const siteMonitor = new SiteMonitor()
export default siteMonitor
+65
View File
@@ -0,0 +1,65 @@
import { PassThrough } from 'node:stream'
import type { HttpContext } from '@adonisjs/core/http'
export interface SseChannel {
/**
* Sends one event to the browser. Ignored once the connection is closed.
*/
send(event: string, data: unknown): void
/**
* Registers a callback invoked once, when the browser disconnects or the
* channel is closed by the server.
*/
onClose(callback: () => void): void
close(): void
}
/**
* Turns the response into a Server-Sent Events stream (used with
* EventSource on the Angular side for the live metrics, logs and jobs).
*/
export function openSse({ response }: HttpContext): SseChannel {
const stream = new PassThrough()
const closeCallbacks: (() => void)[] = []
let closed = false
response.header('Content-Type', 'text/event-stream; charset=utf-8')
response.header('Cache-Control', 'no-cache, no-transform')
response.header('Connection', 'keep-alive')
response.header('X-Accel-Buffering', 'no')
response.stream(stream)
/**
* Comments keep the connection open through Caddy and let the browser
* know quickly that the stream is established.
*/
stream.write('retry: 5000\n\n')
const heartbeat = setInterval(() => stream.write(': ping\n\n'), 25_000)
const close = () => {
if (closed) return
closed = true
clearInterval(heartbeat)
for (const callback of closeCallbacks) callback()
stream.end()
}
/**
* "close" on the response (not the request) is emitted when the browser
* goes away.
*/
response.response.on('close', close)
return {
send(event, data) {
if (closed) return
stream.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
},
onClose(callback) {
closeCallbacks.push(callback)
},
close,
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Templates of the deployment procedure (procédure.md), used when a
* project is created from the dashboard.
*/
export type ProjectType = 'node' | 'adonis' | 'wordpress' | 'static'
const LOGGING = ` logging:
options:
max-size: "10m"
max-file: "3"`
const NETWORKS = `networks:
bdd_network:
external: true
caddy_network:
external: true`
function nodeService(service: string, name: string, port: number, command: string) {
return `services:
${service}:
image: node:22-alpine3.21
container_name: ${name}
working_dir: /app
volumes:
- ./project:/app
command: sh -c "${command.replace(/"/g, '\\"')}"
env_file: ./project/.env
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=${port}
networks:
- bdd_network
- caddy_network
restart: unless-stopped
${LOGGING}
${NETWORKS}
`
}
export const DEFAULT_NODE_COMMAND = 'npm ci --omit=dev && node start.js'
export const ADONIS_COMMAND =
'npm ci --include=dev && node ace build && cd build && npm ci --omit=dev && node bin/server.js'
export function composeFile(
type: Exclude<ProjectType, 'static'>,
name: string,
port: number,
command?: string
) {
switch (type) {
case 'node':
return nodeService('nodejs', name, port, command || DEFAULT_NODE_COMMAND)
case 'adonis':
return nodeService('adonis', name, port, ADONIS_COMMAND)
case 'wordpress':
return `services:
wordpress:
image: wordpress:php8.5-apache
container_name: ${name}
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: mariadb:3306
WORDPRESS_DB_NAME: \${WP_DB_NAME}
WORDPRESS_DB_USER: \${WP_DB_USER}
WORDPRESS_DB_PASSWORD: \${WP_DB_PASSWORD}
volumes:
- ./wp_data:/var/www/html
networks:
- caddy_network
- bdd_network
${LOGGING}
${NETWORKS}
`
}
}
export function proxyBlock(domain: string, upstream: string) {
return `${domain} {
\treverse_proxy ${upstream}
}`
}
export function staticBlock(domain: string, root: string, spa: boolean) {
return `${domain} {
\troot * ${root}
\tencode gzip
\tfile_server${spa ? '\n\ttry_files {path} /index.html' : ''}
}`
}
export function placeholderPage(name: string) {
return `<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"><title>${name}</title></head>
<body style="font-family: system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; margin: 0">
<p>${name} : site en cours de déploiement.</p>
</body>
</html>
`
}
+26
View File
@@ -0,0 +1,26 @@
import QRCode from 'qrcode'
import type { BaseCommand } from '@adonisjs/core/ace'
import totpService from '#services/totp_service'
/**
* Shows the QR code of a new TOTP secret, then asks for a code to make
* sure the authenticator app is set up. Returns the encrypted secret.
*/
export async function enrollTotp(command: BaseCommand, username: string): Promise<string | null> {
const totp = totpService.generate(username)
command.logger.info(
'Scanne ce QR code avec ton application de double authentification (Aegis, 2FAS, Google Authenticator…) :'
)
console.log(await QRCode.toString(totp.uri, { type: 'terminal', small: true }))
command.logger.info(`Ou saisis la clé à la main : ${totp.base32}`)
for (let attempt = 1; attempt <= 3; attempt++) {
const code = await command.prompt.ask('Code à 6 chiffres affiché par l’application')
if (totpService.check(totp.encrypted, username, code.trim()) !== null) return totp.encrypted
command.logger.warning('Code incorrect, réessaie (vérifie l’heure du téléphone).')
}
command.logger.error('Trop d’essais : rien n’a été enregistré.')
return null
}
+56
View File
@@ -0,0 +1,56 @@
import { Secret, TOTP } from 'otpauth'
import encryption from '@adonisjs/core/services/encryption'
import type User from '#models/user'
const ISSUER = 'Big Brother'
const PERIOD = 30
function build(secret: Secret, username: string) {
return new TOTP({ issuer: ISSUER, label: username, secret, period: PERIOD, digits: 6 })
}
/**
* Two-factor authentication with a 6-digit code (Google Authenticator,
* Aegis, 2FAS…). The secret is stored encrypted with the APP_KEY.
*/
const totpService = {
/**
* New secret, its encrypted form for the database and the otpauth://
* URI shown as a QR code.
*/
generate(username: string) {
const secret = new Secret({ size: 20 })
return {
encrypted: encryption.encrypt(secret.base32),
base32: secret.base32,
uri: build(secret, username).toString(),
}
},
/**
* Checks a code against the given encrypted secret, accepting the
* previous and next periods (clock drift). Returns the time step used.
*/
check(encryptedSecret: string, username: string, code: string): number | null {
const base32 = encryption.decrypt<string>(encryptedSecret)
if (!base32 || !/^\d{6}$/.test(code)) return null
const totp = build(Secret.fromBase32(base32), username)
const delta = totp.validate({ token: code, window: 1 })
if (delta === null) return null
return Math.floor(Date.now() / 1000 / PERIOD) + delta
},
/**
* Checks the code of a user and refuses a code already used (replay).
*/
async verify(user: User, code: string) {
const step = this.check(user.totpSecret, user.username, code)
if (step === null) return false
if (user.totpLastStep !== null && step <= user.totpLastStep) return false
user.totpLastStep = step
await user.save()
return true
},
}
export default totpService
+10
View File
@@ -0,0 +1,10 @@
import vine from '@vinejs/vine'
export const loginValidator = vine.create({
username: vine.string().trim().maxLength(64),
password: vine.string().maxLength(256),
code: vine
.string()
.trim()
.regex(/^\d{6}$/),
})
+6
View File
@@ -0,0 +1,6 @@
import vine from '@vinejs/vine'
export const updateCaddyfileValidator = vine.create({
content: vine.string().minLength(1).maxLength(500_000),
reason: vine.string().trim().maxLength(200).optional(),
})
+37
View File
@@ -0,0 +1,37 @@
import vine from '@vinejs/vine'
const NAME = /^[a-z0-9][a-z0-9-]{0,40}$/
const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/
const GIT_URL = /^(https?:\/\/|ssh:\/\/|git@)[^\s]+$/
export const createProjectValidator = vine.create({
type: vine.enum(['node', 'adonis', 'wordpress', 'static'] as const),
name: vine.string().trim().toLowerCase().regex(NAME),
category: vine.enum(['api', 'frontend', 'fullstack', 'wordpress'] as const),
gitUrl: vine
.string()
.trim()
.regex(GIT_URL)
.optional()
.requiredWhen('type', 'in', ['node', 'adonis']),
port: vine
.number()
.withoutDecimals()
.range([1, 65535])
.optional()
.requiredWhen('type', 'in', ['node', 'adonis']),
command: vine.string().trim().maxLength(500).optional(),
env: vine.string().maxLength(20_000).optional(),
createDatabase: vine.boolean(),
domain: vine.string().trim().toLowerCase().regex(DOMAIN),
addCaddy: vine.boolean(),
spa: vine.boolean(),
})
export const projectActionValidator = vine.create({
commit: vine
.string()
.trim()
.regex(/^[0-9a-f]{7,40}$/)
.optional(),
})