first commit
This commit is contained in:
commit
3c0479faf0
169 files changed
+27617
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuditLogSchema } from '#database/schema'
|
||||
|
||||
export type AuditStatus = 'running' | 'success' | 'error'
|
||||
|
||||
export default class AuditLog extends AuditLogSchema {}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { CaddyfileVersionSchema } from '#database/schema'
|
||||
|
||||
export default class CaddyfileVersion extends CaddyfileVersionSchema {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
) {}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}$/),
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
Reference in new issue
Block a user