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

+17
View File
@@ -0,0 +1,17 @@
.git
**/node_modules
**/.DS_Store
web/dist
web/.angular
api/build
api/tmp
api/public
api/.env
api/.env.*
!api/.env.example
dev-srv
dev-backups
deploy
+6
View File
@@ -0,0 +1,6 @@
# Local backups created while testing (BACKUP_DIR=../dev-backups)
dev-backups/
.DS_Store
.vscode/
.idea/
+39
View File
@@ -0,0 +1,39 @@
# Big Brother: API AdonisJS + interface Angular dans une seule image.
# 1. Interface Angular
FROM node:24-alpine AS web
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npx ng build
# 2. API AdonisJS (compilée dans /api/build)
FROM node:24-alpine AS api
RUN apk add --no-cache python3 make g++
WORKDIR /api
COPY api/package.json api/package-lock.json ./
RUN npm ci
COPY api/ ./
RUN node ace build
# 3. Image finale
FROM node:24-alpine
# git (mises à jour des projets), client Docker + compose (actions sur les
# conteneurs), tar (sauvegardes), tzdata (fuseau horaire de TZ)
RUN apk add --no-cache git docker-cli docker-cli-compose tar tzdata \
&& git config --system --add safe.directory '*'
WORKDIR /app
COPY --from=api /api/build ./
RUN apk add --no-cache --virtual .build python3 make g++ \
&& npm ci --omit=dev \
&& apk del .build
COPY --from=web /web/dist/web/browser ./public
ENV NODE_ENV=production
EXPOSE 3333
# Les migrations SQLite passent à chaque démarrage (rien à faire si la base est à jour).
CMD ["sh", "-c", "node ace migration:run --force && node bin/server.js"]
+177
View File
@@ -0,0 +1,177 @@
# Big Brother
Tableau de bord web pour gérer et surveiller le VPS : serveur, conteneurs, sites, projets, Caddy et sauvegardes.
- **API** : AdonisJS 7 (`api/`), base SQLite.
- **Interface** : Angular 22 (`web/`), servie par l'API (un seul conteneur, un seul domaine).
> « Big Brother » est un nom provisoire. Pour le changer, il suffit de modifier `container_name` dans le `docker-compose.yml`, le bloc Caddy et `APP_URL`.
## Fonctionnalités
| Page | Contenu |
|---|---|
| Vue d'ensemble | Processeur, mémoire, disque, réseau, charge, durée depuis le démarrage. Graphiques en direct (toutes les 5 s) et historique sur 1 h, 24 h, 7 j et 30 j. État des conteneurs, des sites et des sauvegardes, dernières actions. |
| Conteneurs | Tous les conteneurs avec leur état, CPU et mémoire. Alerte quand un port est publié sur Internet (seul Caddy devrait l'être). Logs en direct (filtre, pause, téléchargement). Démarrer, arrêter, redémarrer. |
| Sites | Les domaines du Caddyfile, vérifiés chaque minute : état, temps de réponse, disponibilité sur 24 h et 7 j, expiration du certificat HTTPS, incidents. |
| Projets | Les projets de `/srv` (même organisation que la procédure). Pour chacun : dépôt git (version en ligne, nouveautés), mise à jour (`git pull` puis recréation des conteneurs), retour à un ancien commit, redémarrage, arrêt, mise à jour des images. **Création de projet** Node, Adonis, WordPress ou site statique : dossier, clone, `.env`, base MariaDB dédiée, `docker-compose.yml` (templates de la procédure), démarrage et bloc Caddy. |
| Caddy | Éditeur du Caddyfile. Si Caddy refuse la nouvelle configuration, l'ancien fichier est remis en place automatiquement. Historique des 50 dernières versions, restaurables. |
| Sauvegardes | Export de toutes les bases MariaDB et archive de `/srv` (sans `node_modules`). Sauvegarde automatique quotidienne en option, téléchargement depuis la page. |
| Journal | Toutes les actions et connexions, avec la sortie des commandes. |
## Sécurité
Le conteneur a accès au socket Docker et à `/srv` en écriture : **c'est l'équivalent d'un accès root au serveur**. Les protections en place :
- **Comptes** : aucune page d'inscription. Les comptes se créent uniquement en ligne de commande sur le VPS.
- **Connexion** :
- mot de passe d'au moins 12 caractères ;
- code de double authentification (TOTP), refusé s'il a déjà servi ;
- 5 essais ratés par IP, puis blocage pendant 15 minutes.
- **Session** : cookies `httpOnly`, `Secure` et `SameSite=Strict`, session de 12 h, protection CSRF.
- **En-têtes** : Content-Security-Policy stricte (aucun script externe ni en ligne), HSTS, `X-Frame-Options: DENY`.
- **Réseau** : aucun port publié, l'accès passe uniquement par Caddy. Le secret 2FA est chiffré avec `APP_KEY`.
- **Traçabilité** : chaque action est enregistrée dans le journal (qui, quand, depuis quelle IP, avec la sortie).
## Mise en ligne sur le VPS
Le projet suit la procédure : un dossier contenant `docker-compose.yml`, avec le dépôt cloné dans `project/`.
1. Créer le dépôt sur `git.nucleus-ludus.fr` et y pousser ce dossier.
2. Sur le VPS :
```bash
mkdir -p /srv/fullstack/big-brother /root/backups
cd /srv/fullstack/big-brother
git clone https://git.nucleus-ludus.fr/<compte>/big-brother.git project
cp project/deploy/docker-compose.yml .
cp project/deploy/.env.example .env
nano .env
```
Dans `.env`, remplir au minimum :
- `APP_KEY` : générer la valeur avec `openssl rand -base64 24` ;
- `APP_URL` ;
- `BACKUP_DAILY_AT`.
Vérifier aussi `CADDY_CONTAINER` et `MARIADB_CONTAINER` : ce sont les noms de tes conteneurs Caddy et MariaDB.
3. Construire et lancer :
```bash
docker compose up -d --build
docker compose logs -f
```
4. Créer ton compte. La commande affiche un QR code à scanner avec ton application de double authentification (Aegis, 2FAS, Google Authenticator…) :
```bash
docker exec -it big-brother node ace user:create
```
5. Ajouter le bloc à `/srv/caddy/Caddyfile` (voir `deploy/Caddyfile.block`), puis recharger Caddy :
```caddyfile
big-brother.nucleus-ludus.fr {
reverse_proxy big-brother:3333
}
```
```bash
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```
### Ce que monte le `docker-compose.yml`
| Volume | Rôle |
|---|---|
| `./data:/data` | Base SQLite (comptes, historique, journal) |
| `/var/run/docker.sock` | Conteneurs, logs, actions, rechargement de Caddy, MariaDB |
| `/srv:/srv` | Projets et Caddyfile, **au même chemin** que sur le VPS (nécessaire pour `docker compose`) |
| `/proc:/host/proc:ro` | Métriques du serveur (et pas seulement celles du conteneur) |
| `/root/backups:/backups` | Sauvegardes |
## Mettre à jour Big Brother
Depuis le tableau de bord : **Projets → big-brother → Mettre à jour**. La recréation du conteneur se fait dans un conteneur à part (`docker:cli`) pour ne pas être interrompue par l'arrêt du tableau de bord. La page se reconnecte d'elle-même.
À la main :
```bash
cd /srv/fullstack/big-brother
git -C project pull --ff-only
docker compose up -d --build
```
## Gestion des comptes
```bash
docker exec -it big-brother node ace user:create
docker exec -it big-brother node ace user:password <identifiant>
docker exec -it big-brother node ace user:reset-2fa <identifiant> # téléphone perdu
```
## Variables d'environnement
| Variable | Rôle |
|---|---|
| `APP_KEY` | Clé secrète (cookies, chiffrement des secrets 2FA). À ne jamais perdre ni partager. |
| `APP_URL` | URL publique du tableau de bord |
| `TZ` | Fuseau horaire (heure de la sauvegarde automatique) |
| `DB_PATH` | Fichier SQLite (`/data/db.sqlite3`) |
| `SRV_ROOT` | Racine des projets (`/srv`) |
| `HOST_PROC` | `/proc` du serveur monté dans le conteneur (`/host/proc`) |
| `DISK_PATH` | Chemin dont le disque est surveillé (`/srv`) |
| `BACKUP_DIR` | Dossier des sauvegardes dans le conteneur (`/backups`) |
| `DOCKER_SOCKET` | Socket Docker |
| `CADDY_CONTAINER` | Nom du conteneur Caddy |
| `CADDYFILE_PATH` | Caddyfile vu par le tableau de bord (`/srv/caddy/Caddyfile`) |
| `CADDY_CONFIG_PATH` | Caddyfile vu depuis le conteneur Caddy (`/etc/caddy/Caddyfile`) |
| `MARIADB_CONTAINER` | Nom du conteneur MariaDB (son mot de passe root est lu dans le conteneur, via `MARIADB_ROOT_PASSWORD`) |
| `BASE_DOMAIN` | Domaine proposé pour les nouveaux projets (`<nom>.<BASE_DOMAIN>`) |
| `BACKUP_DAILY_AT` | Heure de la sauvegarde automatique (`03:00`). Vide = pas de sauvegarde automatique. |
| `BACKUP_KEEP` | Nombre de sauvegardes gardées pour chaque type (7) |
## Développement local
Prérequis : Node 24. Docker Desktop est facultatif : sans lui, les pages Conteneurs et les actions affichent « Docker injoignable ».
```bash
# API (http://localhost:3333)
cd api
npm install
cp .env.example .env
```
Dans `api/.env`, adapter les valeurs pour le local :
- `NODE_ENV=development`, `HOST=localhost`, `APP_KEY` : une valeur quelconque ;
- `SRV_ROOT=../dev-srv` : faux `/srv` fourni pour le développement ;
- `CADDYFILE_PATH=../dev-srv/caddy/Caddyfile` ;
- `BACKUP_DIR=../dev-backups` ;
- supprimer les lignes `DB_PATH`, `HOST_PROC` et `DOCKER_SOCKET`.
Puis :
```bash
node ace migration:run
node ace user:create
npm run dev
```
```bash
# Interface (http://localhost:4200, les appels /api passent par le proxy vers 3333)
cd web
npm install
npm start
```
Pour tester l'interface compilée servie par l'API : `npm run build` dans `web/`, puis copier `web/dist/web/browser/` dans `api/public/`.
## Limites
- **Pas d'alerte** : il faut consulter le tableau de bord.
- **Vérifications depuis le VPS** : elles détectent un conteneur arrêté ou une erreur de configuration, pas une panne complète du serveur (le tableau de bord serait lui aussi hors ligne).
- **Sites statiques** : le build se fait sur le PC puis s'envoie avec `scp` (la page du projet affiche les commandes).
- **Création de projet** : en cas d'échec à une étape, les étapes déjà faites ne sont pas annulées. Le journal indique où ça s'est arrêté.
+10
View File
@@ -0,0 +1,10 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
/// <reference path="./manifest.d.ts" />
import type { InferData, InferVariants } from '@adonisjs/core/types/transformers'
export namespace Data {
}
+11
View File
@@ -0,0 +1,11 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/auth.ts" />
/// <reference path="../../config/hash.ts" />
/// <reference path="../../config/limiter.ts" />
/// <reference path="../../config/logger.ts" />
/// <reference path="../../config/vps.ts" />
+221
View File
@@ -0,0 +1,221 @@
/* eslint-disable prettier/prettier */
import type { AdonisEndpoint } from '@tuyau/core/types'
import type { Registry } from './schema.d.ts'
import type { ApiDefinition } from './tree.d.ts'
const placeholder: any = {}
const routes = {
'auth.login': {
methods: ["POST"],
pattern: '/api/auth/login',
tokens: [{"old":"/api/auth/login","type":0,"val":"api","end":""},{"old":"/api/auth/login","type":0,"val":"auth","end":""},{"old":"/api/auth/login","type":0,"val":"login","end":""}],
types: placeholder as Registry['auth.login']['types'],
},
'auth.logout': {
methods: ["POST"],
pattern: '/api/auth/logout',
tokens: [{"old":"/api/auth/logout","type":0,"val":"api","end":""},{"old":"/api/auth/logout","type":0,"val":"auth","end":""},{"old":"/api/auth/logout","type":0,"val":"logout","end":""}],
types: placeholder as Registry['auth.logout']['types'],
},
'auth.me': {
methods: ["GET","HEAD"],
pattern: '/api/auth/me',
tokens: [{"old":"/api/auth/me","type":0,"val":"api","end":""},{"old":"/api/auth/me","type":0,"val":"auth","end":""},{"old":"/api/auth/me","type":0,"val":"me","end":""}],
types: placeholder as Registry['auth.me']['types'],
},
'overview.index': {
methods: ["GET","HEAD"],
pattern: '/api/overview',
tokens: [{"old":"/api/overview","type":0,"val":"api","end":""},{"old":"/api/overview","type":0,"val":"overview","end":""}],
types: placeholder as Registry['overview.index']['types'],
},
'metrics.live': {
methods: ["GET","HEAD"],
pattern: '/api/metrics/live',
tokens: [{"old":"/api/metrics/live","type":0,"val":"api","end":""},{"old":"/api/metrics/live","type":0,"val":"metrics","end":""},{"old":"/api/metrics/live","type":0,"val":"live","end":""}],
types: placeholder as Registry['metrics.live']['types'],
},
'metrics.history': {
methods: ["GET","HEAD"],
pattern: '/api/metrics/history',
tokens: [{"old":"/api/metrics/history","type":0,"val":"api","end":""},{"old":"/api/metrics/history","type":0,"val":"metrics","end":""},{"old":"/api/metrics/history","type":0,"val":"history","end":""}],
types: placeholder as Registry['metrics.history']['types'],
},
'containers.index': {
methods: ["GET","HEAD"],
pattern: '/api/containers',
tokens: [{"old":"/api/containers","type":0,"val":"api","end":""},{"old":"/api/containers","type":0,"val":"containers","end":""}],
types: placeholder as Registry['containers.index']['types'],
},
'containers.show': {
methods: ["GET","HEAD"],
pattern: '/api/containers/:id',
tokens: [{"old":"/api/containers/:id","type":0,"val":"api","end":""},{"old":"/api/containers/:id","type":0,"val":"containers","end":""},{"old":"/api/containers/:id","type":1,"val":"id","end":""}],
types: placeholder as Registry['containers.show']['types'],
},
'containers.logs': {
methods: ["GET","HEAD"],
pattern: '/api/containers/:id/logs',
tokens: [{"old":"/api/containers/:id/logs","type":0,"val":"api","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/logs","type":1,"val":"id","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"logs","end":""}],
types: placeholder as Registry['containers.logs']['types'],
},
'containers.action': {
methods: ["POST"],
pattern: '/api/containers/:id/:action',
tokens: [{"old":"/api/containers/:id/:action","type":0,"val":"api","end":""},{"old":"/api/containers/:id/:action","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/:action","type":1,"val":"id","end":""},{"old":"/api/containers/:id/:action","type":1,"val":"action","end":""}],
types: placeholder as Registry['containers.action']['types'],
},
'sites.index': {
methods: ["GET","HEAD"],
pattern: '/api/sites',
tokens: [{"old":"/api/sites","type":0,"val":"api","end":""},{"old":"/api/sites","type":0,"val":"sites","end":""}],
types: placeholder as Registry['sites.index']['types'],
},
'sites.check': {
methods: ["POST"],
pattern: '/api/sites/check',
tokens: [{"old":"/api/sites/check","type":0,"val":"api","end":""},{"old":"/api/sites/check","type":0,"val":"sites","end":""},{"old":"/api/sites/check","type":0,"val":"check","end":""}],
types: placeholder as Registry['sites.check']['types'],
},
'sites.show': {
methods: ["GET","HEAD"],
pattern: '/api/sites/:id',
tokens: [{"old":"/api/sites/:id","type":0,"val":"api","end":""},{"old":"/api/sites/:id","type":0,"val":"sites","end":""},{"old":"/api/sites/:id","type":1,"val":"id","end":""}],
types: placeholder as Registry['sites.show']['types'],
},
'projects.index': {
methods: ["GET","HEAD"],
pattern: '/api/projects',
tokens: [{"old":"/api/projects","type":0,"val":"api","end":""},{"old":"/api/projects","type":0,"val":"projects","end":""}],
types: placeholder as Registry['projects.index']['types'],
},
'projects.store': {
methods: ["POST"],
pattern: '/api/projects',
tokens: [{"old":"/api/projects","type":0,"val":"api","end":""},{"old":"/api/projects","type":0,"val":"projects","end":""}],
types: placeholder as Registry['projects.store']['types'],
},
'projects.options': {
methods: ["GET","HEAD"],
pattern: '/api/projects/options',
tokens: [{"old":"/api/projects/options","type":0,"val":"api","end":""},{"old":"/api/projects/options","type":0,"val":"projects","end":""},{"old":"/api/projects/options","type":0,"val":"options","end":""}],
types: placeholder as Registry['projects.options']['types'],
},
'projects.show': {
methods: ["GET","HEAD"],
pattern: '/api/projects/:category/:name',
tokens: [{"old":"/api/projects/:category/:name","type":0,"val":"api","end":""},{"old":"/api/projects/:category/:name","type":0,"val":"projects","end":""},{"old":"/api/projects/:category/:name","type":1,"val":"category","end":""},{"old":"/api/projects/:category/:name","type":1,"val":"name","end":""}],
types: placeholder as Registry['projects.show']['types'],
},
'projects.fetch': {
methods: ["POST"],
pattern: '/api/projects/:category/:name/fetch',
tokens: [{"old":"/api/projects/:category/:name/fetch","type":0,"val":"api","end":""},{"old":"/api/projects/:category/:name/fetch","type":0,"val":"projects","end":""},{"old":"/api/projects/:category/:name/fetch","type":1,"val":"category","end":""},{"old":"/api/projects/:category/:name/fetch","type":1,"val":"name","end":""},{"old":"/api/projects/:category/:name/fetch","type":0,"val":"fetch","end":""}],
types: placeholder as Registry['projects.fetch']['types'],
},
'projects.action': {
methods: ["POST"],
pattern: '/api/projects/:category/:name/actions/:action',
tokens: [{"old":"/api/projects/:category/:name/actions/:action","type":0,"val":"api","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":0,"val":"projects","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":1,"val":"category","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":1,"val":"name","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":0,"val":"actions","end":""},{"old":"/api/projects/:category/:name/actions/:action","type":1,"val":"action","end":""}],
types: placeholder as Registry['projects.action']['types'],
},
'caddy.show': {
methods: ["GET","HEAD"],
pattern: '/api/caddy',
tokens: [{"old":"/api/caddy","type":0,"val":"api","end":""},{"old":"/api/caddy","type":0,"val":"caddy","end":""}],
types: placeholder as Registry['caddy.show']['types'],
},
'caddy.update': {
methods: ["PUT"],
pattern: '/api/caddy',
tokens: [{"old":"/api/caddy","type":0,"val":"api","end":""},{"old":"/api/caddy","type":0,"val":"caddy","end":""}],
types: placeholder as Registry['caddy.update']['types'],
},
'caddy.reload': {
methods: ["POST"],
pattern: '/api/caddy/reload',
tokens: [{"old":"/api/caddy/reload","type":0,"val":"api","end":""},{"old":"/api/caddy/reload","type":0,"val":"caddy","end":""},{"old":"/api/caddy/reload","type":0,"val":"reload","end":""}],
types: placeholder as Registry['caddy.reload']['types'],
},
'caddy.versions': {
methods: ["GET","HEAD"],
pattern: '/api/caddy/versions',
tokens: [{"old":"/api/caddy/versions","type":0,"val":"api","end":""},{"old":"/api/caddy/versions","type":0,"val":"caddy","end":""},{"old":"/api/caddy/versions","type":0,"val":"versions","end":""}],
types: placeholder as Registry['caddy.versions']['types'],
},
'caddy.version': {
methods: ["GET","HEAD"],
pattern: '/api/caddy/versions/:id',
tokens: [{"old":"/api/caddy/versions/:id","type":0,"val":"api","end":""},{"old":"/api/caddy/versions/:id","type":0,"val":"caddy","end":""},{"old":"/api/caddy/versions/:id","type":0,"val":"versions","end":""},{"old":"/api/caddy/versions/:id","type":1,"val":"id","end":""}],
types: placeholder as Registry['caddy.version']['types'],
},
'caddy.restore': {
methods: ["POST"],
pattern: '/api/caddy/versions/:id/restore',
tokens: [{"old":"/api/caddy/versions/:id/restore","type":0,"val":"api","end":""},{"old":"/api/caddy/versions/:id/restore","type":0,"val":"caddy","end":""},{"old":"/api/caddy/versions/:id/restore","type":0,"val":"versions","end":""},{"old":"/api/caddy/versions/:id/restore","type":1,"val":"id","end":""},{"old":"/api/caddy/versions/:id/restore","type":0,"val":"restore","end":""}],
types: placeholder as Registry['caddy.restore']['types'],
},
'backups.index': {
methods: ["GET","HEAD"],
pattern: '/api/backups',
tokens: [{"old":"/api/backups","type":0,"val":"api","end":""},{"old":"/api/backups","type":0,"val":"backups","end":""}],
types: placeholder as Registry['backups.index']['types'],
},
'backups.store': {
methods: ["POST"],
pattern: '/api/backups',
tokens: [{"old":"/api/backups","type":0,"val":"api","end":""},{"old":"/api/backups","type":0,"val":"backups","end":""}],
types: placeholder as Registry['backups.store']['types'],
},
'backups.download': {
methods: ["GET","HEAD"],
pattern: '/api/backups/:name',
tokens: [{"old":"/api/backups/:name","type":0,"val":"api","end":""},{"old":"/api/backups/:name","type":0,"val":"backups","end":""},{"old":"/api/backups/:name","type":1,"val":"name","end":""}],
types: placeholder as Registry['backups.download']['types'],
},
'backups.destroy': {
methods: ["DELETE"],
pattern: '/api/backups/:name',
tokens: [{"old":"/api/backups/:name","type":0,"val":"api","end":""},{"old":"/api/backups/:name","type":0,"val":"backups","end":""},{"old":"/api/backups/:name","type":1,"val":"name","end":""}],
types: placeholder as Registry['backups.destroy']['types'],
},
'jobs.stream': {
methods: ["GET","HEAD"],
pattern: '/api/jobs/:id/stream',
tokens: [{"old":"/api/jobs/:id/stream","type":0,"val":"api","end":""},{"old":"/api/jobs/:id/stream","type":0,"val":"jobs","end":""},{"old":"/api/jobs/:id/stream","type":1,"val":"id","end":""},{"old":"/api/jobs/:id/stream","type":0,"val":"stream","end":""}],
types: placeholder as Registry['jobs.stream']['types'],
},
'audit.index': {
methods: ["GET","HEAD"],
pattern: '/api/audit',
tokens: [{"old":"/api/audit","type":0,"val":"api","end":""},{"old":"/api/audit","type":0,"val":"audit","end":""}],
types: placeholder as Registry['audit.index']['types'],
},
'audit.show': {
methods: ["GET","HEAD"],
pattern: '/api/audit/:id',
tokens: [{"old":"/api/audit/:id","type":0,"val":"api","end":""},{"old":"/api/audit/:id","type":0,"val":"audit","end":""},{"old":"/api/audit/:id","type":1,"val":"id","end":""}],
types: placeholder as Registry['audit.show']['types'],
},
'spa.index': {
methods: ["GET","HEAD"],
pattern: '/*',
tokens: [{"old":"/*","type":2,"val":"*","end":""}],
types: placeholder as Registry['spa.index']['types'],
},
} as const satisfies Record<string, AdonisEndpoint>
export { routes }
export const registry = {
routes,
$tree: {} as ApiDefinition,
}
declare module '@tuyau/core/types' {
export interface UserRegistry {
routes: typeof routes
$tree: ApiDefinition
}
}
+406
View File
@@ -0,0 +1,406 @@
/* eslint-disable prettier/prettier */
/// <reference path="../manifest.d.ts" />
import type { ExtractBody, ExtractErrorResponse, ExtractQuery, ExtractQueryForGet, ExtractResponse } from '@tuyau/core/types'
import type { InferInput, SimpleError } from '@vinejs/vine/types'
export type ParamValue = string | number | bigint | boolean
export interface Registry {
'auth.login': {
methods: ["POST"]
pattern: '/api/auth/login'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['login']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['login']>>>
}
}
'auth.logout': {
methods: ["POST"]
pattern: '/api/auth/logout'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['logout']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['logout']>>>
}
}
'auth.me': {
methods: ["GET","HEAD"]
pattern: '/api/auth/me'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['me']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/auth_controller').default['me']>>>
}
}
'overview.index': {
methods: ["GET","HEAD"]
pattern: '/api/overview'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/overview_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/overview_controller').default['index']>>>
}
}
'metrics.live': {
methods: ["GET","HEAD"]
pattern: '/api/metrics/live'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/metrics_controller').default['live']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/metrics_controller').default['live']>>>
}
}
'metrics.history': {
methods: ["GET","HEAD"]
pattern: '/api/metrics/history'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/metrics_controller').default['history']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/metrics_controller').default['history']>>>
}
}
'containers.index': {
methods: ["GET","HEAD"]
pattern: '/api/containers'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['index']>>>
}
}
'containers.show': {
methods: ["GET","HEAD"]
pattern: '/api/containers/:id'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['show']>>>
}
}
'containers.logs': {
methods: ["GET","HEAD"]
pattern: '/api/containers/:id/logs'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['logs']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['logs']>>>
}
}
'containers.action': {
methods: ["POST"]
pattern: '/api/containers/:id/:action'
types: {
body: {}
paramsTuple: [ParamValue, ParamValue]
params: { id: ParamValue; action: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['action']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['action']>>>
}
}
'sites.index': {
methods: ["GET","HEAD"]
pattern: '/api/sites'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/sites_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/sites_controller').default['index']>>>
}
}
'sites.check': {
methods: ["POST"]
pattern: '/api/sites/check'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/sites_controller').default['check']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/sites_controller').default['check']>>>
}
}
'sites.show': {
methods: ["GET","HEAD"]
pattern: '/api/sites/:id'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/sites_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/sites_controller').default['show']>>>
}
}
'projects.index': {
methods: ["GET","HEAD"]
pattern: '/api/projects'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['index']>>>
}
}
'projects.store': {
methods: ["POST"]
pattern: '/api/projects'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/project').createProjectValidator)>>
paramsTuple: []
params: {}
query: ExtractQuery<InferInput<(typeof import('#validators/project').createProjectValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'projects.options': {
methods: ["GET","HEAD"]
pattern: '/api/projects/options'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['options']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['options']>>>
}
}
'projects.show': {
methods: ["GET","HEAD"]
pattern: '/api/projects/:category/:name'
types: {
body: {}
paramsTuple: [ParamValue, ParamValue]
params: { category: ParamValue; name: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['show']>>>
}
}
'projects.fetch': {
methods: ["POST"]
pattern: '/api/projects/:category/:name/fetch'
types: {
body: {}
paramsTuple: [ParamValue, ParamValue]
params: { category: ParamValue; name: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['fetch']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['fetch']>>>
}
}
'projects.action': {
methods: ["POST"]
pattern: '/api/projects/:category/:name/actions/:action'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/project').projectActionValidator)>>
paramsTuple: [ParamValue, ParamValue, ParamValue]
params: { category: ParamValue; name: ParamValue; action: ParamValue }
query: ExtractQuery<InferInput<(typeof import('#validators/project').projectActionValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['action']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/projects_controller').default['action']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'caddy.show': {
methods: ["GET","HEAD"]
pattern: '/api/caddy'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['show']>>>
}
}
'caddy.update': {
methods: ["PUT"]
pattern: '/api/caddy'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/caddy').updateCaddyfileValidator)>>
paramsTuple: []
params: {}
query: ExtractQuery<InferInput<(typeof import('#validators/caddy').updateCaddyfileValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['update']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['update']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'caddy.reload': {
methods: ["POST"]
pattern: '/api/caddy/reload'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['reload']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['reload']>>>
}
}
'caddy.versions': {
methods: ["GET","HEAD"]
pattern: '/api/caddy/versions'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['versions']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['versions']>>>
}
}
'caddy.version': {
methods: ["GET","HEAD"]
pattern: '/api/caddy/versions/:id'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['version']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['version']>>>
}
}
'caddy.restore': {
methods: ["POST"]
pattern: '/api/caddy/versions/:id/restore'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['restore']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/caddy_controller').default['restore']>>>
}
}
'backups.index': {
methods: ["GET","HEAD"]
pattern: '/api/backups'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['index']>>>
}
}
'backups.store': {
methods: ["POST"]
pattern: '/api/backups'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['store']>>>
}
}
'backups.download': {
methods: ["GET","HEAD"]
pattern: '/api/backups/:name'
types: {
body: {}
paramsTuple: [ParamValue]
params: { name: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['download']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['download']>>>
}
}
'backups.destroy': {
methods: ["DELETE"]
pattern: '/api/backups/:name'
types: {
body: {}
paramsTuple: [ParamValue]
params: { name: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['destroy']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/backups_controller').default['destroy']>>>
}
}
'jobs.stream': {
methods: ["GET","HEAD"]
pattern: '/api/jobs/:id/stream'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/jobs_controller').default['stream']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/jobs_controller').default['stream']>>>
}
}
'audit.index': {
methods: ["GET","HEAD"]
pattern: '/api/audit'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/audit_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/audit_controller').default['index']>>>
}
}
'audit.show': {
methods: ["GET","HEAD"]
pattern: '/api/audit/:id'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/audit_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/audit_controller').default['show']>>>
}
}
'spa.index': {
methods: ["GET","HEAD"]
pattern: '/*'
types: {
body: {}
paramsTuple: [ParamValue]
params: { '*': ParamValue[] }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/spa_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/spa_controller').default['index']>>>
}
}
}
+60
View File
@@ -0,0 +1,60 @@
/* eslint-disable prettier/prettier */
import type { routes } from './index.ts'
export interface ApiDefinition {
auth: {
login: typeof routes['auth.login']
logout: typeof routes['auth.logout']
me: typeof routes['auth.me']
}
overview: {
index: typeof routes['overview.index']
}
metrics: {
live: typeof routes['metrics.live']
history: typeof routes['metrics.history']
}
containers: {
index: typeof routes['containers.index']
show: typeof routes['containers.show']
logs: typeof routes['containers.logs']
action: typeof routes['containers.action']
}
sites: {
index: typeof routes['sites.index']
check: typeof routes['sites.check']
show: typeof routes['sites.show']
}
projects: {
index: typeof routes['projects.index']
store: typeof routes['projects.store']
options: typeof routes['projects.options']
show: typeof routes['projects.show']
fetch: typeof routes['projects.fetch']
action: typeof routes['projects.action']
}
caddy: {
show: typeof routes['caddy.show']
update: typeof routes['caddy.update']
reload: typeof routes['caddy.reload']
versions: typeof routes['caddy.versions']
version: typeof routes['caddy.version']
restore: typeof routes['caddy.restore']
}
backups: {
index: typeof routes['backups.index']
store: typeof routes['backups.store']
download: typeof routes['backups.download']
destroy: typeof routes['backups.destroy']
}
jobs: {
stream: typeof routes['jobs.stream']
}
audit: {
index: typeof routes['audit.index']
show: typeof routes['audit.show']
}
spa: {
index: typeof routes['spa.index']
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const controllers = {
Audit: () => import('#controllers/audit_controller'),
Auth: () => import('#controllers/auth_controller'),
Backups: () => import('#controllers/backups_controller'),
Caddy: () => import('#controllers/caddy_controller'),
Containers: () => import('#controllers/containers_controller'),
Jobs: () => import('#controllers/jobs_controller'),
Metrics: () => import('#controllers/metrics_controller'),
Overview: () => import('#controllers/overview_controller'),
Projects: () => import('#controllers/projects_controller'),
Sites: () => import('#controllers/sites_controller'),
Spa: () => import('#controllers/spa_controller'),
}
+6
View File
@@ -0,0 +1,6 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const events = {}
+6
View File
@@ -0,0 +1,6 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const listeners = {}
+108
View File
@@ -0,0 +1,108 @@
import '@adonisjs/core/types/http'
type ParamValue = string | number | bigint | boolean
export type ScannedRoutes = {
ALL: {
'auth.login': { paramsTuple?: []; params?: {} }
'auth.logout': { paramsTuple?: []; params?: {} }
'auth.me': { paramsTuple?: []; params?: {} }
'overview.index': { paramsTuple?: []; params?: {} }
'metrics.live': { paramsTuple?: []; params?: {} }
'metrics.history': { paramsTuple?: []; params?: {} }
'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.action': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'action': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} }
'sites.check': { paramsTuple?: []; params?: {} }
'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'projects.index': { paramsTuple?: []; params?: {} }
'projects.store': { paramsTuple?: []; params?: {} }
'projects.options': { paramsTuple?: []; params?: {} }
'projects.show': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} }
'projects.fetch': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} }
'projects.action': { paramsTuple: [ParamValue,ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue,'action': ParamValue} }
'caddy.show': { paramsTuple?: []; params?: {} }
'caddy.update': { paramsTuple?: []; params?: {} }
'caddy.reload': { paramsTuple?: []; params?: {} }
'caddy.versions': { paramsTuple?: []; params?: {} }
'caddy.version': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'caddy.restore': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'backups.index': { paramsTuple?: []; params?: {} }
'backups.store': { paramsTuple?: []; params?: {} }
'backups.download': { paramsTuple: [ParamValue]; params: {'name': ParamValue} }
'backups.destroy': { paramsTuple: [ParamValue]; params: {'name': ParamValue} }
'jobs.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'audit.index': { paramsTuple?: []; params?: {} }
'audit.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
}
POST: {
'auth.login': { paramsTuple?: []; params?: {} }
'auth.logout': { paramsTuple?: []; params?: {} }
'containers.action': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'action': ParamValue} }
'sites.check': { paramsTuple?: []; params?: {} }
'projects.store': { paramsTuple?: []; params?: {} }
'projects.fetch': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} }
'projects.action': { paramsTuple: [ParamValue,ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue,'action': ParamValue} }
'caddy.reload': { paramsTuple?: []; params?: {} }
'caddy.restore': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'backups.store': { paramsTuple?: []; params?: {} }
}
GET: {
'auth.me': { paramsTuple?: []; params?: {} }
'overview.index': { paramsTuple?: []; params?: {} }
'metrics.live': { paramsTuple?: []; params?: {} }
'metrics.history': { paramsTuple?: []; params?: {} }
'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} }
'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'projects.index': { paramsTuple?: []; params?: {} }
'projects.options': { paramsTuple?: []; params?: {} }
'projects.show': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} }
'caddy.show': { paramsTuple?: []; params?: {} }
'caddy.versions': { paramsTuple?: []; params?: {} }
'caddy.version': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'backups.index': { paramsTuple?: []; params?: {} }
'backups.download': { paramsTuple: [ParamValue]; params: {'name': ParamValue} }
'jobs.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'audit.index': { paramsTuple?: []; params?: {} }
'audit.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
}
HEAD: {
'auth.me': { paramsTuple?: []; params?: {} }
'overview.index': { paramsTuple?: []; params?: {} }
'metrics.live': { paramsTuple?: []; params?: {} }
'metrics.history': { paramsTuple?: []; params?: {} }
'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} }
'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'projects.index': { paramsTuple?: []; params?: {} }
'projects.options': { paramsTuple?: []; params?: {} }
'projects.show': { paramsTuple: [ParamValue,ParamValue]; params: {'category': ParamValue,'name': ParamValue} }
'caddy.show': { paramsTuple?: []; params?: {} }
'caddy.versions': { paramsTuple?: []; params?: {} }
'caddy.version': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'backups.index': { paramsTuple?: []; params?: {} }
'backups.download': { paramsTuple: [ParamValue]; params: {'name': ParamValue} }
'jobs.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'audit.index': { paramsTuple?: []; params?: {} }
'audit.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
}
PUT: {
'caddy.update': { paramsTuple?: []; params?: {} }
}
DELETE: {
'backups.destroy': { paramsTuple: [ParamValue]; params: {'name': ParamValue} }
}
}
declare module '@adonisjs/core/types/http' {
export interface RoutesList extends ScannedRoutes {}
}
+22
View File
@@ -0,0 +1,22 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false
+36
View File
@@ -0,0 +1,36 @@
# Node
TZ=Europe/Paris
PORT=3333
HOST=0.0.0.0
NODE_ENV=production
# App
LOG_LEVEL=info
APP_KEY=
APP_URL=https://big-brother.nucleus-ludus.fr
# Session
SESSION_DRIVER=cookie
# Database (SQLite)
DB_PATH=/data/db.sqlite3
# VPS paths, as seen from inside the container
SRV_ROOT=/srv
HOST_PROC=/host/proc
DISK_PATH=/srv
BACKUP_DIR=/backups
# Docker
DOCKER_SOCKET=/var/run/docker.sock
CADDY_CONTAINER=caddy
CADDYFILE_PATH=/srv/caddy/Caddyfile
CADDY_CONFIG_PATH=/etc/caddy/Caddyfile
MARIADB_CONTAINER=mariadb
# Projects
BASE_DOMAIN=nucleus-ludus.fr
# Backups (empty BACKUP_DAILY_AT = no automatic backup)
BACKUP_DAILY_AT=03:00
BACKUP_KEEP=7
+1
View File
@@ -0,0 +1 @@
SESSION_DRIVER=memory
+26
View File
@@ -0,0 +1,26 @@
# Dependencies and AdonisJS build
node_modules
build
tmp/*
!tmp/.gitkeep
# Secrets
.env
.env.local
.env.production.local
.env.development.local
# Angular build (copied from ../web/dist)
public/
# Build tools specific
npm-debug.log
yarn-error.log
# Editors specific
.fleet
.idea
.vscode
# Platform specific
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
.adonisjs
node_modules
build
+27
View File
@@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using @poppinss/ts-exec
*/
import '@poppinss/ts-exec'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')
+126
View File
@@ -0,0 +1,126 @@
import { indexEntities } from '@adonisjs/core/generators'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/session/commands'),
],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/session/session_provider'),
() => import('@adonisjs/shield/shield_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('#providers/api_provider'),
() => import('@adonisjs/limiter/limiter_provider'),
() => import('@adonisjs/static/static_provider'),
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
{
file: () => import('#start/monitors'),
environment: ['web'],
},
],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
files: ['tests/unit/**/*.spec.{ts,js}'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/functional/**/*.spec.{ts,js}'],
name: 'functional',
timeout: 30000,
},
],
forceExit: false,
},
/*
|--------------------------------------------------------------------------
| Metafiles
|--------------------------------------------------------------------------
|
| A collection of files you want to copy to the build folder when creating
| the production build.
|
*/
metaFiles: [
{
pattern: 'public/**',
reloadServer: false,
},
],
hooks: {
init: [
indexEntities({
transformers: { enabled: true },
}),
generateRegistry(),
],
},
})
+50
View File
@@ -0,0 +1,50 @@
import vine from '@vinejs/vine'
import db from '@adonisjs/lucid/services/db'
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog from '#models/audit_log'
import UserError from '#exceptions/user_error'
const indexValidator = vine.create({
page: vine.number().withoutDecimals().min(1).optional(),
status: vine.enum(['running', 'success', 'error'] as const).optional(),
})
export default class AuditController {
async index({ request }: HttpContext) {
const { page = 1, status } = await indexValidator.validate(request.qs())
const query = AuditLog.query()
.select('id', 'username', 'ip', 'action', 'target', 'status', 'created_at', 'finished_at')
.select(db.raw('length(output) > 0 as hasOutput'))
.orderBy('id', 'desc')
if (status) query.where('status', status)
const result = await query.paginate(page, 50)
return {
meta: { total: result.total, page: result.currentPage, lastPage: result.lastPage },
data: result.all().map((entry) => ({
id: entry.id,
username: entry.username,
ip: entry.ip,
action: entry.action,
target: entry.target,
status: entry.status,
createdAt: entry.createdAt.toISO(),
finishedAt: entry.finishedAt?.toISO() ?? null,
hasOutput: Boolean(entry.$extras.hasOutput),
})),
}
}
async show({ params }: HttpContext) {
const entry = await AuditLog.find(params.id)
if (!entry) throw new UserError('Entrée introuvable.', 404)
return {
id: entry.id,
action: entry.action,
target: entry.target,
status: entry.status,
output: entry.output,
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import User from '#models/user'
import limiter from '@adonisjs/limiter/services/main'
import type { HttpContext } from '@adonisjs/core/http'
import totpService from '#services/totp_service'
import { audit } from '#services/audit'
import { loginValidator } from '#validators/auth'
class InvalidLogin extends Error {}
/**
* 5 failed attempts per IP, then blocked for 15 minutes.
*/
const loginLimiter = () =>
limiter.use({ requests: 5, duration: '15 mins', blockDuration: '15 mins' })
export default class AuthController {
async login(ctx: HttpContext) {
const { request, response, auth } = ctx
const { username, password, code } = await request.validateUsing(loginValidator)
const penalize = () =>
loginLimiter().penalize(`login_${request.ip()}`, async (): Promise<User> => {
const user = await User.verifyCredentials(username, password).catch(() => null)
if (!user || !(await totpService.verify(user, code))) throw new InvalidLogin()
return user
})
let result: Awaited<ReturnType<typeof penalize>>
try {
result = await penalize()
} catch (error) {
if (!(error instanceof InvalidLogin)) throw error
await audit(ctx, { action: 'Connexion refusée', target: username, status: 'error' })
return response.badRequest({ message: 'Identifiant, mot de passe ou code incorrect.' })
}
const [throttled, user] = result
if (throttled) {
const minutes = Math.max(1, Math.ceil(throttled.response.availableIn / 60))
return response.tooManyRequests({
message: `Trop de tentatives. Réessaie dans ${minutes} min.`,
})
}
await auth.use('web').login(user!)
await audit(ctx, { action: 'Connexion', username: user!.username, status: 'success' })
return { username: user!.username }
}
async logout(ctx: HttpContext) {
await audit(ctx, { action: 'Déconnexion', status: 'success' })
await ctx.auth.use('web').logout()
return { ok: true }
}
async me({ auth }: HttpContext) {
return { username: auth.getUserOrFail().username }
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import backupService from '#services/backup_service'
import { actor, audit } from '#services/audit'
export default class BackupsController {
async index() {
return {
dir: vpsConfig.backups.dir,
schedule: backupService.schedule,
files: await backupService.list(),
}
}
async store(ctx: HttpContext) {
const job = await backupService.run(actor(ctx))
return { jobId: job.id }
}
async download(ctx: HttpContext) {
const path = backupService.path(ctx.params.name)
await audit(ctx, {
action: 'Téléchargement de sauvegarde',
target: ctx.params.name,
status: 'success',
})
return ctx.response.attachment(path, ctx.params.name)
}
async destroy(ctx: HttpContext) {
await backupService.delete(ctx.params.name)
await audit(ctx, {
action: 'Suppression de sauvegarde',
target: ctx.params.name,
status: 'success',
})
return { ok: true }
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import CaddyfileVersion from '#models/caddyfile_version'
import UserError from '#exceptions/user_error'
import caddyService, { parseCaddyfile, type ReloadResult } from '#services/caddy_service'
import siteMonitor from '#services/site_monitor'
import { actor, audit } from '#services/audit'
import { updateCaddyfileValidator } from '#validators/caddy'
export default class CaddyController {
async show() {
const content = await caddyService.read()
return {
path: vpsConfig.caddy.caddyfilePath,
container: vpsConfig.caddy.container,
content,
sites: parseCaddyfile(content),
}
}
async update(ctx: HttpContext) {
const { content, reason } = await ctx.request.validateUsing(updateCaddyfileValidator)
const result = await caddyService.saveAndReload(content, {
username: actor(ctx).username,
reason: reason || 'Modification manuelle',
})
return this.#respond(ctx, 'Modification du Caddyfile', result)
}
async reload(ctx: HttpContext) {
const result = await caddyService.reload()
return this.#respond(ctx, 'Rechargement de Caddy', result)
}
async versions() {
const versions = await CaddyfileVersion.query()
.select('id', 'username', 'reason', 'created_at')
.orderBy('id', 'desc')
return versions.map((version) => ({
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
}))
}
async version({ params }: HttpContext) {
const version = await CaddyfileVersion.find(params.id)
if (!version) throw new UserError('Version introuvable.', 404)
return {
id: version.id,
username: version.username,
reason: version.reason,
createdAt: version.createdAt.toISO(),
content: version.content,
}
}
async restore(ctx: HttpContext) {
const version = await CaddyfileVersion.find(ctx.params.id)
if (!version) throw new UserError('Version introuvable.', 404)
const result = await caddyService.saveAndReload(version.content, {
username: actor(ctx).username,
reason: `Restauration de la version #${version.id}`,
})
return this.#respond(ctx, `Restauration du Caddyfile (#${version.id})`, result)
}
async #respond(ctx: HttpContext, action: string, result: ReloadResult) {
await audit(ctx, {
action,
target: vpsConfig.caddy.caddyfilePath,
status: result.ok ? 'success' : 'error',
output: result.output,
})
if (!result.ok) {
return ctx.response.unprocessableEntity({
message: 'Caddy a refusé la configuration : le Caddyfile est inchangé.',
output: result.output,
})
}
/**
* New or removed domains show up right away on the sites page.
*/
siteMonitor.sync().catch(() => {})
return { ok: true, output: result.output }
}
}
@@ -0,0 +1,73 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import dockerService from '#services/docker_service'
import { audit } from '#services/audit'
import { openSse } from '#services/sse'
const ACTION_LABELS = {
start: 'Démarrage du conteneur',
stop: 'Arrêt du conteneur',
restart: 'Redémarrage du conteneur',
} as const
const actionValidator = vine.create({
action: vine.enum(['start', 'stop', 'restart'] as const),
})
const logsValidator = vine.create({
tail: vine.number().withoutDecimals().range([0, 5000]).optional(),
})
export default class ContainersController {
async index() {
return dockerService.list()
}
async show({ params }: HttpContext) {
return dockerService.inspect(params.id)
}
async action(ctx: HttpContext) {
const { action } = await actionValidator.validate(ctx.params)
const info = await dockerService.inspect(ctx.params.id)
try {
await dockerService.action(info.id, action)
} catch (error) {
await audit(ctx, {
action: ACTION_LABELS[action],
target: info.name,
status: 'error',
output: error instanceof Error ? error.message : String(error),
})
throw error
}
await audit(ctx, { action: ACTION_LABELS[action], target: info.name, status: 'success' })
return dockerService.inspect(info.id)
}
/**
* Follows the logs of the container (Server-Sent Events).
*/
async logs(ctx: HttpContext) {
const { tail } = await logsValidator.validate(ctx.request.qs())
const channel = openSse(ctx)
try {
const stop = await dockerService.followLogs(
ctx.params.id,
{ tail: tail ?? 300 },
(line) => channel.send('line', line),
() => {
channel.send('end', {})
channel.close()
}
)
channel.onClose(stop)
} catch (error) {
channel.send('failure', { message: error instanceof Error ? error.message : String(error) })
channel.close()
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog from '#models/audit_log'
import UserError from '#exceptions/user_error'
import jobRunner, { type JobLine } from '#services/job_runner'
import { openSse } from '#services/sse'
export default class JobsController {
/**
* Replays the output of a job, then follows it until the end. Jobs
* finished a while ago are read from the action log.
*/
async stream(ctx: HttpContext) {
const id = Number(ctx.params.id)
const job = jobRunner.get(id)
const entry = job ? null : await AuditLog.find(id)
if (!job && !entry) throw new UserError('Action introuvable.', 404)
const channel = openSse(ctx)
if (!job) {
const lines = (entry!.output ?? '')
.split('\n')
.map((text) => ({ ts: 0, stream: 'out', text }))
channel.send('meta', { id, action: entry!.action, target: entry!.target })
channel.send('lines', lines)
channel.send('end', { status: entry!.status })
channel.close()
return
}
channel.send('meta', { id, action: job.meta.action, target: job.meta.target })
channel.send('lines', job.lines)
if (job.status !== 'running') {
channel.send('end', { status: job.status })
channel.close()
return
}
const onLine = (line: JobLine) => channel.send('lines', [line])
const onEnd = (status: string) => {
channel.send('end', { status })
channel.close()
}
job.on('line', onLine)
job.once('end', onEnd)
channel.onClose(() => {
job.off('line', onLine)
job.off('end', onEnd)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import metricsService, { type HostSample } from '#services/metrics_service'
import { openSse } from '#services/sse'
const historyValidator = vine.create({
range: vine.enum(['1h', '24h', '7d', '30d'] as const),
})
export default class MetricsController {
/**
* Live samples (every 5 s) for the charts of the overview.
*/
async live(ctx: HttpContext) {
const channel = openSse(ctx)
channel.send('init', metricsService.live)
const onSample = (sample: HostSample) => channel.send('sample', sample)
metricsService.on('sample', onSample)
channel.onClose(() => metricsService.off('sample', onSample))
}
async history({ request }: HttpContext) {
const { range } = await historyValidator.validate(request.qs())
return metricsService.history(range)
}
}
@@ -0,0 +1,71 @@
import os from 'node:os'
import Site from '#models/site'
import AuditLog from '#models/audit_log'
import backupService from '#services/backup_service'
import dockerService from '#services/docker_service'
import metricsService from '#services/metrics_service'
const CERT_WARNING_DAYS = 14
export default class OverviewController {
async index() {
const now = Math.floor(Date.now() / 1000)
const [containers, sites, backups, recent, uptime] = await Promise.all([
dockerService.list().catch((error: Error) => error),
Site.query().where('active', true).orderBy('domain'),
backupService.list(),
AuditLog.query().orderBy('id', 'desc').limit(8),
metricsService.uptime(),
])
const docker =
containers instanceof Error
? { ok: false as const, error: containers.message }
: {
ok: true as const,
total: containers.length,
running: containers.filter((c) => c.state === 'running').length,
unhealthy: containers.filter((c) => c.health === 'unhealthy').map((c) => c.name),
stopped: containers.filter((c) => c.state !== 'running').map((c) => c.name),
}
return {
host: {
latest: metricsService.latest,
uptime,
cpus: os.cpus().length,
},
docker,
sites: {
total: sites.length,
up: sites.filter((site) => site.lastOk === true).length,
down: sites
.filter((site) => site.lastOk === false)
.map((site) => ({ id: site.id, domain: site.domain, error: site.lastError })),
certSoon: sites
.filter(
(site) => site.certExpiresAt && site.certExpiresAt - now < CERT_WARNING_DAYS * 86_400
)
.map((site) => ({
id: site.id,
domain: site.domain,
days: Math.floor((site.certExpiresAt! - now) / 86_400),
})),
},
backups: {
last: backups[0] ?? null,
count: backups.length,
schedule: backupService.schedule,
},
recent: recent.map((entry) => ({
id: entry.id,
action: entry.action,
target: entry.target,
status: entry.status,
username: entry.username,
createdAt: entry.createdAt.toISO(),
})),
}
}
}
@@ -0,0 +1,51 @@
import type { HttpContext } from '@adonisjs/core/http'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import projectService, { PROJECT_ACTIONS, type ProjectAction } from '#services/project_service'
import { DEFAULT_NODE_COMMAND } from '#services/templates'
import { actor } from '#services/audit'
import { createProjectValidator, projectActionValidator } from '#validators/project'
export default class ProjectsController {
async index() {
return projectService.list()
}
/**
* Values used by the "new project" form.
*/
async options() {
return {
baseDomain: vpsConfig.baseDomain,
categories: vpsConfig.projectCategories,
srvRoot: vpsConfig.srvRoot,
defaultNodeCommand: DEFAULT_NODE_COMMAND,
}
}
async show({ params }: HttpContext) {
const project = await projectService.find(params.category, params.name)
return projectService.details(project)
}
async fetch({ params }: HttpContext) {
const project = await projectService.find(params.category, params.name)
return projectService.fetch(project)
}
async action(ctx: HttpContext) {
const action = ctx.params.action as ProjectAction
if (!PROJECT_ACTIONS.includes(action)) throw new UserError('Action inconnue.', 404)
const { commit } = await ctx.request.validateUsing(projectActionValidator)
const project = await projectService.find(ctx.params.category, ctx.params.name)
const job = await projectService.runAction(project, action, { commit }, actor(ctx))
return { jobId: job.id }
}
async store(ctx: HttpContext) {
const input = await ctx.request.validateUsing(createProjectValidator)
const job = await projectService.create(input, actor(ctx))
return { jobId: job.id }
}
}
+77
View File
@@ -0,0 +1,77 @@
import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http'
import Site from '#models/site'
import siteMonitor from '#services/site_monitor'
import UserError from '#exceptions/user_error'
const showValidator = vine.create({
range: vine.enum(['24h', '7d', '30d'] as const).optional(),
})
const RANGES = {
'24h': { seconds: 86_400, bucket: 300 },
'7d': { seconds: 7 * 86_400, bucket: 1800 },
'30d': { seconds: 30 * 86_400, bucket: 7200 },
} as const
export default class SitesController {
async index() {
const [sites, day, week] = await Promise.all([
Site.query().orderBy('active', 'desc').orderBy('domain'),
siteMonitor.stats(86_400),
siteMonitor.stats(7 * 86_400),
])
return sites.map((site) => ({
...serializeSite(site),
uptime24h: day.get(site.id)?.uptime ?? null,
uptime7d: week.get(site.id)?.uptime ?? null,
avgResponseMs24h: day.get(site.id)?.avgResponseMs ?? null,
}))
}
async show({ params, request }: HttpContext) {
const { range = '24h' } = await showValidator.validate(request.qs())
const site = await Site.find(params.id)
if (!site) throw new UserError('Site introuvable.', 404)
const { seconds, bucket } = RANGES[range]
const [history, stats] = await Promise.all([
siteMonitor.history(site.id, seconds, bucket),
siteMonitor.stats(seconds),
])
return {
site: serializeSite(site),
range,
bucket,
uptime: stats.get(site.id)?.uptime ?? null,
avgResponseMs: stats.get(site.id)?.avgResponseMs ?? null,
...history,
}
}
/**
* Runs the checks right away instead of waiting for the next minute.
*/
async check() {
await siteMonitor.checkAll()
return this.index()
}
}
function serializeSite(site: Site) {
return {
id: site.id,
domain: site.domain,
active: site.active,
kind: site.kind,
upstream: site.upstream,
lastOk: site.lastOk,
lastStatus: site.lastStatus,
lastResponseMs: site.lastResponseMs,
lastError: site.lastError,
lastCheckedAt: site.lastCheckedAt,
certExpiresAt: site.certExpiresAt,
}
}
+25
View File
@@ -0,0 +1,25 @@
import { existsSync } from 'node:fs'
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
/**
* Serves the Angular application for every URL that is neither a file
* of "public/" nor an API route (/containers, /projects/…).
*/
export default class SpaController {
async index({ request, response }: HttpContext) {
if (request.url().startsWith('/api/')) {
return response.notFound({ message: 'Route inconnue.' })
}
const index = app.publicPath('index.html')
if (!existsSync(index)) {
return response.notFound(
'Interface non compilée : lance "npm run build" dans web/ (ou "npm start" pour le développement).'
)
}
response.header('Cache-Control', 'no-cache')
return response.download(index)
}
}
+55
View File
@@ -0,0 +1,55 @@
import app from '@adonisjs/core/services/app'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import { isDockerUnreachable } from '#services/docker_service'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
/**
* Shield redirects back on a bad CSRF token, which means nothing for
* the API: answer with a JSON error instead.
*/
if ((error as { code?: string })?.code === 'E_BAD_CSRF_TOKEN') {
return ctx.response.forbidden({
message: 'Session expirée ou jeton de sécurité invalide : recharge la page.',
})
}
if (isDockerUnreachable(error)) {
return ctx.response.serviceUnavailable({
message: 'Docker est injoignable : le socket Docker est-il monté dans le conteneur ?',
})
}
/**
* Errors of the Docker API (unknown container, conflict…).
*/
const dockerError = error as { statusCode?: number; json?: { message?: string } }
if (typeof dockerError?.statusCode === 'number' && dockerError.json?.message) {
return ctx.response
.status(dockerError.statusCode === 404 ? 404 : 400)
.send({ message: `Docker : ${dockerError.json.message}` })
}
return super.handle(error, ctx)
}
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
/**
* An error whose message is meant for the user (shown as is in the
* dashboard). Defaults to a 400 status.
*/
export default class UserError extends Exception {
static status = 400
constructor(message: string, status?: number) {
super(message, { status: status ?? UserError.status })
}
async handle(error: this, ctx: HttpContext) {
ctx.response.status(error.status).send({ message: error.message })
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards)
return next()
}
}
@@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import { type NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}
@@ -0,0 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* The API always answers in JSON, including the errors raised before the
* route middleware (CSRF) and the EventSource requests: an unauthenticated
* stream gets a 401 instead of a redirect.
*/
export default class ForceJsonResponseMiddleware {
handle(ctx: HttpContext, next: NextFn) {
if (ctx.request.url().startsWith('/api/')) {
ctx.request.request.headers.accept = 'application/json'
}
return next()
}
}
@@ -0,0 +1,39 @@
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Only same-origin resources. Angular puts the component styles in
* <style> tags, hence "unsafe-inline" for styles (not for scripts).
*/
const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ')
/**
* Sets the security headers on every response, including the static
* files served before the router (which shield does not cover).
*/
export default class SecurityHeadersMiddleware {
handle({ response }: HttpContext, next: NextFn) {
response.header('Content-Security-Policy', CONTENT_SECURITY_POLICY)
response.header('X-Frame-Options', 'DENY')
response.header('X-Content-Type-Options', 'nosniff')
response.header('Referrer-Policy', 'no-referrer')
response.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
response.header('Cross-Origin-Opener-Policy', 'same-origin')
if (app.inProduction) {
response.header('Strict-Transport-Security', 'max-age=15552000')
}
return next()
}
}
+5
View File
@@ -0,0 +1,5 @@
import { AuditLogSchema } from '#database/schema'
export type AuditStatus = 'running' | 'success' | 'error'
export default class AuditLog extends AuditLogSchema {}
+3
View File
@@ -0,0 +1,3 @@
import { CaddyfileVersionSchema } from '#database/schema'
export default class CaddyfileVersion extends CaddyfileVersionSchema {}
+15
View File
@@ -0,0 +1,15 @@
import { SiteSchema } from '#database/schema'
import { column } from '@adonisjs/lucid/orm'
/**
* SQLite stores booleans as 0/1: convert them back when reading.
*/
const toBoolean = (value: unknown) => (value === null ? null : Boolean(value))
export default class Site extends SiteSchema {
@column({ consume: toBoolean })
declare active: boolean
@column({ consume: toBoolean })
declare lastOk: boolean | null
}
+9
View File
@@ -0,0 +1,9 @@
import { UserSchema } from '#database/schema'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
export default class User extends compose(
UserSchema,
withAuthFinder(hash, { uids: ['username'], passwordColumnName: 'password' })
) {}
+39
View File
@@ -0,0 +1,39 @@
import { DateTime } from 'luxon'
import type { HttpContext } from '@adonisjs/core/http'
import AuditLog, { type AuditStatus } from '#models/audit_log'
/**
* Who is acting, for the action log and the jobs.
*/
export function actor({ auth, request }: HttpContext) {
return {
username: auth.user?.username ?? null,
ip: request.ip(),
}
}
/**
* Records a short action (container restart, Caddyfile change…) in the
* action log. Long actions go through the job runner instead.
*/
export async function audit(
ctx: HttpContext,
entry: {
action: string
target?: string | null
status: AuditStatus
output?: string | null
username?: string | null
}
) {
const { username, ip } = actor(ctx)
await AuditLog.create({
username: entry.username !== undefined ? entry.username : username,
ip,
action: entry.action,
target: entry.target ?? null,
status: entry.status,
output: entry.output ?? null,
finishedAt: DateTime.now(),
})
}
+171
View File
@@ -0,0 +1,171 @@
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import jobRunner, { type JobMeta } from '#services/job_runner'
/**
* db-2026-10-11_0300.sql.gz (all the MariaDB databases)
* srv-2026-10-11_0300.tar.gz (the /srv folder)
*/
const BACKUP_FILE = /^(db|srv)-\d{4}-\d{2}-\d{2}_\d{4}\.(sql\.gz|tar\.gz)$/
export interface BackupFile {
name: string
type: 'db' | 'srv'
size: number
createdAt: number
}
class BackupService {
#timer: NodeJS.Timeout | null = null
#lastScheduledDay: string | null = null
async list(): Promise<BackupFile[]> {
let names: string[] = []
try {
names = await readdir(vpsConfig.backups.dir)
} catch {
return []
}
const files = await Promise.all(
names
.filter((name) => BACKUP_FILE.test(name))
.map(async (name) => {
const info = await stat(join(vpsConfig.backups.dir, name))
return {
name,
type: name.startsWith('db-') ? 'db' : 'srv',
size: info.size,
createdAt: Math.floor(info.mtimeMs / 1000),
} satisfies BackupFile
})
)
return files.sort((a, b) => b.createdAt - a.createdAt)
}
/**
* Absolute path of a backup, after checking its name (no path traversal).
*/
path(name: string) {
if (!BACKUP_FILE.test(name)) throw new UserError('Sauvegarde introuvable.', 404)
return join(vpsConfig.backups.dir, name)
}
async delete(name: string) {
await rm(this.path(name))
}
run(meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>) {
return jobRunner.start(
{ ...meta, action: 'Sauvegarde', target: vpsConfig.backups.dir, lockKey: 'backup' },
async (ctx) => {
const dir = vpsConfig.backups.dir
const stamp = DateTime.now().toFormat('yyyy-LL-dd_HHmm')
await mkdir(dir, { recursive: true })
const dbFile = join(dir, `db-${stamp}.sql.gz`)
ctx.log('1/2 Export de toutes les bases MariaDB')
try {
await ctx.run(
'docker',
[
'exec',
vpsConfig.mariadb.container,
'sh',
'-c',
'exec "$(command -v mariadb-dump || command -v mysqldump)" -u root -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --all-databases --single-transaction --routines --events',
],
{
display: `docker exec ${vpsConfig.mariadb.container} mariadb-dump --all-databases > ${basename(dbFile)}`,
stdoutFile: dbFile,
gzipStdout: true,
}
)
} catch (error) {
await rm(dbFile, { force: true })
throw error
}
/**
* node_modules can be reinstalled and db_data holds the raw MariaDB
* files, already covered (consistently) by the export above.
*/
const srvFile = join(dir, `srv-${stamp}.tar.gz`)
ctx.log(`2/2 Archive de ${vpsConfig.srvRoot}`)
try {
await ctx.run(
'tar',
[
'czf',
srvFile,
'--exclude=node_modules',
'--exclude=db_data',
'-C',
dirname(vpsConfig.srvRoot),
basename(vpsConfig.srvRoot),
],
/**
* 1 = some files changed while being read (logs…): not an error.
*/
{ okCodes: [0, 1] }
)
} catch (error) {
await rm(srvFile, { force: true })
throw error
}
const removed = await this.#applyRetention()
if (removed.length) ctx.log(`Anciennes sauvegardes supprimées : ${removed.join(', ')}`)
}
)
}
/**
* Keeps the last N backups of each type.
*/
async #applyRetention() {
const files = await this.list()
const removed: string[] = []
for (const type of ['db', 'srv'] as const) {
const old = files.filter((file) => file.type === type).slice(vpsConfig.backups.keep)
for (const file of old) {
await rm(join(vpsConfig.backups.dir, file.name), { force: true })
removed.push(file.name)
}
}
return removed
}
get schedule() {
return { dailyAt: vpsConfig.backups.dailyAt, keep: vpsConfig.backups.keep }
}
/**
* Daily backup at BACKUP_DAILY_AT (server time).
*/
startSchedule() {
const dailyAt = vpsConfig.backups.dailyAt
if (!dailyAt) return
this.#timer = setInterval(() => {
const now = DateTime.now()
const today = now.toISODate()
if (now.toFormat('HH:mm') !== dailyAt || this.#lastScheduledDay === today) return
this.#lastScheduledDay = today
this.run({ username: 'automatique', ip: null }).catch((error) =>
logger.error({ err: error }, 'scheduled backup failed to start')
)
}, 30_000)
}
stopSchedule() {
if (this.#timer) clearInterval(this.#timer)
}
}
const backupService = new BackupService()
export default backupService
+215
View File
@@ -0,0 +1,215 @@
import { readFile, writeFile } from 'node:fs/promises'
import vpsConfig from '#config/vps'
import dockerService from '#services/docker_service'
import CaddyfileVersion from '#models/caddyfile_version'
export interface CaddySite {
/**
* Addresses as written in the Caddyfile ("a.fr, www.a.fr").
*/
addresses: string[]
/**
* Domains that can be checked over HTTPS (no wildcard, no port-only address).
*/
domains: string[]
kind: 'proxy' | 'static' | 'other'
/**
* "container:port" for a reverse proxy, the root folder for a static site.
*/
upstream: string | null
line: number
}
export interface ReloadResult {
ok: boolean
output: string
}
const LINE_BREAK = /\r?\n/
/**
* Extracts the site blocks of a Caddyfile. This is not a full parser:
* it only needs the addresses and what each site serves.
*/
export function parseCaddyfile(content: string): CaddySite[] {
const sites: CaddySite[] = []
let depth = 0
let current: CaddySite | null = null
let currentDepth = 0
content.split(LINE_BREAK).forEach((rawLine, index) => {
const line = rawLine.replace(/(^|\s)#.*$/, '').trim()
if (!line) return
const opens = (line.match(/{/g) ?? []).length
const closes = (line.match(/}/g) ?? []).length
/**
* A site block starts at the top level, with the addresses before "{".
* "{" alone is the global options block and "(name) {" a snippet.
*/
if (depth === 0 && line.endsWith('{') && line !== '{' && !line.startsWith('(')) {
const addresses = line
.slice(0, -1)
.split(/[,\s]+/)
.map((address) => address.trim())
.filter(Boolean)
current = {
addresses,
domains: addresses.map(toDomain).filter((domain): domain is string => domain !== null),
kind: 'other',
upstream: null,
line: index + 1,
}
currentDepth = depth + opens - closes
sites.push(current)
depth = currentDepth
/**
* Block opened and closed on the same line.
*/
if (currentDepth === 0) current = null
return
}
if (current) {
const [directive, ...args] = line.split(/\s+/)
if (directive === 'reverse_proxy' && current.kind !== 'proxy') {
const upstream = args.find(
(arg) => !arg.startsWith('/') && !arg.startsWith('@') && arg !== '{' && arg !== '*'
)
current.kind = 'proxy'
current.upstream = upstream?.replace(/^https?:\/\//, '') ?? null
} else if (directive === 'root' && current.kind === 'other') {
current.kind = 'static'
current.upstream = args.at(-1) ?? null
} else if (directive === 'file_server' && current.kind === 'other') {
current.kind = 'static'
}
}
depth += opens - closes
if (current && depth < currentDepth) current = null
})
return sites
}
/**
* "https://a.fr:443" -> "a.fr". Null when the address cannot be checked.
*/
function toDomain(address: string): string | null {
const host = address
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.replace(/:\d+$/, '')
if (!host || host.includes('*') || host.includes('{') || host === 'localhost') return null
if (/^[\d.]+$/.test(host) || host.includes(':')) return null
return host.toLowerCase()
}
/**
* Caddy logs JSON lines: keep the level, the message and the error.
*/
function readableCaddyLog(output: string) {
return output
.split(LINE_BREAK)
.filter((line) => line.trim())
.map((line) => {
try {
const entry = JSON.parse(line) as { level?: string; msg?: string; error?: string }
return [entry.level?.toUpperCase(), entry.msg, entry.error].filter(Boolean).join(' · ')
} catch {
return line
}
})
.join('\n')
}
class CaddyService {
read() {
return readFile(vpsConfig.caddy.caddyfilePath, 'utf8')
}
async sites() {
return parseCaddyfile(await this.read())
}
/**
* Writes in place: the Caddyfile is often mounted as a single file in
* the Caddy container, and replacing the file (write + rename) would leave
* the container on the old version.
*/
#write(content: string) {
return writeFile(vpsConfig.caddy.caddyfilePath, content, 'utf8')
}
async reload(): Promise<ReloadResult> {
const result = await dockerService.exec(vpsConfig.caddy.container, [
'caddy',
'reload',
'--config',
vpsConfig.caddy.configPath,
'--adapter',
'caddyfile',
])
return {
ok: result.exitCode === 0,
output: readableCaddyLog(`${result.stdout}${result.stderr}`),
}
}
/**
* Saves the new Caddyfile and reloads Caddy. When Caddy refuses the new
* configuration, the previous file is put back (Caddy keeps running with
* the previous configuration anyway).
*/
async saveAndReload(
content: string,
meta: { username: string | null; reason: string }
): Promise<ReloadResult> {
const previous = await this.read()
const normalized = content.replace(/\r\n/g, '\n')
if (normalized === previous) return { ok: true, output: 'Aucun changement.' }
await this.#write(normalized)
let result: ReloadResult
try {
result = await this.reload()
} catch (error) {
result = { ok: false, output: error instanceof Error ? error.message : String(error) }
}
if (!result.ok) {
await this.#write(previous)
return result
}
await CaddyfileVersion.create({
content: previous,
username: meta.username,
reason: meta.reason,
})
await this.#pruneVersions()
return result
}
async appendBlock(block: string, meta: { username: string | null; reason: string }) {
const current = await this.read()
return this.saveAndReload(`${current.trimEnd()}\n\n${block.trim()}\n`, meta)
}
/**
* Keeps the last 50 versions.
*/
async #pruneVersions() {
const kept = await CaddyfileVersion.query().select('id').orderBy('id', 'desc').limit(50)
if (kept.length < 50) return
await CaddyfileVersion.query().where('id', '<', kept.at(-1)!.id).delete()
}
}
const caddyService = new CaddyService()
export default caddyService
+360
View File
@@ -0,0 +1,360 @@
import Docker from 'dockerode'
import { hostname } from 'node:os'
import { PassThrough, type Readable, type Writable } from 'node:stream'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import { lineReader } from '#services/job_runner'
export interface ContainerStats {
/**
* Share of the whole host CPU (0-100).
*/
cpu: number
memUsed: number
memLimit: number
ts: number
}
export interface ContainerSummary {
id: string
name: string
image: string
state: string
status: string
health: string | null
createdAt: number
composeProject: string | null
composeWorkingDir: string | null
composeService: string | null
networks: string[]
publishedPorts: string[]
stats: ContainerStats | null
self: boolean
}
export type ContainerAction = 'start' | 'stop' | 'restart'
export interface LogLine {
ts: string | null
stream: 'out' | 'err'
text: string
}
interface CpuSample {
container: number
system: number
}
class DockerService {
readonly docker = new Docker(
vpsConfig.docker.socketPath ? { socketPath: vpsConfig.docker.socketPath } : undefined
)
#stats = new Map<string, ContainerStats>()
#previousCpu = new Map<string, CpuSample>()
#statsTimer: NodeJS.Timeout | null = null
#selfId: Promise<string | null> | null = null
/**
* Id of the container running the dashboard (null in development).
* Inside a container, the hostname is the short container id.
*/
selfId(): Promise<string | null> {
this.#selfId ??= this.docker
.getContainer(hostname())
.inspect()
.then((info) => info.Id)
.catch(() => null)
return this.#selfId
}
async isSelf(id: string) {
const selfId = await this.selfId()
return selfId !== null && (selfId === id || selfId.startsWith(id))
}
async list(): Promise<ContainerSummary[]> {
const [containers, selfId] = await Promise.all([
this.docker.listContainers({ all: true }),
this.selfId(),
])
return containers
.map((container) => {
const labels = container.Labels ?? {}
const health = /\((healthy|unhealthy|health: starting)\)/.exec(container.Status)?.[1]
return {
id: container.Id,
name: (container.Names[0] ?? container.Id).replace(/^\//, ''),
image: container.Image,
state: container.State,
status: container.Status,
health: health ? health.replace('health: ', '') : null,
createdAt: container.Created,
composeProject: labels['com.docker.compose.project'] ?? null,
composeWorkingDir: labels['com.docker.compose.project.working_dir'] ?? null,
composeService: labels['com.docker.compose.service'] ?? null,
networks: Object.keys(container.NetworkSettings?.Networks ?? {}),
publishedPorts: [
...new Set(
(container.Ports ?? [])
.filter((port) => port.PublicPort)
.map((port) =>
formatPort(port.IP, port.PublicPort, `${port.PrivatePort}/${port.Type}`)
)
),
],
stats: container.State === 'running' ? (this.#stats.get(container.Id) ?? null) : null,
self: selfId === container.Id,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Details shown on the container page. The environment variables are
* left out on purpose: they hold passwords.
*/
async inspect(id: string) {
const info = await this.docker.getContainer(id).inspect()
const labels = info.Config.Labels ?? {}
return {
id: info.Id,
name: info.Name.replace(/^\//, ''),
image: info.Config.Image,
state: info.State.Status,
health: info.State.Health?.Status ?? null,
startedAt: info.State.StartedAt,
finishedAt: info.State.FinishedAt,
exitCode: info.State.ExitCode,
restartCount: info.RestartCount,
restartPolicy: info.HostConfig.RestartPolicy?.Name ?? null,
createdAt: info.Created,
command: [...(info.Config.Entrypoint ?? []), ...(info.Config.Cmd ?? [])].join(' '),
workingDir: info.Config.WorkingDir,
composeProject: labels['com.docker.compose.project'] ?? null,
composeWorkingDir: labels['com.docker.compose.project.working_dir'] ?? null,
composeService: labels['com.docker.compose.service'] ?? null,
networks: Object.keys(info.NetworkSettings.Networks ?? {}),
mounts: (info.Mounts ?? []).map((mount) => ({
type: mount.Type,
source: mount.Name ?? mount.Source,
destination: mount.Destination,
readOnly: !mount.RW,
})),
publishedPorts: [
...new Set(
Object.entries(info.NetworkSettings.Ports ?? {}).flatMap(([containerPort, bindings]) =>
(bindings ?? []).map((binding) =>
formatPort(binding.HostIp, binding.HostPort, containerPort)
)
)
),
],
stats: this.#stats.get(info.Id) ?? null,
self: await this.isSelf(info.Id),
}
}
async action(id: string, action: ContainerAction) {
if (action === 'stop' && (await this.isSelf(id))) {
throw new UserError('Impossible d’arrêter le conteneur du tableau de bord depuis lui-même.')
}
const container = this.docker.getContainer(id)
try {
if (action === 'start') await container.start()
else if (action === 'stop') await container.stop()
else await container.restart()
} catch (error: any) {
/**
* 304: the container is already in the requested state.
*/
if (error?.statusCode !== 304) throw error
}
}
/**
* Follows the logs of a container. Returns a function stopping the stream.
*/
async followLogs(
id: string,
options: { tail: number },
onLine: (line: LogLine) => void,
onEnd: () => void
): Promise<() => void> {
const container = this.docker.getContainer(id)
const info = await container.inspect()
const stream = (await container.logs({
follow: true,
stdout: true,
stderr: true,
tail: options.tail,
timestamps: true,
})) as unknown as Readable
const parse = (source: LogLine['stream']) => (raw: string) => {
const space = raw.indexOf(' ')
const hasTs = space > 0 && /^\d{4}-\d{2}-\d{2}T/.test(raw)
onLine({
ts: hasTs ? raw.slice(0, space) : null,
stream: source,
text: hasTs ? raw.slice(space + 1) : raw,
})
}
/**
* Without a TTY, Docker multiplexes stdout and stderr in one stream.
*/
if (info.Config.Tty) {
lineReader(stream, parse('out'))
} else {
const stdout = new PassThrough()
const stderr = new PassThrough()
this.docker.modem.demuxStream(stream, stdout, stderr)
stream.on('end', () => {
stdout.end()
stderr.end()
})
lineReader(stdout, parse('out'))
lineReader(stderr, parse('err'))
}
let ended = false
const end = () => {
if (ended) return
ended = true
onEnd()
}
stream.on('end', end)
stream.on('error', end)
return () => {
stream.destroy()
}
}
/**
* Runs a command inside a container and waits for it.
*/
async exec(containerName: string, cmd: string[], stdoutTarget?: Writable) {
const container = this.docker.getContainer(containerName)
const exec = await container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true })
const stream = await exec.start({ hijack: true, stdin: false })
const stdoutChunks: Buffer[] = []
const stderrChunks: Buffer[] = []
const stdout = stdoutTarget ?? collect(stdoutChunks)
const stderr = collect(stderrChunks)
this.docker.modem.demuxStream(stream, stdout, stderr)
await new Promise<void>((resolve, reject) => {
stream.on('end', resolve)
stream.on('close', resolve)
stream.on('error', reject)
})
const result = await exec.inspect()
return {
exitCode: result.ExitCode ?? -1,
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
}
}
/**
* Collects the CPU and memory usage of the running containers. Uses
* "one-shot" stats (instant) and computes the CPU usage between two polls.
*/
async refreshStats() {
const running = await this.docker.listContainers()
const seen = new Set<string>()
await Promise.all(
running.map(async ({ Id: id }) => {
seen.add(id)
try {
const stats: any = await this.docker
.getContainer(id)
.stats({ 'stream': false, 'one-shot': true } as any)
const sample: CpuSample = {
container: Number(stats.cpu_stats?.cpu_usage?.total_usage ?? 0),
system: Number(stats.cpu_stats?.system_cpu_usage ?? 0),
}
const previous = this.#previousCpu.get(id)
this.#previousCpu.set(id, sample)
let cpu = 0
if (previous && sample.system > previous.system) {
cpu =
((sample.container - previous.container) / (sample.system - previous.system)) * 100
}
const memory = stats.memory_stats ?? {}
const cache =
memory.stats?.inactive_file ??
memory.stats?.total_inactive_file ??
memory.stats?.cache ??
0
this.#stats.set(id, {
cpu: Math.max(0, Math.min(100, cpu)),
memUsed: Math.max(0, Number(memory.usage ?? 0) - Number(cache)),
memLimit: Number(memory.limit ?? 0),
ts: Date.now(),
})
} catch {
// The container may have stopped between the list and the stats call.
}
})
)
for (const id of this.#stats.keys()) {
if (!seen.has(id)) {
this.#stats.delete(id)
this.#previousCpu.delete(id)
}
}
}
startStatsPolling(intervalMs = 10_000) {
const tick = () => this.refreshStats().catch(() => {})
tick()
this.#statsTimer = setInterval(tick, intervalMs)
}
stopStatsPolling() {
if (this.#statsTimer) clearInterval(this.#statsTimer)
}
}
/**
* "8080->80/tcp" for a port open on every interface (reachable from
* Internet), "127.0.0.1:8080->80/tcp" when bound to one address only.
*/
function formatPort(ip: string | undefined, hostPort: number | string, target: string) {
const everywhere = !ip || ip === '0.0.0.0' || ip === '::'
return `${everywhere ? '' : `${ip}:`}${hostPort}->${target}`
}
function collect(chunks: Buffer[]) {
const stream = new PassThrough()
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
return stream
}
/**
* True when the error comes from the Docker socket being unreachable.
*/
export function isDockerUnreachable(error: unknown) {
const code = (error as NodeJS.ErrnoException)?.code
return (
code === 'ECONNREFUSED' ||
code === 'EACCES' ||
(code === 'ENOENT' && (error as any)?.syscall === 'connect')
)
}
const dockerService = new DockerService()
export default dockerService
+278
View File
@@ -0,0 +1,278 @@
import { spawn } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { EventEmitter } from 'node:events'
import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import AuditLog, { type AuditStatus } from '#models/audit_log'
import UserError from '#exceptions/user_error'
export interface JobLine {
ts: number
stream: 'out' | 'err' | 'info'
text: string
}
export interface RunOptions {
cwd?: string
env?: Record<string, string>
/**
* Exit codes considered as a success (default: [0]).
*/
okCodes?: number[]
/**
* Text shown in the log instead of the real command (to hide secrets).
*/
display?: string
/**
* Writes stdout to this file instead of the log.
*/
stdoutFile?: string
gzipStdout?: boolean
timeoutMs?: number
}
export interface JobContext {
log(text: string, stream?: JobLine['stream']): void
run(command: string, args: string[], options?: RunOptions): Promise<void>
}
export interface JobMeta {
action: string
target: string | null
username: string | null
ip: string | null
/**
* Two jobs with the same lock key cannot run at the same time
* (for example two deployments of the same project).
*/
lockKey?: string
}
/**
* Output kept in the action log, to keep the SQLite file small.
*/
const MAX_STORED_OUTPUT = 200_000
/**
* Finished jobs stay in memory for a while so that a page opened late
* can still replay the whole output.
*/
const KEEP_FINISHED_MS = 15 * 60_000
export class Job extends EventEmitter {
lines: JobLine[] = []
status: AuditStatus = 'running'
constructor(
readonly id: number,
readonly meta: JobMeta
) {
super()
this.setMaxListeners(50)
}
push(line: JobLine) {
this.lines.push(line)
this.emit('line', line)
}
get output() {
return this.lines.map((line) => line.text).join('\n')
}
}
/**
* Runs the long actions (deployments, backups, project creation) in the
* background. Each job is recorded in the action log and its output is
* streamed to the browser.
*/
class JobRunner {
#jobs = new Map<number, Job>()
#locks = new Set<string>()
get(id: number) {
return this.#jobs.get(id)
}
isLocked(lockKey: string) {
return this.#locks.has(lockKey)
}
async start(meta: JobMeta, task: (ctx: JobContext) => Promise<void>): Promise<Job> {
if (meta.lockKey && this.#locks.has(meta.lockKey)) {
throw new UserError('Une action est déjà en cours sur cet élément, attends la fin.', 409)
}
if (meta.lockKey) this.#locks.add(meta.lockKey)
const entry = await AuditLog.create({
action: meta.action,
target: meta.target,
username: meta.username,
ip: meta.ip,
status: 'running',
})
const job = new Job(entry.id, meta)
this.#jobs.set(job.id, job)
const ctx: JobContext = {
log: (text, stream = 'info') => {
for (const line of text.split('\n')) job.push({ ts: Date.now(), stream, text: line })
},
run: (command, args, options) => this.#run(job, command, args, options),
}
/**
* Not awaited: the HTTP request returns the job id right away.
*/
void (async () => {
try {
await task(ctx)
job.status = 'success'
ctx.log('✔ Terminé')
} catch (error) {
job.status = 'error'
ctx.log(`✖ ${error instanceof Error ? error.message : String(error)}`, 'err')
} finally {
if (meta.lockKey) this.#locks.delete(meta.lockKey)
await this.#finish(job, entry)
job.emit('end', job.status)
setTimeout(() => this.#jobs.delete(job.id), KEEP_FINISHED_MS).unref()
}
})()
return job
}
async #finish(job: Job, entry: AuditLog) {
try {
const output = job.output
entry.status = job.status
entry.output =
output.length > MAX_STORED_OUTPUT
? `[…] ${output.slice(output.length - MAX_STORED_OUTPUT)}`
: output
entry.finishedAt = DateTime.now()
await entry.save()
} catch (error) {
logger.error({ err: error }, 'cannot save the job output')
}
}
#run(job: Job, command: string, args: string[], options: RunOptions = {}) {
const okCodes = options.okCodes ?? [0]
job.push({
ts: Date.now(),
stream: 'info',
text: `$ ${options.display ?? [command, ...args.map(quote)].join(' ')}`,
})
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: cleanEnv(options.env),
stdio: ['ignore', 'pipe', 'pipe'],
})
const timeout = setTimeout(
() => {
job.push({ ts: Date.now(), stream: 'err', text: 'Délai dépassé, commande arrêtée.' })
child.kill('SIGKILL')
},
options.timeoutMs ?? 15 * 60_000
)
let fileWritten: Promise<void> = Promise.resolve()
if (options.stdoutFile) {
const file = createWriteStream(options.stdoutFile)
fileWritten = options.gzipStdout
? pipeline(child.stdout, createGzip(), file)
: pipeline(child.stdout, file)
} else {
lineReader(child.stdout, (text) => job.push({ ts: Date.now(), stream: 'out', text }))
}
lineReader(child.stderr, (text) => job.push({ ts: Date.now(), stream: 'err', text }))
child.on('error', (error) => {
clearTimeout(timeout)
reject(new Error(`Impossible de lancer "${command}" : ${error.message}`))
})
child.on('close', async (code) => {
clearTimeout(timeout)
try {
await fileWritten
} catch (error) {
return reject(error)
}
if (code !== null && okCodes.includes(code)) return resolve()
reject(new Error(`La commande "${command}" a échoué (code ${code ?? 'tué'}).`))
})
})
}
}
/**
* Variables passed to the commands. The dashboard's own variables (PORT,
* APP_KEY…) are left out: "docker compose" would use them to fill the
* ${VAR} of the projects' compose files.
*/
const INHERITED_ENV = [
'PATH',
'HOME',
'TZ',
'DOCKER_HOST',
'DOCKER_CONFIG',
// Windows (local development)
'USERPROFILE',
'APPDATA',
'LOCALAPPDATA',
'ProgramData',
'ProgramFiles',
'SystemRoot',
'TEMP',
'TMP',
]
export function cleanEnv(extra: Record<string, string> = {}) {
const env: Record<string, string> = {}
for (const key of INHERITED_ENV) {
if (process.env[key] !== undefined) env[key] = process.env[key]!
}
/**
* Never wait for a password: fail right away instead.
*/
env.GIT_TERMINAL_PROMPT = '0'
return { ...env, ...extra }
}
/**
* Calls the callback for each complete line of the stream.
*/
export function lineReader(stream: NodeJS.ReadableStream, onLine: (line: string) => void) {
let rest = ''
stream.setEncoding('utf8')
stream.on('data', (chunk: string) => {
const lines = (rest + chunk).split(/\r?\n/)
rest = lines.pop() ?? ''
for (const line of lines) onLine(line)
})
stream.on('end', () => {
if (rest) onLine(rest)
rest = ''
})
}
function quote(arg: string) {
return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`
}
const jobRunner = new JobRunner()
export default jobRunner
+262
View File
@@ -0,0 +1,262 @@
import os from 'node:os'
import { EventEmitter } from 'node:events'
import { readFile, statfs } from 'node:fs/promises'
import { join } from 'node:path'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import vpsConfig from '#config/vps'
export interface HostSample {
ts: number
cpu: number
load1: number | null
memUsed: number
memTotal: number
diskUsed: number
diskTotal: number
/**
* Bytes per second (null when not available, e.g. on Windows).
*/
netRx: number | null
netTx: number | null
}
export type HistoryRange = '1h' | '24h' | '7d' | '30d'
/**
* Size of the buckets used to average the history, per range.
*/
const BUCKETS: Record<HistoryRange, { seconds: number; bucket: number }> = {
'1h': { seconds: 3600, bucket: 60 },
'24h': { seconds: 86_400, bucket: 300 },
'7d': { seconds: 7 * 86_400, bucket: 1800 },
'30d': { seconds: 30 * 86_400, bucket: 7200 },
}
const SAMPLE_EVERY_MS = 5000
const LIVE_SAMPLES = 120
const RETENTION_SECONDS = 31 * 86_400
/**
* Virtual interfaces (Docker bridges, veth pairs, loopback) are left out of
* the network traffic so that only the real traffic of the VPS is counted.
*/
const VIRTUAL_INTERFACE = /^(lo|docker\d*|br-|veth|virbr|tun|tap|wg|tailscale)/
/**
* Samples the host every 5 seconds, keeps the last 10 minutes in memory
* for the live charts and stores one averaged row per minute.
*/
class MetricsService extends EventEmitter {
#live: HostSample[] = []
#minute: HostSample[] = []
#previousCpu: { idle: number; total: number } | null = null
#previousNet: { rx: number; tx: number; ts: number } | null = null
#timers: NodeJS.Timeout[] = []
constructor() {
super()
this.setMaxListeners(100)
}
get latest(): HostSample | null {
return this.#live.at(-1) ?? null
}
get live() {
return this.#live
}
start() {
const tick = () =>
this.#sample().catch((error) => logger.warn({ err: error }, 'cannot sample host metrics'))
tick()
this.#timers.push(setInterval(tick, SAMPLE_EVERY_MS))
this.#timers.push(setInterval(() => this.#persist(), 60_000))
this.#timers.push(setInterval(() => this.#prune(), 6 * 3600_000))
}
stop() {
this.#timers.forEach(clearInterval)
}
async uptime(): Promise<number> {
const content = await this.#readProc('uptime')
return content ? Number.parseFloat(content) : os.uptime()
}
async history(range: HistoryRange) {
const { seconds, bucket } = BUCKETS[range]
const since = Math.floor(Date.now() / 1000) - seconds
const rows = await db
.from('metrics')
.select(db.raw('(ts / ?) * ? as t', [bucket, bucket]))
.select(
db.raw(`avg(cpu) as cpu, avg(load1) as load1,
avg(mem_used) as memUsed, max(mem_total) as memTotal,
avg(disk_used) as diskUsed, max(disk_total) as diskTotal,
avg(net_rx) as netRx, avg(net_tx) as netTx`)
)
.where('ts', '>=', since)
.groupBy('t')
.orderBy('t')
return { range, bucket, points: rows }
}
async #sample() {
const [cpu, memory, disk, net, load1] = await Promise.all([
this.#cpu(),
this.#memory(),
this.#disk(),
this.#network(),
this.#load(),
])
const sample: HostSample = { ts: Date.now(), cpu, load1, ...memory, ...disk, ...net }
this.#live.push(sample)
if (this.#live.length > LIVE_SAMPLES) this.#live.shift()
this.#minute.push(sample)
this.emit('sample', sample)
}
async #persist() {
const samples = this.#minute
this.#minute = []
if (!samples.length) return
const average = (pick: (sample: HostSample) => number | null) => {
const values = samples.map(pick).filter((value): value is number => value !== null)
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
}
const last = samples.at(-1)!
try {
await db.table('metrics').insert({
ts: Math.floor(last.ts / 1000),
cpu: average((sample) => sample.cpu) ?? 0,
load1: average((sample) => sample.load1),
mem_used: Math.round(average((sample) => sample.memUsed) ?? 0),
mem_total: last.memTotal,
disk_used: last.diskUsed,
disk_total: last.diskTotal,
net_rx: average((sample) => sample.netRx),
net_tx: average((sample) => sample.netTx),
})
} catch (error) {
logger.error({ err: error }, 'cannot store host metrics')
}
}
async #prune() {
const limit = Math.floor(Date.now() / 1000) - RETENTION_SECONDS
await db
.from('metrics')
.where('ts', '<', limit)
.delete()
.catch(() => {})
}
async #readProc(file: string) {
const root = vpsConfig.hostProc ?? (process.platform === 'linux' ? '/proc' : null)
if (!root) return null
try {
return await readFile(join(root, file), 'utf8')
} catch {
return null
}
}
/**
* CPU usage between two samples, from /proc/stat (or os.cpus()).
*/
async #cpu() {
let idle = 0
let total = 0
const stat = await this.#readProc('stat')
if (stat) {
const fields = stat.split('\n')[0].trim().split(/\s+/).slice(1).map(Number)
/**
* user nice system idle iowait irq softirq steal (guest is already in user)
*/
const [user, nice, system, idleTime, iowait, irq, softirq, steal] = fields
idle = idleTime + (iowait ?? 0)
total =
user + nice + system + idleTime + (iowait ?? 0) + (irq ?? 0) + (softirq ?? 0) + (steal ?? 0)
} else {
for (const cpu of os.cpus()) {
idle += cpu.times.idle
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq
}
}
const previous = this.#previousCpu
this.#previousCpu = { idle, total }
if (!previous || total <= previous.total) return 0
const usage = (1 - (idle - previous.idle) / (total - previous.total)) * 100
return Math.max(0, Math.min(100, usage))
}
async #memory() {
const meminfo = await this.#readProc('meminfo')
if (meminfo) {
const read = (key: string) => {
const match = new RegExp(`^${key}:\\s+(\\d+)`, 'm').exec(meminfo)
return match ? Number(match[1]) * 1024 : 0
}
const memTotal = read('MemTotal')
return { memTotal, memUsed: memTotal - read('MemAvailable') }
}
return { memTotal: os.totalmem(), memUsed: os.totalmem() - os.freemem() }
}
async #disk() {
try {
const stats = await statfs(vpsConfig.diskPath)
const used = (stats.blocks - stats.bfree) * stats.bsize
const available = stats.bavail * stats.bsize
/**
* Same computation as "df": the space reserved for root is left out.
*/
return { diskUsed: used, diskTotal: used + available }
} catch {
return { diskUsed: 0, diskTotal: 0 }
}
}
/**
* Network traffic of the host. With HOST_PROC, /proc/1/net/dev is the
* network namespace of the host (not the one of the container).
*/
async #network(): Promise<{ netRx: number | null; netTx: number | null }> {
const content = await this.#readProc(vpsConfig.hostProc ? '1/net/dev' : 'net/dev')
if (!content) return { netRx: null, netTx: null }
let rx = 0
let tx = 0
for (const line of content.split('\n').slice(2)) {
const [name, data] = line.split(':')
if (!data || VIRTUAL_INTERFACE.test(name.trim())) continue
const fields = data.trim().split(/\s+/).map(Number)
rx += fields[0]
tx += fields[8]
}
const now = Date.now()
const previous = this.#previousNet
this.#previousNet = { rx, tx, ts: now }
if (!previous || rx < previous.rx || tx < previous.tx) return { netRx: null, netTx: null }
const seconds = (now - previous.ts) / 1000
return { netRx: (rx - previous.rx) / seconds, netTx: (tx - previous.tx) / seconds }
}
async #load() {
const content = await this.#readProc('loadavg')
if (content) return Number.parseFloat(content)
return process.platform === 'win32' ? null : os.loadavg()[0]
}
}
const metricsService = new MetricsService()
export default metricsService
+629
View File
@@ -0,0 +1,629 @@
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { promisify } from 'node:util'
import { join, resolve } from 'node:path'
import vpsConfig from '#config/vps'
import UserError from '#exceptions/user_error'
import caddyService from '#services/caddy_service'
import dockerService, { type ContainerSummary } from '#services/docker_service'
import jobRunner, { cleanEnv, type JobContext, type JobMeta } from '#services/job_runner'
import {
composeFile,
placeholderPage,
proxyBlock,
staticBlock,
type ProjectType,
} from '#services/templates'
const execFileAsync = promisify(execFile)
const COMPOSE_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']
const SEGMENT = /^[\w][\w.-]*$/
export type ProjectKind = 'compose' | 'static' | 'infra'
export interface GitCommit {
hash: string
short: string
subject: string
author: string
date: string
}
export interface GitInfo {
repoPath: string
branch: string | null
detached: boolean
upstream: string | null
ahead: number
behind: number
/**
* Number of tracked files modified on the server.
*/
dirty: number
commit: GitCommit | null
}
export interface ProjectContainer {
id: string
name: string
state: string
status: string
health: string | null
}
export interface Project {
category: string
name: string
kind: ProjectKind
path: string
composeFile: string | null
git: GitInfo | null
containers: ProjectContainer[]
/**
* The project of the dashboard itself.
*/
self: boolean
updatedAt: number | null
}
export const PROJECT_ACTIONS = [
'deploy',
'restart',
'stop',
'start',
'recreate',
'pull',
'checkout',
'checkout-branch',
] as const
export type ProjectAction = (typeof PROJECT_ACTIONS)[number]
export const ACTION_LABELS: Record<ProjectAction, string> = {
'deploy': 'Mise à jour (git pull + redémarrage)',
'restart': 'Redémarrage',
'stop': 'Arrêt',
'start': 'Démarrage',
'recreate': 'Recréation des conteneurs',
'pull': 'Mise à jour des images Docker',
'checkout': 'Retour à un commit',
'checkout-branch': 'Retour sur la branche',
}
export interface CreateProjectInput {
type: ProjectType
name: string
category: string
gitUrl?: string
port?: number
command?: string
env?: string
createDatabase: boolean
domain: string
addCaddy: boolean
spa: boolean
}
async function exists(path: string) {
try {
await access(path)
return true
} catch {
return false
}
}
async function directories(path: string) {
try {
const entries = await readdir(path, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b))
} catch {
return []
}
}
async function findComposeFile(dir: string) {
for (const file of COMPOSE_FILES) {
if (await exists(join(dir, file))) return file
}
return null
}
function git(repo: string, args: string[], timeout = 15_000) {
return execFileAsync('git', ['-C', repo, ...args], {
timeout,
env: cleanEnv(),
maxBuffer: 10 * 1024 * 1024,
}).then(({ stdout }) => stdout.trim())
}
/**
* Hides the credentials of a git URL ("https://user:token@host/…").
*/
export function hideCredentials(url: string) {
return url.replace(/\/\/[^/@\s]+@/, '//***@')
}
function samePath(a: string, b: string) {
const normalize = (path: string) =>
resolve(path)
.replace(/[\\/]+$/, '')
.toLowerCase()
return normalize(a) === normalize(b)
}
function randomSecret(length: number) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
return Array.from(randomBytes(length), (byte) => alphabet[byte % alphabet.length]).join('')
}
class ProjectService {
/**
* Scans /srv: "html/*" are static sites, folders with their own compose
* file are infrastructure, the others hold one project per sub-folder.
*/
async list(): Promise<Project[]> {
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
const root = vpsConfig.srvRoot
const found: { category: string; name: string; path: string; kind: ProjectKind }[] = []
for (const folder of await directories(root)) {
const path = join(root, folder)
if (folder === vpsConfig.staticCategory) {
for (const name of await directories(path)) {
found.push({ category: folder, name, path: join(path, name), kind: 'static' })
}
} else if (await findComposeFile(path)) {
found.push({ category: 'infra', name: folder, path, kind: 'infra' })
} else {
for (const name of await directories(path)) {
if (await findComposeFile(join(path, name))) {
found.push({ category: folder, name, path: join(path, name), kind: 'compose' })
}
}
}
}
return Promise.all(found.map((entry) => this.#build(entry, containers)))
}
async find(category: string, name: string): Promise<Project> {
if (!SEGMENT.test(category) || !SEGMENT.test(name)) {
throw new UserError('Projet introuvable.', 404)
}
const root = vpsConfig.srvRoot
let path: string
let kind: ProjectKind
if (category === 'infra') {
path = join(root, name)
kind = 'infra'
} else if (category === vpsConfig.staticCategory) {
path = join(root, category, name)
kind = 'static'
} else {
path = join(root, category, name)
kind = 'compose'
}
const valid = kind === 'static' ? await exists(path) : (await findComposeFile(path)) !== null
if (!valid) throw new UserError('Projet introuvable.', 404)
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
return this.#build({ category, name, path, kind }, containers)
}
async #build(
entry: { category: string; name: string; path: string; kind: ProjectKind },
containers: ContainerSummary[]
): Promise<Project> {
const selfDir = containers.find((container) => container.self)?.composeWorkingDir ?? null
const project: Project = {
...entry,
composeFile: entry.kind === 'static' ? null : await findComposeFile(entry.path),
git: null,
containers: containers
.filter((c) => c.composeWorkingDir && samePath(c.composeWorkingDir, entry.path))
.map(({ id, name, state, status, health }) => ({ id, name, state, status, health })),
self: selfDir !== null && samePath(selfDir, entry.path),
updatedAt: null,
}
if (entry.kind === 'static') {
const index = join(entry.path, 'index.html')
const info = await stat((await exists(index)) ? index : entry.path).catch(() => null)
project.updatedAt = info ? Math.floor(info.mtimeMs / 1000) : null
} else {
const repoPath = (await exists(join(entry.path, 'project', '.git')))
? join(entry.path, 'project')
: (await exists(join(entry.path, '.git')))
? entry.path
: null
if (repoPath) project.git = await this.#gitInfo(repoPath).catch(() => null)
}
return project
}
async #gitInfo(repoPath: string): Promise<GitInfo> {
const [status, log] = await Promise.all([
git(repoPath, ['status', '--porcelain=v2', '--branch']),
git(repoPath, ['log', '-1', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI']).catch(() => ''),
])
const info: GitInfo = {
repoPath,
branch: null,
detached: false,
upstream: null,
ahead: 0,
behind: 0,
dirty: 0,
commit: parseCommit(log),
}
for (const line of status.split('\n')) {
if (line.startsWith('# branch.head ')) {
const head = line.slice('# branch.head '.length)
info.detached = head === '(detached)'
info.branch = info.detached ? null : head
} else if (line.startsWith('# branch.upstream ')) {
info.upstream = line.slice('# branch.upstream '.length)
} else if (line.startsWith('# branch.ab ')) {
const [ahead, behind] = line.slice('# branch.ab '.length).split(' ')
info.ahead = Math.abs(Number(ahead))
info.behind = Math.abs(Number(behind))
} else if (/^[12u] /.test(line)) {
info.dirty++
}
}
return info
}
/**
* Branch followed by the remote ("main" most of the time).
*/
async #defaultBranch(repoPath: string) {
const head = await git(repoPath, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).catch(
() => ''
)
if (head) return head.replace(/^origin\//, '')
for (const branch of ['main', 'master']) {
const ok = await git(repoPath, ['rev-parse', '--verify', '--quiet', `origin/${branch}`])
.then(() => true)
.catch(() => false)
if (ok) return branch
}
return null
}
/**
* Everything shown on the project page.
*/
async details(project: Project) {
let compose: string | null = null
if (project.composeFile) {
compose = await readFile(join(project.path, project.composeFile), 'utf8').catch(() => null)
}
if (!project.git) return { ...project, compose, remote: null, defaultBranch: null, commits: [] }
const repo = project.git.repoPath
const [remote, defaultBranch] = await Promise.all([
git(repo, ['remote', 'get-url', 'origin']).catch(() => null),
this.#defaultBranch(repo),
])
/**
* When the project is on an older commit (detached), the commits of the
* remote branch are listed, to be able to come back to a newer one.
*/
const ref =
!project.git.detached && project.git.upstream
? project.git.upstream
: defaultBranch
? `origin/${defaultBranch}`
: 'HEAD'
const log = await git(repo, ['log', '-30', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI', ref]).catch(
() => ''
)
return {
...project,
compose,
remote: remote ? hideCredentials(remote) : null,
defaultBranch,
commits: log
.split('\n')
.map(parseCommit)
.filter((commit): commit is GitCommit => commit !== null),
}
}
/**
* "git fetch" to know if new commits are waiting.
*/
async fetch(project: Project) {
if (!project.git) throw new UserError('Ce projet n’a pas de dépôt git.')
try {
await git(project.git.repoPath, ['fetch', '--prune', 'origin'], 60_000)
} catch (error: any) {
throw new UserError(`git fetch a échoué : ${(error?.stderr || error?.message || '').trim()}`)
}
return this.#gitInfo(project.git.repoPath)
}
async runAction(
project: Project,
action: ProjectAction,
params: { commit?: string },
meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>
) {
if (project.kind === 'static') {
throw new UserError(
'Un site statique se met à jour en envoyant son build (voir la procédure).'
)
}
if (['deploy', 'checkout', 'checkout-branch'].includes(action) && !project.git) {
throw new UserError('Ce projet n’a pas de dépôt git.')
}
if (action === 'stop' && project.self) {
throw new UserError('Impossible d’arrêter le tableau de bord depuis lui-même.')
}
if (action === 'checkout' && !/^[0-9a-f]{7,40}$/.test(params.commit ?? '')) {
throw new UserError('Commit invalide.')
}
const repo = project.git?.repoPath ?? project.path
const compose = (ctx: JobContext, args: string[]) => this.#compose(ctx, project, args)
const recreate = (ctx: JobContext) => compose(ctx, ['up', '-d', '--build', '--force-recreate'])
return jobRunner.start(
{
...meta,
action: ACTION_LABELS[action],
target: `${project.category}/${project.name}`,
lockKey: `project:${project.path}`,
},
async (ctx) => {
switch (action) {
case 'deploy':
if (project.git?.detached) {
throw new Error(
'Le projet est sur un ancien commit : utilise d’abord « Revenir sur la branche ».'
)
}
await ctx.run('git', ['pull', '--ff-only'], { cwd: repo })
await recreate(ctx)
break
case 'restart':
await compose(ctx, ['restart'])
break
case 'stop':
await compose(ctx, ['stop'])
break
case 'start':
await compose(ctx, ['up', '-d'])
break
case 'recreate':
await recreate(ctx)
break
case 'pull':
await ctx.run('docker', ['compose', 'pull'], { cwd: project.path })
await compose(ctx, ['up', '-d'])
break
case 'checkout':
await ctx.run('git', ['checkout', '--detach', params.commit!], { cwd: repo })
await recreate(ctx)
break
case 'checkout-branch': {
const branch = await this.#defaultBranch(repo)
if (!branch) throw new Error('Branche distante introuvable.')
await ctx.run('git', ['checkout', branch], { cwd: repo })
await ctx.run('git', ['pull', '--ff-only'], { cwd: repo })
await recreate(ctx)
break
}
}
}
)
}
/**
* Runs "docker compose" in the project folder. For the dashboard itself,
* the command runs in a separate container: recreating the dashboard
* container would otherwise kill the command half-way.
*/
async #compose(ctx: JobContext, project: Project, args: string[]) {
if (!project.self) {
await ctx.run('docker', ['compose', ...args], { cwd: project.path })
return
}
ctx.log('Projet du tableau de bord : la commande est lancée dans un conteneur à part.')
await ctx.run('docker', [
'run',
'--rm',
'-d',
'--name',
`big-brother-updater-${Date.now()}`,
'-v',
'/var/run/docker.sock:/var/run/docker.sock',
'-v',
`${project.path}:${project.path}`,
'-w',
project.path,
'docker:cli',
'docker',
'compose',
...args,
])
ctx.log('Le tableau de bord va redémarrer : la page se reconnectera toute seule.')
}
/**
* Creates a project the way the procedure does it: folder, git clone,
* .env, database, docker-compose.yml, start, then the Caddy block.
*/
async create(input: CreateProjectInput, meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>) {
const isStatic = input.type === 'static'
const category = isStatic ? vpsConfig.staticCategory : input.category
const categoryPath = join(vpsConfig.srvRoot, category)
const path = join(categoryPath, input.name)
if (await exists(path)) throw new UserError(`Le dossier ${path} existe déjà.`)
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
if (!isStatic && containers.some((container) => container.name === input.name)) {
throw new UserError(`Un conteneur s’appelle déjà « ${input.name} ».`)
}
if (input.addCaddy) {
const sites = await caddyService.sites()
if (sites.some((site) => site.domains.includes(input.domain))) {
throw new UserError(`${input.domain} est déjà dans le Caddyfile.`)
}
}
return jobRunner.start(
{
...meta,
action: 'Création de projet',
target: `${category}/${input.name}`,
lockKey: `create:${path}`,
},
async (ctx) => {
await mkdir(categoryPath, { recursive: true })
await mkdir(path)
ctx.log(`Dossier créé : ${path}`)
if (isStatic) {
await writeFile(join(path, 'index.html'), placeholderPage(input.name))
ctx.log('Page d’attente créée : envoie ensuite le build du site dans ce dossier.')
} else {
await this.#createApp(ctx, input, path)
}
if (input.addCaddy) {
const block = isStatic
? staticBlock(input.domain, `${vpsConfig.srvRoot}/${category}/${input.name}`, input.spa)
: proxyBlock(
input.domain,
`${input.name}:${input.type === 'wordpress' ? 80 : input.port}`
)
ctx.log(`Ajout au Caddyfile :\n${block}`)
const result = await caddyService.appendBlock(block, {
username: meta.username,
reason: `Création de ${input.name}`,
})
if (result.output) ctx.log(result.output, result.ok ? 'out' : 'err')
if (!result.ok)
throw new Error('Caddy a refusé la configuration, le Caddyfile est inchangé.')
ctx.log(`Caddy rechargé : https://${input.domain}`)
}
}
)
}
async #createApp(ctx: JobContext, input: CreateProjectInput, path: string) {
const type = input.type as Exclude<ProjectType, 'static'>
const isWordpress = type === 'wordpress'
if (!isWordpress) {
await ctx.run('git', ['clone', input.gitUrl!, 'project'], {
cwd: path,
display: `git clone ${hideCredentials(input.gitUrl!)} project`,
})
}
let database: { name: string; user: string; password: string } | null = null
if (isWordpress || input.createDatabase) {
const dbName = input.name.replace(/-/g, '_')
database = { name: dbName, user: dbName, password: randomSecret(24) }
await this.#createDatabase(ctx, database)
}
const envLines: string[] = []
if (isWordpress) {
envLines.push(
`WP_DB_NAME=${database!.name}`,
`WP_DB_USER=${database!.user}`,
`WP_DB_PASSWORD=${database!.password}`
)
await writeFile(join(path, '.env'), `${envLines.join('\n')}\n`, { mode: 0o600 })
} else {
const env = (input.env ?? '').replace(/\r\n/g, '\n').trim()
const has = (key: string) => new RegExp(`^${key}=`, 'm').test(env)
if (env) envLines.push(env)
if (type === 'adonis') {
if (!has('APP_KEY')) envLines.push(`APP_KEY=${randomSecret(32)}`)
if (!has('LOG_LEVEL')) envLines.push('LOG_LEVEL=info')
if (!has('APP_URL')) envLines.push(`APP_URL=https://${input.domain}`)
}
if (database) {
envLines.push(
'DB_HOST=mariadb',
'DB_PORT=3306',
`DB_USER=${database.user}`,
`DB_PASSWORD=${database.password}`,
`DB_DATABASE=${database.name}`
)
}
await writeFile(join(path, 'project', '.env'), `${envLines.join('\n')}\n`, { mode: 0o600 })
}
ctx.log('Fichier .env écrit.')
await writeFile(
join(path, 'docker-compose.yml'),
composeFile(type, input.name, input.port ?? 80, input.command)
)
ctx.log('docker-compose.yml écrit.')
await ctx.run('docker', ['compose', 'up', '-d'], { cwd: path })
}
/**
* One database and one user per project, created with the root password
* of the MariaDB container (read inside the container, never here).
*/
async #createDatabase(
ctx: JobContext,
database: { name: string; user: string; password: string }
) {
const sql = [
`CREATE DATABASE \`${database.name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`,
`CREATE USER '${database.user}'@'%' IDENTIFIED BY '${database.password}';`,
`GRANT ALL PRIVILEGES ON \`${database.name}\`.* TO '${database.user}'@'%';`,
].join(' ')
await ctx.run(
'docker',
[
'exec',
vpsConfig.mariadb.container,
'sh',
'-c',
'exec "$(command -v mariadb || command -v mysql)" -u root -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" -e "$0"',
sql,
],
{
display: `docker exec ${vpsConfig.mariadb.container} mariadb -e "CREATE DATABASE ${database.name} … (utilisateur ${database.user})"`,
}
)
ctx.log(`Base « ${database.name} » et utilisateur créés (mot de passe dans le .env).`)
}
}
function parseCommit(line: string): GitCommit | null {
const [hash, short, subject, author, date] = line.split('\x1f')
if (!hash || !short) return null
return { hash, short, subject, author, date }
}
const projectService = new ProjectService()
export default projectService
+277
View File
@@ -0,0 +1,277 @@
import tls from 'node:tls'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import Site from '#models/site'
import caddyService from '#services/caddy_service'
const CHECK_EVERY_MS = 60_000
const REQUEST_TIMEOUT_MS = 10_000
const CERT_CHECK_EVERY_S = 6 * 3600
const RETENTION_SECONDS = 31 * 86_400
const CONCURRENCY = 5
export interface SiteCheckResult {
ok: boolean
status: number | null
responseMs: number | null
error: string | null
}
/**
* Checks every minute that each domain of the Caddyfile answers, and
* twice a day when its HTTPS certificate expires.
*
* The checks run from the VPS itself: they spot a stopped container or a
* broken configuration, not the VPS being down.
*/
class SiteMonitor {
#timers: NodeJS.Timeout[] = []
#running = false
start() {
const tick = () =>
this.checkAll().catch((error) => logger.warn({ err: error }, 'site checks failed'))
setTimeout(tick, 3000)
this.#timers.push(setInterval(tick, CHECK_EVERY_MS))
this.#timers.push(setInterval(() => this.#prune(), 6 * 3600_000))
}
stop() {
this.#timers.forEach(clearInterval)
}
/**
* Adds the new domains of the Caddyfile and flags the removed ones.
*/
async sync() {
const caddySites = await caddyService.sites()
const seen = new Set<string>()
for (const caddySite of caddySites) {
for (const domain of caddySite.domains) {
if (seen.has(domain)) continue
seen.add(domain)
await Site.updateOrCreate(
{ domain },
{ active: true, kind: caddySite.kind, upstream: caddySite.upstream }
)
}
}
const query = Site.query().where('active', true)
if (seen.size) query.whereNotIn('domain', [...seen])
await query.update({ active: false })
}
async checkAll() {
if (this.#running) return
this.#running = true
try {
try {
await this.sync()
} catch (error) {
logger.warn({ err: error }, 'cannot read the Caddyfile')
}
const sites = await Site.query().where('active', true)
const queue = [...sites]
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
let site: Site | undefined
while ((site = queue.shift())) await this.check(site)
})
)
} finally {
this.#running = false
}
}
async check(site: Site) {
const result = await probe(site.domain)
const now = Math.floor(Date.now() / 1000)
await db.table('site_checks').insert({
site_id: site.id,
ts: now,
ok: result.ok,
status: result.status,
response_ms: result.responseMs,
error: result.error,
})
site.lastOk = result.ok
site.lastStatus = result.status
site.lastResponseMs = result.responseMs
site.lastError = result.error
site.lastCheckedAt = now
if (!site.certCheckedAt || now - site.certCheckedAt > CERT_CHECK_EVERY_S) {
site.certExpiresAt = await certificateExpiry(site.domain)
site.certCheckedAt = now
}
await site.save()
return site
}
/**
* Uptime (%) and average response time of every site over a period.
*/
async stats(sinceSeconds: number) {
const since = Math.floor(Date.now() / 1000) - sinceSeconds
const rows = await db
.from('site_checks')
.select('site_id')
.select(
db.raw('avg(ok) * 100 as uptime, avg(response_ms) as avgResponseMs, count(*) as checks')
)
.where('ts', '>=', since)
.groupBy('site_id')
return new Map<number, { uptime: number; avgResponseMs: number | null; checks: number }>(
rows.map((row) => [
row.site_id,
{
uptime: Number(row.uptime),
avgResponseMs: row.avgResponseMs,
checks: Number(row.checks),
},
])
)
}
/**
* Response times over a period (averaged per bucket) and the incidents
* (consecutive failed checks).
*/
async history(siteId: number, sinceSeconds: number, bucket: number) {
const since = Math.floor(Date.now() / 1000) - sinceSeconds
const points = await db
.from('site_checks')
.select(db.raw('(ts / ?) * ? as t', [bucket, bucket]))
.select(db.raw('avg(response_ms) as responseMs, min(ok) as ok, count(*) as checks'))
.where('site_id', siteId)
.where('ts', '>=', since)
.groupBy('t')
.orderBy('t')
const checks = await db
.from('site_checks')
.select('ts', 'ok', 'status', 'error')
.where('site_id', siteId)
.where('ts', '>=', since)
.orderBy('ts')
const incidents: { start: number; end: number | null; error: string | null; checks: number }[] =
[]
let open: (typeof incidents)[number] | null = null
for (const check of checks) {
if (!check.ok) {
if (!open) {
open = {
start: check.ts,
end: null,
error: check.error ?? `HTTP ${check.status}`,
checks: 0,
}
incidents.push(open)
}
open.checks++
} else if (open) {
open.end = check.ts
open = null
}
}
return {
points: points.map((point) => ({ ...point, ok: Boolean(point.ok) })),
incidents: incidents.reverse(),
}
}
async #prune() {
const limit = Math.floor(Date.now() / 1000) - RETENTION_SECONDS
await db
.from('site_checks')
.where('ts', '<', limit)
.delete()
.catch(() => {})
}
}
/**
* A site is "up" when it answers with a status below 500: an API without
* route on "/" answers 404 but works, while a stopped container gives 502.
*/
async function probe(domain: string): Promise<SiteCheckResult> {
const started = performance.now()
try {
const response = await fetch(`https://${domain}/`, {
redirect: 'manual',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
headers: { 'user-agent': 'big-brother-monitor' },
})
const responseMs = Math.round(performance.now() - started)
await response.body?.cancel()
return {
ok: response.status < 500,
status: response.status,
responseMs,
error: response.status < 500 ? null : `HTTP ${response.status}`,
}
} catch (error: any) {
return { ok: false, status: null, responseMs: null, error: describeFetchError(error) }
}
}
function describeFetchError(error: any): string {
if (error?.name === 'TimeoutError') return `Pas de réponse en ${REQUEST_TIMEOUT_MS / 1000} s`
const code = error?.cause?.code ?? error?.code
switch (code) {
case 'ENOTFOUND':
return 'Domaine introuvable (DNS)'
case 'ECONNREFUSED':
return 'Connexion refusée'
case 'ECONNRESET':
return 'Connexion interrompue'
case 'CERT_HAS_EXPIRED':
return 'Certificat HTTPS expiré'
case 'ERR_TLS_CERT_ALTNAME_INVALID':
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
return 'Certificat HTTPS invalide'
case 'UND_ERR_CONNECT_TIMEOUT':
return 'Délai de connexion dépassé'
default:
return error?.cause?.message ?? error?.message ?? 'Erreur inconnue'
}
}
/**
* Expiry date (unix seconds) of the certificate served for the domain.
*/
function certificateExpiry(domain: string): Promise<number | null> {
return new Promise((resolve) => {
const socket = tls.connect({
host: domain,
port: 443,
servername: domain,
rejectUnauthorized: false,
timeout: REQUEST_TIMEOUT_MS,
})
const done = (value: number | null) => {
socket.destroy()
resolve(value)
}
socket.once('secureConnect', () => {
const validTo = socket.getPeerCertificate()?.valid_to
done(validTo ? Math.floor(Date.parse(validTo) / 1000) : null)
})
socket.once('timeout', () => done(null))
socket.once('error', () => done(null))
})
}
const siteMonitor = new SiteMonitor()
export default siteMonitor
+65
View File
@@ -0,0 +1,65 @@
import { PassThrough } from 'node:stream'
import type { HttpContext } from '@adonisjs/core/http'
export interface SseChannel {
/**
* Sends one event to the browser. Ignored once the connection is closed.
*/
send(event: string, data: unknown): void
/**
* Registers a callback invoked once, when the browser disconnects or the
* channel is closed by the server.
*/
onClose(callback: () => void): void
close(): void
}
/**
* Turns the response into a Server-Sent Events stream (used with
* EventSource on the Angular side for the live metrics, logs and jobs).
*/
export function openSse({ response }: HttpContext): SseChannel {
const stream = new PassThrough()
const closeCallbacks: (() => void)[] = []
let closed = false
response.header('Content-Type', 'text/event-stream; charset=utf-8')
response.header('Cache-Control', 'no-cache, no-transform')
response.header('Connection', 'keep-alive')
response.header('X-Accel-Buffering', 'no')
response.stream(stream)
/**
* Comments keep the connection open through Caddy and let the browser
* know quickly that the stream is established.
*/
stream.write('retry: 5000\n\n')
const heartbeat = setInterval(() => stream.write(': ping\n\n'), 25_000)
const close = () => {
if (closed) return
closed = true
clearInterval(heartbeat)
for (const callback of closeCallbacks) callback()
stream.end()
}
/**
* "close" on the response (not the request) is emitted when the browser
* goes away.
*/
response.response.on('close', close)
return {
send(event, data) {
if (closed) return
stream.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
},
onClose(callback) {
closeCallbacks.push(callback)
},
close,
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Templates of the deployment procedure (procédure.md), used when a
* project is created from the dashboard.
*/
export type ProjectType = 'node' | 'adonis' | 'wordpress' | 'static'
const LOGGING = ` logging:
options:
max-size: "10m"
max-file: "3"`
const NETWORKS = `networks:
bdd_network:
external: true
caddy_network:
external: true`
function nodeService(service: string, name: string, port: number, command: string) {
return `services:
${service}:
image: node:22-alpine3.21
container_name: ${name}
working_dir: /app
volumes:
- ./project:/app
command: sh -c "${command.replace(/"/g, '\\"')}"
env_file: ./project/.env
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=${port}
networks:
- bdd_network
- caddy_network
restart: unless-stopped
${LOGGING}
${NETWORKS}
`
}
export const DEFAULT_NODE_COMMAND = 'npm ci --omit=dev && node start.js'
export const ADONIS_COMMAND =
'npm ci --include=dev && node ace build && cd build && npm ci --omit=dev && node bin/server.js'
export function composeFile(
type: Exclude<ProjectType, 'static'>,
name: string,
port: number,
command?: string
) {
switch (type) {
case 'node':
return nodeService('nodejs', name, port, command || DEFAULT_NODE_COMMAND)
case 'adonis':
return nodeService('adonis', name, port, ADONIS_COMMAND)
case 'wordpress':
return `services:
wordpress:
image: wordpress:php8.5-apache
container_name: ${name}
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: mariadb:3306
WORDPRESS_DB_NAME: \${WP_DB_NAME}
WORDPRESS_DB_USER: \${WP_DB_USER}
WORDPRESS_DB_PASSWORD: \${WP_DB_PASSWORD}
volumes:
- ./wp_data:/var/www/html
networks:
- caddy_network
- bdd_network
${LOGGING}
${NETWORKS}
`
}
}
export function proxyBlock(domain: string, upstream: string) {
return `${domain} {
\treverse_proxy ${upstream}
}`
}
export function staticBlock(domain: string, root: string, spa: boolean) {
return `${domain} {
\troot * ${root}
\tencode gzip
\tfile_server${spa ? '\n\ttry_files {path} /index.html' : ''}
}`
}
export function placeholderPage(name: string) {
return `<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"><title>${name}</title></head>
<body style="font-family: system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; margin: 0">
<p>${name} : site en cours de déploiement.</p>
</body>
</html>
`
}
+26
View File
@@ -0,0 +1,26 @@
import QRCode from 'qrcode'
import type { BaseCommand } from '@adonisjs/core/ace'
import totpService from '#services/totp_service'
/**
* Shows the QR code of a new TOTP secret, then asks for a code to make
* sure the authenticator app is set up. Returns the encrypted secret.
*/
export async function enrollTotp(command: BaseCommand, username: string): Promise<string | null> {
const totp = totpService.generate(username)
command.logger.info(
'Scanne ce QR code avec ton application de double authentification (Aegis, 2FAS, Google Authenticator…) :'
)
console.log(await QRCode.toString(totp.uri, { type: 'terminal', small: true }))
command.logger.info(`Ou saisis la clé à la main : ${totp.base32}`)
for (let attempt = 1; attempt <= 3; attempt++) {
const code = await command.prompt.ask('Code à 6 chiffres affiché par l’application')
if (totpService.check(totp.encrypted, username, code.trim()) !== null) return totp.encrypted
command.logger.warning('Code incorrect, réessaie (vérifie l’heure du téléphone).')
}
command.logger.error('Trop d’essais : rien n’a été enregistré.')
return null
}
+56
View File
@@ -0,0 +1,56 @@
import { Secret, TOTP } from 'otpauth'
import encryption from '@adonisjs/core/services/encryption'
import type User from '#models/user'
const ISSUER = 'Big Brother'
const PERIOD = 30
function build(secret: Secret, username: string) {
return new TOTP({ issuer: ISSUER, label: username, secret, period: PERIOD, digits: 6 })
}
/**
* Two-factor authentication with a 6-digit code (Google Authenticator,
* Aegis, 2FAS…). The secret is stored encrypted with the APP_KEY.
*/
const totpService = {
/**
* New secret, its encrypted form for the database and the otpauth://
* URI shown as a QR code.
*/
generate(username: string) {
const secret = new Secret({ size: 20 })
return {
encrypted: encryption.encrypt(secret.base32),
base32: secret.base32,
uri: build(secret, username).toString(),
}
},
/**
* Checks a code against the given encrypted secret, accepting the
* previous and next periods (clock drift). Returns the time step used.
*/
check(encryptedSecret: string, username: string, code: string): number | null {
const base32 = encryption.decrypt<string>(encryptedSecret)
if (!base32 || !/^\d{6}$/.test(code)) return null
const totp = build(Secret.fromBase32(base32), username)
const delta = totp.validate({ token: code, window: 1 })
if (delta === null) return null
return Math.floor(Date.now() / 1000 / PERIOD) + delta
},
/**
* Checks the code of a user and refuses a code already used (replay).
*/
async verify(user: User, code: string) {
const step = this.check(user.totpSecret, user.username, code)
if (step === null) return false
if (user.totpLastStep !== null && step <= user.totpLastStep) return false
user.totpLastStep = step
await user.save()
return true
},
}
export default totpService
+10
View File
@@ -0,0 +1,10 @@
import vine from '@vinejs/vine'
export const loginValidator = vine.create({
username: vine.string().trim().maxLength(64),
password: vine.string().maxLength(256),
code: vine
.string()
.trim()
.regex(/^\d{6}$/),
})
+6
View File
@@ -0,0 +1,6 @@
import vine from '@vinejs/vine'
export const updateCaddyfileValidator = vine.create({
content: vine.string().minLength(1).maxLength(500_000),
reason: vine.string().trim().maxLength(200).optional(),
})
+37
View File
@@ -0,0 +1,37 @@
import vine from '@vinejs/vine'
const NAME = /^[a-z0-9][a-z0-9-]{0,40}$/
const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/
const GIT_URL = /^(https?:\/\/|ssh:\/\/|git@)[^\s]+$/
export const createProjectValidator = vine.create({
type: vine.enum(['node', 'adonis', 'wordpress', 'static'] as const),
name: vine.string().trim().toLowerCase().regex(NAME),
category: vine.enum(['api', 'frontend', 'fullstack', 'wordpress'] as const),
gitUrl: vine
.string()
.trim()
.regex(GIT_URL)
.optional()
.requiredWhen('type', 'in', ['node', 'adonis']),
port: vine
.number()
.withoutDecimals()
.range([1, 65535])
.optional()
.requiredWhen('type', 'in', ['node', 'adonis']),
command: vine.string().trim().maxLength(500).optional(),
env: vine.string().maxLength(20_000).optional(),
createDatabase: vine.boolean(),
domain: vine.string().trim().toLowerCase().regex(DOMAIN),
addCaddy: vine.boolean(),
spa: vine.boolean(),
})
export const projectActionValidator = vine.create({
commit: vine
.string()
.trim()
.regex(/^[0-9a-f]{7,40}$/)
.optional(),
})
+47
View File
@@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core/ignitor')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+45
View File
@@ -0,0 +1,45 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core/ignitor')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+62
View File
@@ -0,0 +1,62 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core/ignitor'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})
+51
View File
@@ -0,0 +1,51 @@
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
/**
* Creates the account used to log in. There is no sign-up page: the only
* way to create an account is this command, run on the VPS:
*
* docker exec -it big-brother node ace user:create
*/
export default class UserCreate extends BaseCommand {
static commandName = 'user:create'
static description = 'Créer un compte (mot de passe + double authentification)'
static options: CommandOptions = {
startApp: true,
}
async run() {
const { default: User } = await import('#models/user')
const { enrollTotp } = await import('#services/totp_enrollment')
const username = await this.prompt.ask('Identifiant', {
validate: (value) =>
/^[\w.-]{3,64}$/.test(value) || '3 à 64 caractères : lettres, chiffres, . _ -',
})
if (await User.findBy('username', username)) {
this.logger.error(`Le compte « ${username} » existe déjà.`)
this.exitCode = 1
return
}
const password = await this.prompt.secure('Mot de passe (12 caractères minimum)', {
validate: (value) => value.length >= 12 || 'Au moins 12 caractères',
})
const confirmation = await this.prompt.secure('Confirme le mot de passe')
if (password !== confirmation) {
this.logger.error('Les mots de passe ne correspondent pas.')
this.exitCode = 1
return
}
const totpSecret = await enrollTotp(this, username)
if (!totpSecret) {
this.exitCode = 1
return
}
await User.create({ username, password, totpSecret })
this.logger.success(`Compte « ${username} » créé. Tu peux te connecter.`)
}
}
+39
View File
@@ -0,0 +1,39 @@
import { BaseCommand, args } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class UserPassword extends BaseCommand {
static commandName = 'user:password'
static description = 'Changer le mot de passe d’un compte'
static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Identifiant du compte' })
declare username: string
async run() {
const { default: User } = await import('#models/user')
const user = await User.findBy('username', this.username)
if (!user) {
this.logger.error(`Compte « ${this.username} » introuvable.`)
this.exitCode = 1
return
}
const password = await this.prompt.secure('Nouveau mot de passe (12 caractères minimum)', {
validate: (value) => value.length >= 12 || 'Au moins 12 caractères',
})
const confirmation = await this.prompt.secure('Confirme le mot de passe')
if (password !== confirmation) {
this.logger.error('Les mots de passe ne correspondent pas.')
this.exitCode = 1
return
}
user.password = password
await user.save()
this.logger.success('Mot de passe changé.')
}
}
+40
View File
@@ -0,0 +1,40 @@
import { BaseCommand, args } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
/**
* New double authentication secret (lost phone, new app…).
*/
export default class UserReset2fa extends BaseCommand {
static commandName = 'user:reset-2fa'
static description = 'Réinitialiser la double authentification d’un compte'
static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Identifiant du compte' })
declare username: string
async run() {
const { default: User } = await import('#models/user')
const { enrollTotp } = await import('#services/totp_enrollment')
const user = await User.findBy('username', this.username)
if (!user) {
this.logger.error(`Compte « ${this.username} » introuvable.`)
this.exitCode = 1
return
}
const totpSecret = await enrollTotp(this, user.username)
if (!totpSecret) {
this.exitCode = 1
return
}
user.totpSecret = totpSecret
user.totpLastStep = null
await user.save()
this.logger.success('Double authentification réinitialisée.')
}
}
+94
View File
@@ -0,0 +1,94 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app URL can be used in various places where you want to create absolute
* URLs to your application. For example, when sending emails, images should
* use absolute URLs.
*/
export const appUrl = env.get('APP_URL')
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
/**
* Generate a unique request id for each incoming request.
* Useful to correlate logs and debug a request flow.
*/
generateRequestId: true,
/**
* The app is only reachable through Caddy (no published port), so the
* X-Forwarded-For header set by Caddy can be trusted to get the client IP.
*/
trustProxy: () => true,
/**
* Allow HTTP method spoofing via the "_method" form/query parameter.
* This lets HTML forms target PUT/PATCH/DELETE routes while still
* submitting with POST.
*/
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
router: {
matcher: 'tree',
},
/**
* Redirect configuration controls the behavior of
* response.redirect().back() and query string forwarding.
*/
redirect: {
/**
* When enabled, all redirects automatically carry over the current
* request's query string parameters to the redirect destination.
* Use withQs(false) to opt out for a specific redirect.
*/
forwardQueryString: true,
},
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
/**
* Restrict the cookie to a specific domain.
* Keep empty to use the current host.
*/
domain: '',
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Default lifetime for cookies managed by the HTTP layer.
*/
maxAge: '2h',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* The dashboard is never used from another site.
*/
sameSite: 'strict',
},
})
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
/**
* Default guard used when no guard is explicitly specified.
*/
default: 'web',
guards: {
/**
* Session-based guard for browser authentication.
*/
web: sessionGuard({
/**
* Enable persistent login using remember-me tokens.
*/
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
/**
* Inferring types from the configured auth
* guards.
*/
declare module '@adonisjs/auth/types' {
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
}
declare module '@adonisjs/core/types' {
interface EventsList extends InferAuthEvents<Authenticators> {}
}
+78
View File
@@ -0,0 +1,78 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* Parse request bodies for these HTTP methods.
* Keep this aligned with methods that receive payloads in your routes.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser.
*/
form: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the form parser.
*/
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser.
*/
json: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the JSON parser.
*/
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Automatically process uploaded files into the system tmp directory.
*/
autoProcess: true,
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Routes where multipart processing is handled manually.
*/
processManually: [],
/**
* Maximum accepted payload size for multipart requests.
*/
limit: '20mb',
/**
* Content types handled by the multipart parser.
*/
types: ['multipart/form-data'],
},
})
export default bodyParserConfig
+66
View File
@@ -0,0 +1,66 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid'
const dbConfig = defineConfig({
/**
* Default connection used for all queries.
*/
connection: 'sqlite',
connections: {
/**
* SQLite keeps the dashboard independent from MariaDB: the history and
* the action log stay available even when MariaDB is down.
*/
sqlite: {
client: 'better-sqlite3',
connection: {
filename: env.get('DB_PATH') ?? app.tmpPath('db.sqlite3'),
},
/**
* WAL lets the HTTP requests read while the monitors write.
*/
pool: {
afterCreate: (connection: any, done: (error: Error | null, connection: any) => void) => {
connection.pragma('journal_mode = WAL')
connection.pragma('foreign_keys = ON')
done(null, connection)
},
},
/**
* Required by Knex for SQLite defaults.
*/
useNullAsDefault: true,
migrations: {
/**
* Sort migration files naturally by filename.
*/
naturalSort: true,
/**
* Paths containing migration files.
*/
paths: ['database/migrations'],
},
schemaGeneration: {
/**
* Enable schema generation from Lucid models.
*/
enabled: true,
/**
* Custom schema rules file paths.
*/
rulesPaths: ['./database/schema_rules.js'],
},
},
},
})
export default dbConfig
+34
View File
@@ -0,0 +1,34 @@
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
const encryptionConfig = defineConfig({
/**
* Default encryption driver used by the application.
*/
default: 'gcm',
list: {
gcm: drivers.aes256gcm({
/**
* Keys used for encryption/decryption.
* First key encrypts, all keys are tried for decryption.
*/
keys: [env.get('APP_KEY')],
/**
* Stable identifier for this driver.
*/
id: 'gcm',
}),
},
})
export default encryptionConfig
/**
* Inferring types for the list of encryptors you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
}
+75
View File
@@ -0,0 +1,75 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
/**
* Hashing configuration.
*
* This starter uses Node.js scrypt under the hood.
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
*/
const hashConfig = defineConfig({
/**
* Default hasher used by the application.
*/
default: 'scrypt',
list: {
/**
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
*/
scrypt: drivers.scrypt({
/**
* Work factor (Node alias: N / cost).
* Higher values increase security and CPU+memory usage.
*
* Tuning guideline:
* - Start with 16384.
* - Increase gradually (for example 32768) and benchmark login/signup latency.
* - Keep values practical for your slowest production machine.
*
* Node constraint: value must be a power of two greater than 1.
*/
cost: 16384,
/**
* Block size (Node alias: r / blockSize).
* Increases memory and CPU linearly.
*
* Tuning guideline:
* - Keep 8 unless you have a measured reason to change it.
* - Raise only with benchmark data, because memory usage grows quickly.
*/
blockSize: 8,
/**
* Parallelization (Node alias: p / parallelization).
* Controls how many independent computations are performed.
*
* Tuning guideline:
* - Keep 1 for most applications.
* - Increase only after load testing if your infrastructure benefits from it.
*/
parallelization: 1,
/**
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
* Hashing throws if the estimated memory usage is above this limit.
* Node documents the check as approximately: 128 * N * r > maxmem.
*
* Tuning guideline:
* - Keep this aligned with your cost/blockSize choices.
* - Increase carefully on memory-constrained environments.
*/
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, stores } from '@adonisjs/limiter'
import type { InferLimiters } from '@adonisjs/limiter/types'
/**
* A single instance of the dashboard runs: the memory store is enough.
*/
const limiterConfig = defineConfig({
default: 'memory',
stores: {
memory: stores.memory({}),
},
})
export default limiterConfig
declare module '@adonisjs/limiter/types' {
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
}
+51
View File
@@ -0,0 +1,51 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
/**
* Default logger name used by ctx.logger and app logger calls.
*/
default: 'app',
loggers: {
app: {
/**
* Toggle this logger on/off.
*/
enabled: true,
/**
* Logger name shown in log records.
*/
name: env.get('APP_NAME'),
/**
* Minimum level to output (trace, debug, info, warn, error, fatal).
*/
level: env.get('LOG_LEVEL'),
/**
* Use sync destination in non-production for immediate flush.
*/
destination: !app.inProduction ? await syncDestination() : undefined,
/**
* Configure where logs are written.
*/
transport: {
targets: [targets.file({ destination: 1 })],
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}
+78
View File
@@ -0,0 +1,78 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
/**
* Enable or disable session support globally.
*/
enabled: true,
/**
* Cookie name storing the session identifier.
*/
cookieName: 'adonis-session',
/**
* When set to true, the session id cookie will be deleted
* once the user closes the browser.
*/
clearWithBrowser: false,
/**
* Define how long to keep the session data alive without
* any activity.
*/
age: '12h',
/**
* Configuration for session cookie and the
* cookie store.
*/
cookie: {
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* The dashboard is never used from another site.
*/
sameSite: 'strict',
},
/**
* The store to use. Make sure to validate the environment
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
/**
* List of configured stores. Refer documentation to see
* list of available stores and their config.
*/
stores: {
/**
* Store session data inside encrypted cookies.
*/
cookie: stores.cookie(),
/**
* Store session data inside the configured database.
*/
database: stores.database(),
},
})
export default sessionConfig
+96
View File
@@ -0,0 +1,96 @@
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
/**
* Configure CSP policies for your app. Refer documentation
* to learn more.
*/
csp: {
/**
* Enable the Content-Security-Policy header.
*/
enabled: false,
/**
* Per-resource CSP directives.
*/
directives: {},
/**
* Report violations without blocking resources.
*/
reportOnly: false,
},
/**
* Configure CSRF protection options. Refer documentation
* to learn more.
*/
csrf: {
/**
* Enable CSRF token verification for state-changing requests.
* Angular's HttpClient sends the XSRF-TOKEN cookie back automatically.
*/
enabled: true,
/**
* Route patterns to exclude from CSRF checks.
* Useful for external webhooks or API endpoints.
*/
exceptRoutes: [],
/**
* Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.
*/
enableXsrfCookie: true,
/**
* HTTP methods protected by CSRF validation.
*/
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
/**
* Control how your website should be embedded inside
* iframes.
*/
xFrame: {
/**
* Set by the security headers middleware for every response.
*/
enabled: false,
/**
* Block all framing attempts. Default value is DENY.
*/
action: 'DENY',
},
/**
* Force browser to always use HTTPS.
*/
hsts: {
/**
* Set by the security headers middleware for every response.
*/
enabled: false,
/**
* HSTS policy duration remembered by browsers.
*/
maxAge: '180 days',
},
/**
* Disable browsers from sniffing content types and rely only
* on the response content-type header.
*/
contentTypeSniffing: {
/**
* Set by the security headers middleware for every response.
*/
enabled: false,
},
})
export default shieldConfig
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from '@adonisjs/static'
/**
* Serves the Angular build copied in "public/". The bundles have a hash
* in their name and can be cached forever, index.html must not be cached.
*
* https://docs.adonisjs.com/guides/static-assets
*/
const staticServerConfig = defineConfig({
enabled: true,
etag: true,
lastModified: true,
dotFiles: 'ignore',
headers: (path) => {
if (/(^|[\\/])(main|chunk|styles|polyfills)-[\w-]{8,}\.(js|css)$/.test(path)) {
return { 'Cache-Control': 'public, max-age=31536000, immutable' }
}
return { 'Cache-Control': 'no-cache' }
},
})
export default staticServerConfig
+76
View File
@@ -0,0 +1,76 @@
import env from '#start/env'
import { resolve } from 'node:path'
/**
* Everything the dashboard needs to know about the VPS. Relative paths
* are resolved from the working directory, which lets the development
* setup point to the "dev-srv" fixture folder.
*/
const srvRoot = resolve(env.get('SRV_ROOT'))
const vpsConfig = {
/**
* Root of the projects tree ("/srv" on the VPS).
*/
srvRoot,
/**
* Folders of "/srv" holding one compose project per sub-folder.
* Any other folder with its own compose file is shown as infrastructure.
*/
projectCategories: ['api', 'frontend', 'fullstack', 'wordpress'] as const,
/**
* Folder of "/srv" holding the static sites.
*/
staticCategory: 'html',
/**
* The host "/proc" mounted in the container. When missing (local
* development), metrics fall back to the Node.js "os" module.
*/
hostProc: env.get('HOST_PROC'),
/**
* Path whose filesystem is reported as "disk" usage.
*/
diskPath: resolve(env.get('DISK_PATH') ?? srvRoot),
docker: {
/**
* Docker socket path. Dockerode picks the platform default when empty.
*/
socketPath: env.get('DOCKER_SOCKET'),
},
caddy: {
container: env.get('CADDY_CONTAINER'),
/**
* Caddyfile as seen by the dashboard (through the /srv mount).
*/
caddyfilePath: resolve(env.get('CADDYFILE_PATH')),
/**
* Same file as seen from inside the Caddy container.
*/
configPath: env.get('CADDY_CONFIG_PATH'),
},
mariadb: {
container: env.get('MARIADB_CONTAINER'),
},
/**
* Default domain of new projects: <name>.<baseDomain>
*/
baseDomain: env.get('BASE_DOMAIN'),
backups: {
dir: resolve(env.get('BACKUP_DIR')),
dailyAt: env.get('BACKUP_DAILY_AT') || null,
keep: env.get('BACKUP_KEEP') ?? 7,
},
}
export default vpsConfig
@@ -0,0 +1,30 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('username', 64).notNullable().unique()
table.string('password').notNullable()
/**
* TOTP secret, encrypted with the APP_KEY.
*/
table.text('totp_secret').notNullable()
/**
* Last accepted TOTP time step, to refuse a code being replayed.
*/
table.integer('totp_last_step').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,27 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* One row per minute with the averaged host metrics.
*/
export default class extends BaseSchema {
protected tableName = 'metrics'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('ts').notNullable().index()
table.float('cpu').notNullable()
table.float('load1').nullable()
table.bigInteger('mem_used').notNullable()
table.bigInteger('mem_total').notNullable()
table.bigInteger('disk_used').notNullable()
table.bigInteger('disk_total').notNullable()
table.float('net_rx').nullable()
table.float('net_tx').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,57 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Sites are synchronized from the Caddyfile and checked every minute.
*/
export default class extends BaseSchema {
async up() {
this.schema.createTable('sites', (table) => {
table.increments('id')
table.string('domain').notNullable().unique()
/**
* False once the domain disappeared from the Caddyfile.
*/
table.boolean('active').notNullable().defaultTo(true)
/**
* What Caddy serves: "proxy" (upstream = container:port) or "static" (upstream = root path).
*/
table.string('kind').nullable()
table.string('upstream').nullable()
table.boolean('last_ok').nullable()
table.integer('last_status').nullable()
table.integer('last_response_ms').nullable()
table.text('last_error').nullable()
table.integer('last_checked_at').nullable()
table.integer('cert_expires_at').nullable()
table.integer('cert_checked_at').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
this.schema.createTable('site_checks', (table) => {
table.increments('id')
table
.integer('site_id')
.notNullable()
.unsigned()
.references('id')
.inTable('sites')
.onDelete('CASCADE')
table.integer('ts').notNullable()
table.boolean('ok').notNullable()
table.integer('status').nullable()
table.integer('response_ms').nullable()
table.text('error').nullable()
table.index(['site_id', 'ts'])
})
}
async down() {
this.schema.dropTable('site_checks')
this.schema.dropTable('sites')
}
}
@@ -0,0 +1,32 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Every action done from the dashboard, with the output of the commands.
*/
export default class extends BaseSchema {
protected tableName = 'audit_logs'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('username').nullable()
table.string('ip').nullable()
table.string('action').notNullable()
table.string('target').nullable()
/**
* "running", "success" or "error"
*/
table.string('status').notNullable()
table.text('output').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('finished_at').nullable()
table.index(['created_at'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Previous contents of the Caddyfile, saved before every change.
*/
export default class extends BaseSchema {
protected tableName = 'caddyfile_versions'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.text('content').notNullable()
table.string('username').nullable()
table.string('reason').nullable()
table.timestamp('created_at').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* This file is automatically generated
* DO NOT EDIT manually
* Run "node ace migration:run" command to re-generate this file
*/
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export class AuditLogSchema extends BaseModel {
static $columns = ['action', 'createdAt', 'finishedAt', 'id', 'ip', 'output', 'status', 'target', 'username'] as const
$columns = AuditLogSchema.$columns
@column()
declare action: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime()
declare finishedAt: DateTime | null
@column({ isPrimary: true })
declare id: number
@column()
declare ip: string | null
@column()
declare output: string | null
@column()
declare status: string
@column()
declare target: string | null
@column()
declare username: string | null
}
export class CaddyfileVersionSchema extends BaseModel {
static $columns = ['content', 'createdAt', 'id', 'reason', 'username'] as const
$columns = CaddyfileVersionSchema.$columns
@column()
declare content: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare reason: string | null
@column()
declare username: string | null
}
export class MetricSchema extends BaseModel {
static $columns = ['cpu', 'diskTotal', 'diskUsed', 'id', 'load1', 'memTotal', 'memUsed', 'netRx', 'netTx', 'ts'] as const
$columns = MetricSchema.$columns
@column()
declare cpu: number
@column()
declare diskTotal: bigint | number
@column()
declare diskUsed: bigint | number
@column({ isPrimary: true })
declare id: number
@column()
declare load1: number | null
@column()
declare memTotal: bigint | number
@column()
declare memUsed: bigint | number
@column()
declare netRx: number | null
@column()
declare netTx: number | null
@column()
declare ts: number
}
export class SiteCheckSchema extends BaseModel {
static $columns = ['error', 'id', 'ok', 'responseMs', 'siteId', 'status', 'ts'] as const
$columns = SiteCheckSchema.$columns
@column()
declare error: string | null
@column({ isPrimary: true })
declare id: number
@column()
declare ok: boolean
@column()
declare responseMs: number | null
@column()
declare siteId: number
@column()
declare status: number | null
@column()
declare ts: number
}
export class SiteSchema extends BaseModel {
static $columns = ['active', 'certCheckedAt', 'certExpiresAt', 'createdAt', 'domain', 'id', 'kind', 'lastCheckedAt', 'lastError', 'lastOk', 'lastResponseMs', 'lastStatus', 'updatedAt', 'upstream'] as const
$columns = SiteSchema.$columns
@column()
declare active: boolean
@column()
declare certCheckedAt: number | null
@column()
declare certExpiresAt: number | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare domain: string
@column({ isPrimary: true })
declare id: number
@column()
declare kind: string | null
@column()
declare lastCheckedAt: number | null
@column()
declare lastError: string | null
@column()
declare lastOk: boolean | null
@column()
declare lastResponseMs: number | null
@column()
declare lastStatus: number | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column()
declare upstream: string | null
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'password', 'totpLastStep', 'totpSecret', 'updatedAt', 'username'] as const
$columns = UserSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column({ serializeAs: null })
declare password: string
@column()
declare totpLastStep: number | null
@column()
declare totpSecret: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column()
declare username: string
}
+3
View File
@@ -0,0 +1,3 @@
import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'
export default {} satisfies SchemaRules
+2
View File
@@ -0,0 +1,2 @@
import { configApp } from '@adonisjs/eslint-config'
export default configApp()
+8116
View File
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
{
"name": "api",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
"./data": "./.adonisjs/client/data.d.ts",
"./registry": "./.adonisjs/client/registry/index.ts"
},
"scripts": {
"start": "node bin/server.js",
"build": "node ace build",
"dev": "node ace serve --hmr",
"test": "node ace test",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"imports": {
"#controllers/*": "./app/controllers/*.js",
"#exceptions/*": "./app/exceptions/*.js",
"#models/*": "./app/models/*.js",
"#mails/*": "./app/mails/*.js",
"#services/*": "./app/services/*.js",
"#listeners/*": "./app/listeners/*.js",
"#events/*": "./app/events/*.js",
"#generated/*": "./.adonisjs/server/*.js",
"#middleware/*": "./app/middleware/*.js",
"#transformers/*": "./app/transformers/*.js",
"#validators/*": "./app/validators/*.js",
"#providers/*": "./providers/*.js",
"#policies/*": "./app/policies/*.js",
"#abilities/*": "./app/abilities/*.js",
"#database/*": "./database/*.js",
"#tests/*": "./tests/*.js",
"#start/*": "./start/*.js",
"#config/*": "./config/*.js"
},
"devDependencies": {
"@adonisjs/assembler": "^8.5.2",
"@adonisjs/eslint-config": "^3.1.0",
"@adonisjs/prettier-config": "^1.5.0",
"@adonisjs/tsconfig": "^2.0.0",
"@japa/assert": "^4.2.0",
"@japa/plugin-adonisjs": "^5.2.0",
"@japa/runner": "^5.3.0",
"@poppinss/ts-exec": "^1.4.4",
"@types/dockerode": "^4.0.2",
"@types/luxon": "^3.7.5",
"@types/node": "~26.6.2",
"@types/qrcode": "^1.5.6",
"eslint": "^10.11.0",
"hot-hook": "^1.0.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.9.9",
"typescript": "~6.0.3",
"youch": "^4.1.1"
},
"dependencies": {
"@adonisjs/auth": "^10.1.0",
"@adonisjs/core": "^7.5.2",
"@adonisjs/limiter": "^3.0.1",
"@adonisjs/lucid": "^22.4.2",
"@adonisjs/session": "^8.1.0",
"@adonisjs/shield": "^9.0.0",
"@adonisjs/static": "^2.0.1",
"@japa/api-client": "^3.2.1",
"@tuyau/core": "^1.2.2",
"@vinejs/vine": "^4.4.0",
"better-sqlite3": "^13.0.3",
"dockerode": "^5.0.1",
"luxon": "^3.7.2",
"otpauth": "^9.5.2",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2"
},
"hotHook": {
"boundaries": [
"./app/controllers/**/*.ts",
"./app/middleware/*.ts"
]
},
"allowScripts": {
"@swc/core": true,
"better-sqlite3": true,
"esbuild": true,
"fsevents": true
},
"prettier": "@adonisjs/prettier-config"
}
+69
View File
@@ -0,0 +1,69 @@
import { HttpContext } from '@adonisjs/core/http'
import { BaseSerializer } from '@adonisjs/core/transformers'
import { type SimplePaginatorMetaKeys } from '@adonisjs/lucid/types/querybuilder'
/**
* Custom serializer for API responses that ensures consistent JSON structure
* across all API endpoints. Wraps response data in a 'data' property and handles
* pagination metadata for Lucid ORM query results.
*/
class ApiSerializer extends BaseSerializer<{
Wrap: 'data'
PaginationMetaData: SimplePaginatorMetaKeys
}> {
/**
* Wraps all serialized data under this key in the response object.
* Example: { data: [...] } instead of returning raw arrays/objects
*/
wrap: 'data' = 'data'
/**
* Validates and defines pagination metadata structure for paginated responses.
* Ensures that pagination info from Lucid queries is properly formatted.
*
* @throws Error if metadata doesn't match Lucid's pagination structure
*/
definePaginationMetaData(metaData: unknown): SimplePaginatorMetaKeys {
if (!this.isLucidPaginatorMetaData(metaData)) {
throw new Error(
'Invalid pagination metadata. Expected metadata to contain Lucid pagination keys'
)
}
return metaData
}
}
/**
* Single instance of ApiSerializer used across the application
*/
const serializer = new ApiSerializer()
const serialize = Object.assign(
function (this: HttpContext, ...[data, resolver]: Parameters<ApiSerializer['serialize']>) {
return serializer.serialize(data, resolver ?? this.containerResolver)
},
{
withoutWrapping(
this: HttpContext,
...[data, resolver]: Parameters<ApiSerializer['serializeWithoutWrapping']>
) {
return serializer.serializeWithoutWrapping(data, resolver ?? this.containerResolver)
},
}
) as ApiSerializer['serialize'] & { withoutWrapping: ApiSerializer['serializeWithoutWrapping'] }
/**
* Adds the serialize method to all HttpContext instances.
* Usage in controllers: return ctx.serialize(data)
* This ensures all API responses follow the same structure with data wrapping.
*/
HttpContext.instanceProperty('serialize', serialize)
/**
* Module augmentation to add the serialize method to HttpContext.
* This allows controllers to use ctx.serialize() for consistent API responses.
*/
declare module '@adonisjs/core/http' {
export interface HttpContext {
serialize: typeof serialize
}
}
+50
View File
@@ -0,0 +1,50 @@
/*
|--------------------------------------------------------------------------
| Environment variables service
|--------------------------------------------------------------------------
|
| The `Env.create` method creates an instance of the Env service. The
| service validates the environment variables and also cast values
| to JavaScript data types.
|
*/
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
// Node
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
PORT: Env.schema.number(),
HOST: Env.schema.string({ format: 'host' }),
LOG_LEVEL: Env.schema.string(),
// App
APP_KEY: Env.schema.secret(),
APP_URL: Env.schema.string({ format: 'url', tld: false }),
// Session
SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),
// Database (SQLite file holding users, history and the action log)
DB_PATH: Env.schema.string.optional(),
// VPS paths, as seen from inside the container
SRV_ROOT: Env.schema.string(),
HOST_PROC: Env.schema.string.optional(),
DISK_PATH: Env.schema.string.optional(),
BACKUP_DIR: Env.schema.string(),
// Docker
DOCKER_SOCKET: Env.schema.string.optional(),
CADDY_CONTAINER: Env.schema.string(),
CADDYFILE_PATH: Env.schema.string(),
CADDY_CONFIG_PATH: Env.schema.string(),
MARIADB_CONTAINER: Env.schema.string(),
// Projects
BASE_DOMAIN: Env.schema.string(),
// Backups ("HH:MM" in the server timezone, empty = no automatic backup)
BACKUP_DAILY_AT: Env.schema.string.optional(),
BACKUP_KEEP: Env.schema.number.optional(),
})
+52
View File
@@ -0,0 +1,52 @@
/*
|--------------------------------------------------------------------------
| HTTP kernel file
|--------------------------------------------------------------------------
|
| The HTTP kernel file is used to register the middleware with the server
| or the router.
|
*/
import router from '@adonisjs/core/services/router'
import server from '@adonisjs/core/services/server'
/**
* The error handler is used to convert an exception
* to a HTTP response.
*/
server.errorHandler(() => import('#exceptions/handler'))
/**
* The server middleware stack runs middleware on all the HTTP
* requests, even if there is no route registered for
* the request URL.
*
* Security headers come first so that the static files (the Angular
* build served from "public/") receive them too.
*/
server.use([
() => import('#middleware/security_headers_middleware'),
() => import('#middleware/force_json_response_middleware'),
() => import('#middleware/container_bindings_middleware'),
() => import('@adonisjs/static/static_middleware'),
])
/**
* The router middleware stack runs middleware on all the HTTP
* requests with a registered route.
*/
router.use([
() => import('@adonisjs/core/bodyparser_middleware'),
() => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/shield/shield_middleware'),
() => import('@adonisjs/auth/initialize_auth_middleware'),
])
/**
* Named middleware collection must be explicitly assigned to
* the routes or the routes group.
*/
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
})
+29
View File
@@ -0,0 +1,29 @@
/*
|--------------------------------------------------------------------------
| Background monitors
|--------------------------------------------------------------------------
|
| Started with the HTTP server only (not with the ace commands): host
| metrics, containers stats, site checks and the daily backup.
|
*/
import app from '@adonisjs/core/services/app'
import backupService from '#services/backup_service'
import dockerService from '#services/docker_service'
import metricsService from '#services/metrics_service'
import siteMonitor from '#services/site_monitor'
app.ready(() => {
metricsService.start()
dockerService.startStatsPolling()
siteMonitor.start()
backupService.startSchedule()
})
app.terminating(() => {
metricsService.stop()
dockerService.stopStatsPolling()
siteMonitor.stop()
backupService.stopSchedule()
})
+68
View File
@@ -0,0 +1,68 @@
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router
.group(() => {
router.post('auth/login', [controllers.Auth, 'login'])
router
.group(() => {
router.post('auth/logout', [controllers.Auth, 'logout'])
router.get('auth/me', [controllers.Auth, 'me'])
router.get('overview', [controllers.Overview, 'index'])
router.get('metrics/live', [controllers.Metrics, 'live'])
router.get('metrics/history', [controllers.Metrics, 'history'])
router.get('containers', [controllers.Containers, 'index'])
router.get('containers/:id', [controllers.Containers, 'show'])
router.get('containers/:id/logs', [controllers.Containers, 'logs'])
router.post('containers/:id/:action', [controllers.Containers, 'action'])
router.get('sites', [controllers.Sites, 'index'])
router.post('sites/check', [controllers.Sites, 'check'])
router.get('sites/:id', [controllers.Sites, 'show'])
router.get('projects', [controllers.Projects, 'index'])
router.post('projects', [controllers.Projects, 'store'])
router.get('projects/options', [controllers.Projects, 'options'])
router.get('projects/:category/:name', [controllers.Projects, 'show'])
router.post('projects/:category/:name/fetch', [controllers.Projects, 'fetch'])
router.post('projects/:category/:name/actions/:action', [controllers.Projects, 'action'])
router.get('caddy', [controllers.Caddy, 'show'])
router.put('caddy', [controllers.Caddy, 'update'])
router.post('caddy/reload', [controllers.Caddy, 'reload'])
router.get('caddy/versions', [controllers.Caddy, 'versions'])
router.get('caddy/versions/:id', [controllers.Caddy, 'version'])
router.post('caddy/versions/:id/restore', [controllers.Caddy, 'restore'])
router.get('backups', [controllers.Backups, 'index'])
router.post('backups', [controllers.Backups, 'store'])
router.get('backups/:name', [controllers.Backups, 'download'])
router.delete('backups/:name', [controllers.Backups, 'destroy'])
router.get('jobs/:id/stream', [controllers.Jobs, 'stream'])
router.get('audit', [controllers.Audit, 'index'])
router.get('audit/:id', [controllers.Audit, 'show'])
})
.use(middleware.auth())
})
.prefix('/api')
/**
* Every other URL is a page of the Angular application.
*/
router.get('*', [controllers.Spa, 'index'])
+57
View File
@@ -0,0 +1,57 @@
/*
|--------------------------------------------------------------------------
| Validator file
|--------------------------------------------------------------------------
|
| The validator file is used for configuring global transforms for VineJS.
| The transform below converts all VineJS date outputs from JavaScript
| Date objects to Luxon DateTime instances, so that validated dates are
| ready to use with Lucid models and other parts of the app that expect
| Luxon DateTime.
|
*/
import { DateTime } from 'luxon'
import vine, { SimpleMessagesProvider, VineDate } from '@vinejs/vine'
declare module '@vinejs/vine/types' {
interface VineGlobalTransforms {
date: DateTime
}
}
VineDate.transform((value) => DateTime.fromJSDate(value))
/**
* Error messages shown in the dashboard.
*/
vine.messagesProvider = new SimpleMessagesProvider(
{
'required': 'Le champ « {{ field }} » est obligatoire.',
'requiredWhen': 'Le champ « {{ field }} » est obligatoire pour ce type de projet.',
'string': 'Le champ « {{ field }} » doit être un texte.',
'number': 'Le champ « {{ field }} » doit être un nombre.',
'boolean': 'Le champ « {{ field }} » doit être vrai ou faux.',
'enum': 'La valeur de « {{ field }} » n’est pas autorisée.',
'regex': 'Le format de « {{ field }} » est invalide.',
'range': '« {{ field }} » doit être entre {{ min }} et {{ max }}.',
'withoutDecimals': '« {{ field }} » doit être un nombre entier.',
'minLength': '« {{ field }} » doit faire au moins {{ min }} caractères.',
'maxLength': '« {{ field }} » doit faire au plus {{ max }} caractères.',
'code.regex': 'Le code doit contenir 6 chiffres.',
'name.regex':
'Le nom ne peut contenir que des minuscules, des chiffres et des tirets (et commencer par une lettre ou un chiffre).',
'domain.regex': 'Le domaine est invalide.',
'gitUrl.regex': 'L’URL git doit commencer par https://, ssh:// ou git@.',
},
{
username: 'identifiant',
password: 'mot de passe',
code: 'code',
name: 'nom',
gitUrl: 'URL git',
port: 'port',
domain: 'domaine',
content: 'contenu',
}
)
+56
View File
@@ -0,0 +1,56 @@
import { assert } from '@japa/assert'
import { apiClient } from '@japa/api-client'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import { dbAssertions } from '@adonisjs/lucid/plugins/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { authApiClient } from '@adonisjs/auth/plugins/api_client'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
import type { Registry } from '../.adonisjs/client/registry/schema.d.ts'
/**
* This file is imported by the "bin/test.ts" entrypoint file
*/
declare module '@japa/api-client/types' {
interface RoutesRegistry extends Registry {}
}
/**
* This file is imported by the "bin/test.ts" entrypoint file
*/
/**
* Configure Japa plugins in the plugins array.
* Learn more - https://japa.dev/docs/runner-config#plugins-optional
*/
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
dbAssertions(app),
apiClient(),
sessionApiClient(app),
authApiClient(app),
]
/**
* Configure lifecycle function to run before and after all the
* tests.
*
* The setup functions are executed before all the tests
* The teardown functions are executed after all the tests
*/
export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
setup: [],
teardown: [],
}
/**
* Configure suites by tapping into the test suite instance.
* Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
}
}
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
"compilerOptions": {
"rootDir": "./",
"jsx": "react",
"outDir": "./build"
}
}
+37
View File
@@ -0,0 +1,37 @@
# Node
TZ=Europe/Paris
PORT=3333
HOST=0.0.0.0
NODE_ENV=production
# App
LOG_LEVEL=info
# Clé secrète (chiffre les cookies et les secrets 2FA) : openssl rand -base64 24
APP_KEY=
APP_URL=https://big-brother.nucleus-ludus.fr
# Session
SESSION_DRIVER=cookie
# Database (SQLite)
DB_PATH=/data/db.sqlite3
# VPS paths, as seen from inside the container
SRV_ROOT=/srv
HOST_PROC=/host/proc
DISK_PATH=/srv
BACKUP_DIR=/backups
# Docker
DOCKER_SOCKET=/var/run/docker.sock
CADDY_CONTAINER=caddy
CADDYFILE_PATH=/srv/caddy/Caddyfile
CADDY_CONFIG_PATH=/etc/caddy/Caddyfile
MARIADB_CONTAINER=mariadb
# Projects
BASE_DOMAIN=nucleus-ludus.fr
# Backups (empty BACKUP_DAILY_AT = no automatic backup)
BACKUP_DAILY_AT=03:00
BACKUP_KEEP=7
+3
View File
@@ -0,0 +1,3 @@
big-brother.nucleus-ludus.fr {
reverse_proxy big-brother:3333
}
+30
View File
@@ -0,0 +1,30 @@
# À copier dans /srv/fullstack/big-brother/docker-compose.yml sur le VPS
# (le dépôt est cloné dans /srv/fullstack/big-brother/project).
services:
big-brother:
build: ./project
image: big-brother
container_name: big-brother
restart: unless-stopped
env_file: ./.env
volumes:
# Base SQLite : comptes, historique, journal
- ./data:/data
# Accès à Docker : conteneurs, logs, redémarrages, Caddy, MariaDB
- /var/run/docker.sock:/var/run/docker.sock
# Projets, Caddyfile (lecture et écriture), au même chemin que sur le VPS
- /srv:/srv
# Métriques du serveur (processeur, mémoire, réseau)
- /proc:/host/proc:ro
# Sauvegardes
- /root/backups:/backups
networks:
- caddy_network
logging:
options:
max-size: "10m"
max-file: "3"
networks:
caddy_network:
external: true
+3
View File
@@ -0,0 +1,3 @@
Faux dossier /srv pour le développement local (SRV_ROOT=../dev-srv dans api/.env).
Il reprend l'organisation du VPS : un dossier par projet avec son docker-compose.yml,
les sites statiques dans html/ et l'infrastructure (caddy, database, git, portainer).
@@ -0,0 +1,4 @@
services:
nodejs:
image: node:22-alpine3.21
container_name: apiPortfolio
Whitespace-only changes.
@@ -0,0 +1,4 @@
services:
nodejs:
image: node:22-alpine3.21
container_name: apiWoodenMaster
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more