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