first commit

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

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
import vine from '@vinejs/vine'
import db from '@adonisjs/lucid/services/db'
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog from '#models/audit_log'
import UserError from '#exceptions/user_error'
const indexValidator = vine.create({
page: vine.number().withoutDecimals().min(1).optional(),
status: vine.enum(['running', 'success', 'error'] as const).optional(),
})
export default class AuditController {
async index({ request }: HttpContext) {
const { page = 1, status } = await indexValidator.validate(request.qs())
const query = AuditLog.query()
.select('id', 'username', 'ip', 'action', 'target', 'status', 'created_at', 'finished_at')
.select(db.raw('length(output) > 0 as hasOutput'))
.orderBy('id', 'desc')
if (status) query.where('status', status)
const result = await query.paginate(page, 50)
return {
meta: { total: result.total, page: result.currentPage, lastPage: result.lastPage },
data: result.all().map((entry) => ({
id: entry.id,
username: entry.username,
ip: entry.ip,
action: entry.action,
target: entry.target,
status: entry.status,
createdAt: entry.createdAt.toISO(),
finishedAt: entry.finishedAt?.toISO() ?? null,
hasOutput: Boolean(entry.$extras.hasOutput),
})),
}
}
async show({ params }: HttpContext) {
const entry = await AuditLog.find(params.id)
if (!entry) throw new UserError('Entrée introuvable.', 404)
return {
id: entry.id,
action: entry.action,
target: entry.target,
status: entry.status,
output: entry.output,
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import User from '#models/user'
import limiter from '@adonisjs/limiter/services/main'
import type { HttpContext } from '@adonisjs/core/http'
import totpService from '#services/totp_service'
import { audit } from '#services/audit'
import { loginValidator } from '#validators/auth'
class InvalidLogin extends Error {}
/**
* 5 failed attempts per IP, then blocked for 15 minutes.
*/
const loginLimiter = () =>
limiter.use({ requests: 5, duration: '15 mins', blockDuration: '15 mins' })
export default class AuthController {
async login(ctx: HttpContext) {
const { request, response, auth } = ctx
const { username, password, code } = await request.validateUsing(loginValidator)
const penalize = () =>
loginLimiter().penalize(`login_${request.ip()}`, async (): Promise<User> => {
const user = await User.verifyCredentials(username, password).catch(() => null)
if (!user || !(await totpService.verify(user, code))) throw new InvalidLogin()
return user
})
let result: Awaited<ReturnType<typeof penalize>>
try {
result = await penalize()
} catch (error) {
if (!(error instanceof InvalidLogin)) throw error
await audit(ctx, { action: 'Connexion refusée', target: username, status: 'error' })
return response.badRequest({ message: 'Identifiant, mot de passe ou code incorrect.' })
}
const [throttled, user] = result
if (throttled) {
const minutes = Math.max(1, Math.ceil(throttled.response.availableIn / 60))
return response.tooManyRequests({
message: `Trop de tentatives. Réessaie dans ${minutes} min.`,
})
}
await auth.use('web').login(user!)
await audit(ctx, { action: 'Connexion', username: user!.username, status: 'success' })
return { username: user!.username }
}
async logout(ctx: HttpContext) {
await audit(ctx, { action: 'Déconnexion', status: 'success' })
await ctx.auth.use('web').logout()
return { ok: true }
}
async me({ auth }: HttpContext) {
return { username: auth.getUserOrFail().username }
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import backupService from '#services/backup_service'
import { actor, audit } from '#services/audit'
export default class BackupsController {
async index() {
return {
dir: vpsConfig.backups.dir,
schedule: backupService.schedule,
files: await backupService.list(),
}
}
async store(ctx: HttpContext) {
const job = await backupService.run(actor(ctx))
return { jobId: job.id }
}
async download(ctx: HttpContext) {
const path = backupService.path(ctx.params.name)
await audit(ctx, {
action: 'Téléchargement de sauvegarde',
target: ctx.params.name,
status: 'success',
})
return ctx.response.attachment(path, ctx.params.name)
}
async destroy(ctx: HttpContext) {
await backupService.delete(ctx.params.name)
await audit(ctx, {
action: 'Suppression de sauvegarde',
target: ctx.params.name,
status: 'success',
})
return { ok: true }
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import CaddyfileVersion from '#models/caddyfile_version'
import UserError from '#exceptions/user_error'
import caddyService, { parseCaddyfile, type ReloadResult } from '#services/caddy_service'
import siteMonitor from '#services/site_monitor'
import { actor, audit } from '#services/audit'
import { updateCaddyfileValidator } from '#validators/caddy'
export default class CaddyController {
async show() {
const content = await caddyService.read()
return {
path: vpsConfig.caddy.caddyfilePath,
container: vpsConfig.caddy.container,
content,
sites: parseCaddyfile(content),
}
}
async update(ctx: HttpContext) {
const { content, reason } = await ctx.request.validateUsing(updateCaddyfileValidator)
const result = await caddyService.saveAndReload(content, {
username: actor(ctx).username,
reason: reason || 'Modification manuelle',
})
return this.#respond(ctx, 'Modification du Caddyfile', result)
}
async reload(ctx: HttpContext) {
const result = await caddyService.reload()
return this.#respond(ctx, 'Rechargement de Caddy', result)
}
async versions() {
const versions = await CaddyfileVersion.query()
.select('id', 'username', 'reason', 'created_at')
.orderBy('id', 'desc')
return versions.map((version) => ({
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
}))
}
async version({ params }: HttpContext) {
const version = await CaddyfileVersion.find(params.id)
if (!version) throw new UserError('Version introuvable.', 404)
return {
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
content: version.content,
}
}
async restore(ctx: HttpContext) {
const version = await CaddyfileVersion.find(ctx.params.id)
if (!version) throw new UserError('Version introuvable.', 404)
const result = await caddyService.saveAndReload(version.content, {
username: actor(ctx).username,
reason: `Restauration de la version #${version.id}`,
})
return this.#respond(ctx, `Restauration du Caddyfile (#${version.id})`, result)
}
async #respond(ctx: HttpContext, action: string, result: ReloadResult) {
await audit(ctx, {
action,
target: vpsConfig.caddy.caddyfilePath,
status: result.ok ? 'success' : 'error',
output: result.output,
})
if (!result.ok) {
return ctx.response.unprocessableEntity({
message: 'Caddy a refusé la configuration : le Caddyfile est inchangé.',
output: result.output,
})
}
/**
* New or removed domains show up right away on the sites page.
*/
siteMonitor.sync().catch(() => {})
return { ok: true, output: result.output }
}
}
@@ -0,0 +1,73 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import dockerService from '#services/docker_service'
import { audit } from '#services/audit'
import { openSse } from '#services/sse'
const ACTION_LABELS = {
start: 'Démarrage du conteneur',
stop: 'Arrêt du conteneur',
restart: 'Redémarrage du conteneur',
} as const
const actionValidator = vine.create({
action: vine.enum(['start', 'stop', 'restart'] as const),
})
const logsValidator = vine.create({
tail: vine.number().withoutDecimals().range([0, 5000]).optional(),
})
export default class ContainersController {
async index() {
return dockerService.list()
}
async show({ params }: HttpContext) {
return dockerService.inspect(params.id)
}
async action(ctx: HttpContext) {
const { action } = await actionValidator.validate(ctx.params)
const info = await dockerService.inspect(ctx.params.id)
try {
await dockerService.action(info.id, action)
} catch (error) {
await audit(ctx, {
action: ACTION_LABELS[action],
target: info.name,
status: 'error',
output: error instanceof Error ? error.message : String(error),
})
throw error
}
await audit(ctx, { action: ACTION_LABELS[action], target: info.name, status: 'success' })
return dockerService.inspect(info.id)
}
/**
* Follows the logs of the container (Server-Sent Events).
*/
async logs(ctx: HttpContext) {
const { tail } = await logsValidator.validate(ctx.request.qs())
const channel = openSse(ctx)
try {
const stop = await dockerService.followLogs(
ctx.params.id,
{ tail: tail ?? 300 },
(line) => channel.send('line', line),
() => {
channel.send('end', {})
channel.close()
}
)
channel.onClose(stop)
} catch (error) {
channel.send('failure', { message: error instanceof Error ? error.message : String(error) })
channel.close()
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog from '#models/audit_log'
import UserError from '#exceptions/user_error'
import jobRunner, { type JobLine } from '#services/job_runner'
import { openSse } from '#services/sse'
export default class JobsController {
/**
* Replays the output of a job, then follows it until the end. Jobs
* finished a while ago are read from the action log.
*/
async stream(ctx: HttpContext) {
const id = Number(ctx.params.id)
const job = jobRunner.get(id)
const entry = job ? null : await AuditLog.find(id)
if (!job && !entry) throw new UserError('Action introuvable.', 404)
const channel = openSse(ctx)
if (!job) {
const lines = (entry!.output ?? '')
.split('\n')
.map((text) => ({ ts: 0, stream: 'out', text }))
channel.send('meta', { id, action: entry!.action, target: entry!.target })
channel.send('lines', lines)
channel.send('end', { status: entry!.status })
channel.close()
return
}
channel.send('meta', { id, action: job.meta.action, target: job.meta.target })
channel.send('lines', job.lines)
if (job.status !== 'running') {
channel.send('end', { status: job.status })
channel.close()
return
}
const onLine = (line: JobLine) => channel.send('lines', [line])
const onEnd = (status: string) => {
channel.send('end', { status })
channel.close()
}
job.on('line', onLine)
job.once('end', onEnd)
channel.onClose(() => {
job.off('line', onLine)
job.off('end', onEnd)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import metricsService, { type HostSample } from '#services/metrics_service'
import { openSse } from '#services/sse'
const historyValidator = vine.create({
range: vine.enum(['1h', '24h', '7d', '30d'] as const),
})
export default class MetricsController {
/**
* Live samples (every 5 s) for the charts of the overview.
*/
async live(ctx: HttpContext) {
const channel = openSse(ctx)
channel.send('init', metricsService.live)
const onSample = (sample: HostSample) => channel.send('sample', sample)
metricsService.on('sample', onSample)
channel.onClose(() => metricsService.off('sample', onSample))
}
async history({ request }: HttpContext) {
const { range } = await historyValidator.validate(request.qs())
return metricsService.history(range)
}
}
@@ -0,0 +1,71 @@
import os from 'node:os'
import Site from '#models/site'
import AuditLog from '#models/audit_log'
import backupService from '#services/backup_service'
import dockerService from '#services/docker_service'
import metricsService from '#services/metrics_service'
const CERT_WARNING_DAYS = 14
export default class OverviewController {
async index() {
const now = Math.floor(Date.now() / 1000)
const [containers, sites, backups, recent, uptime] = await Promise.all([
dockerService.list().catch((error: Error) => error),
Site.query().where('active', true).orderBy('domain'),
backupService.list(),
AuditLog.query().orderBy('id', 'desc').limit(8),
metricsService.uptime(),
])
const docker =
containers instanceof Error
? { ok: false as const, error: containers.message }
: {
ok: true as const,
total: containers.length,
running: containers.filter((c) => c.state === 'running').length,
unhealthy: containers.filter((c) => c.health === 'unhealthy').map((c) => c.name),
stopped: containers.filter((c) => c.state !== 'running').map((c) => c.name),
}
return {
host: {
latest: metricsService.latest,
uptime,
cpus: os.cpus().length,
},
docker,
sites: {
total: sites.length,
up: sites.filter((site) => site.lastOk === true).length,
down: sites
.filter((site) => site.lastOk === false)
.map((site) => ({ id: site.id, domain: site.domain, error: site.lastError })),
certSoon: sites
.filter(
(site) => site.certExpiresAt && site.certExpiresAt - now < CERT_WARNING_DAYS * 86_400
)
.map((site) => ({
id: site.id,
domain: site.domain,
days: Math.floor((site.certExpiresAt! - now) / 86_400),
})),
},
backups: {
last: backups[0] ?? null,
count: backups.length,
schedule: backupService.schedule,
},
recent: recent.map((entry) => ({
id: entry.id,
action: entry.action,
target: entry.target,
status: entry.status,
username: entry.username,
createdAt: entry.createdAt.toISO(),
})),
}
}
}
@@ -0,0 +1,51 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import projectService, { PROJECT_ACTIONS, type ProjectAction } from '#services/project_service'
import { DEFAULT_NODE_COMMAND } from '#services/templates'
import { actor } from '#services/audit'
import { createProjectValidator, projectActionValidator } from '#validators/project'
export default class ProjectsController {
async index() {
return projectService.list()
}
/**
* Values used by the "new project" form.
*/
async options() {
return {
baseDomain: vpsConfig.baseDomain,
categories: vpsConfig.projectCategories,
srvRoot: vpsConfig.srvRoot,
defaultNodeCommand: DEFAULT_NODE_COMMAND,
}
}
async show({ params }: HttpContext) {
const project = await projectService.find(params.category, params.name)
return projectService.details(project)
}
async fetch({ params }: HttpContext) {
const project = await projectService.find(params.category, params.name)
return projectService.fetch(project)
}
async action(ctx: HttpContext) {
const action = ctx.params.action as ProjectAction
if (!PROJECT_ACTIONS.includes(action)) throw new UserError('Action inconnue.', 404)
const { commit } = await ctx.request.validateUsing(projectActionValidator)
const project = await projectService.find(ctx.params.category, ctx.params.name)
const job = await projectService.runAction(project, action, { commit }, actor(ctx))
return { jobId: job.id }
}
async store(ctx: HttpContext) {
const input = await ctx.request.validateUsing(createProjectValidator)
const job = await projectService.create(input, actor(ctx))
return { jobId: job.id }
}
}
+77
View File
@@ -0,0 +1,77 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import Site from '#models/site'
import siteMonitor from '#services/site_monitor'
import UserError from '#exceptions/user_error'
const showValidator = vine.create({
range: vine.enum(['24h', '7d', '30d'] as const).optional(),
})
const RANGES = {
'24h': { seconds: 86_400, bucket: 300 },
'7d': { seconds: 7 * 86_400, bucket: 1800 },
'30d': { seconds: 30 * 86_400, bucket: 7200 },
} as const
export default class SitesController {
async index() {
const [sites, day, week] = await Promise.all([
Site.query().orderBy('active', 'desc').orderBy('domain'),
siteMonitor.stats(86_400),
siteMonitor.stats(7 * 86_400),
])
return sites.map((site) => ({
...serializeSite(site),
uptime24h: day.get(site.id)?.uptime ?? null,
uptime7d: week.get(site.id)?.uptime ?? null,
avgResponseMs24h: day.get(site.id)?.avgResponseMs ?? null,
}))
}
async show({ params, request }: HttpContext) {
const { range = '24h' } = await showValidator.validate(request.qs())
const site = await Site.find(params.id)
if (!site) throw new UserError('Site introuvable.', 404)
const { seconds, bucket } = RANGES[range]
const [history, stats] = await Promise.all([
siteMonitor.history(site.id, seconds, bucket),
siteMonitor.stats(seconds),
])
return {
site: serializeSite(site),
range,
bucket,
uptime: stats.get(site.id)?.uptime ?? null,
avgResponseMs: stats.get(site.id)?.avgResponseMs ?? null,
...history,
}
}
/**
* Runs the checks right away instead of waiting for the next minute.
*/
async check() {
await siteMonitor.checkAll()
return this.index()
}
}
function serializeSite(site: Site) {
return {
id: site.id,
domain: site.domain,
active: site.active,
kind: site.kind,
upstream: site.upstream,
lastOk: site.lastOk,
lastStatus: site.lastStatus,
lastResponseMs: site.lastResponseMs,
lastError: site.lastError,
lastCheckedAt: site.lastCheckedAt,
certExpiresAt: site.certExpiresAt,
}
}
+25
View File
@@ -0,0 +1,25 @@
import { existsSync } from 'node:fs'
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
/**
* Serves the Angular application for every URL that is neither a file
* of "public/" nor an API route (/containers, /projects/…).
*/
export default class SpaController {
async index({ request, response }: HttpContext) {
if (request.url().startsWith('/api/')) {
return response.notFound({ message: 'Route inconnue.' })
}
const index = app.publicPath('index.html')
if (!existsSync(index)) {
return response.notFound(
'Interface non compilée : lance "npm run build" dans web/ (ou "npm start" pour le développement).'
)
}
response.header('Cache-Control', 'no-cache')
return response.download(index)
}
}