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