8 Commits

Author SHA1 Message Date
cb0d2c80cf fix: readme discord 2026-03-19 10:48:40 +01:00
d593d3f0e2 fix: status app 2026-03-19 10:24:41 +01:00
66a6a8caf0 fix: status app 2026-03-19 09:37:55 +01:00
6aa85ac683 fix: systeme metrics chart 2026-03-19 09:29:28 +01:00
403bc91f33 fix: systeme metrics chart 2026-03-18 11:36:59 +01:00
0a73c5cb37 fix: use recette status log 2026-03-18 10:33:18 +01:00
c12387ac94 fix: t-005 a t-0029 correctif 2026-03-18 09:00:11 +01:00
bdb65a09ff fix: t-001 a t-005 correctif 2026-03-17 15:33:36 +01:00
34 changed files with 633 additions and 286 deletions

View File

@@ -11,12 +11,12 @@ BACKUPS_REMOTE_ROOT=
BACKUPS_MAX_FILES=
# Paramètres utilisés pour construire les commandes disque et backup
DISK_REMOTE_HOST=malio-b
DISK_LOCAL_SCRIPT_DIR=/home/malio/Malio-ops/CheckStorage
DISK_REMOTE_SCRIPT_DIR=/home/malio-b/Malio-ops/CheckStorage
RECETTE_SCRIPTS_DIR=/home/malio/Malio-ops/RecetteScripts
VAULTWARDEN_SSH_HOST=bitwarden
VAULTWARDEN_SCRIPTS_DIR=/home/matt/vaultwarden/Malio-ops/BackupVaultWarden
DISK_REMOTE_HOST=
DISK_LOCAL_SCRIPT_DIR=
DISK_REMOTE_SCRIPT_DIR=
RECETTE_SCRIPTS_DIR=
VAULTWARDEN_SSH_HOST=
VAULTWARDEN_SCRIPTS_DIR=
# A quelle heure les backups doivent être effectués (format 24h)
BACKUPS_HOUR=19

View File

@@ -21,4 +21,8 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npx semantic-release

View File

@@ -1,11 +1,3 @@
## [1.4.1](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.4.0...v1.4.1) (2026-03-17)
### Bug Fixes
* readme ([13457ce](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/13457ceb5a74686cd7a5e4180a87f130d1e2f73d))
* resolve production runtime issues ([8886e8b](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/8886e8b7dfe4fb6c9f90f3be7f2a64e23dd7cb3c))
# [1.4.0](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.3.1...v1.4.0) (2026-03-17)

View File

@@ -22,7 +22,6 @@ Sur Linux, installer Docker et nvm.
Suivre la documentation suivante :
https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux
### Installation du projet
Une fois les prérequis installés, cloner le dépôt puis installer les dépendances.
Les étapes ci-dessous sont celles qui sont réellement supportées par le depot.
@@ -63,11 +62,9 @@ Les variables visibles dans le depot sont :
- `BACKUPS_REMOTE_HOST` : hôte SSH cible pour les operations distantes
- `BACKUPS_REMOTE_ROOT` : dossier racine des sauvegardes sur l'hôte distant
- `BACKUPS_MAX_FILES` : nombre maximal de fichiers retournés par dossier de backup
- `DISK_COMMAND_REMOTE` : commande shell utilisée pour la verification disque distante
- `DISK_COMMAND_LOCAL` : commande shell utilisée pour la verification disque locale
- `BACKUP_SCRIPT_COMMAND_BACKUP_BDD_RECETTE` : commande a exécuter pour le script "Backup BDD recette"
- `BACKUP_SCRIPT_COMMAND_CHECK_STATUT_RECETTE` : commande à exécuter pour le script "Check statut recette"
- `BACKUP_SCRIPT_COMMAND_BACKUP_VAULTWARDEN` : commande à exécuter pour le script "Backup vault warden"
- `DISK_REMOTE_HOST` : commande shell utilisée pour la verification disque distante
- `DISK_REMOTE_SCRIPT_DIR` : dossier des scripts de vérification disque distante
- `DISK_LOCAL_SCRIPT_DIR` : commande shell utilisée pour la verification disque locale
- `BACKUPS_HOUR` : heure attendue des sauvegardes pour le contrôle de fraicheur
### 4. Installer les dépendances
@@ -82,7 +79,7 @@ npm install
npm run dev
```
Par défaut, l'application Nuxt sera accessible sûr <http://localhost:3000>.
Par défaut, l'application Nuxt sera accessible sur <http://localhost:3000>.
## Configuration necessaire
@@ -95,6 +92,19 @@ Consequence visible :
- si `API_SECRET_KEY` est vide, les appels API sont refusés avec `401 Unauthorized`
- l'application web pose aussi un cookie HTTP-only via `server/middleware/auth-cookie.ts` pour réutiliser ce secret coté navigateur
## Securite
Le comportement actuel du projet repose sur une hypothèse d'exposition très forte.
- `server/middleware/auth-cookie.ts` pose automatiquement le cookie `api_auth_token` à tout visiteur qui charge l'interface web
- ce cookie permet ensuite d'accéder aux routes `/api/*` protégées par `API_SECRET_KEY`
- il n'existe pas de login utilisateur ni de contrôle d'identité distinct dans le dépôt
Conséquence :
- `Supervisor` doit être déployé uniquement sur un réseau de confiance, derrière un VPN, une restriction d'IP, un proxy d'authentification ou un autre contrôle d'accès externe
- si l'application est exposée publiquement sans protection supplémentaire, ce mécanisme ne constitue pas une authentification suffisante
### SSH pour les backups
Les fonctionnalités de backup utilisent `ssh` avec les options `BatchMode=yes` et `ConnectTimeout=5` dans `server/utils/ssh.ts`. Cela implique un accès sans saisie interactive de mot de passe.
@@ -123,7 +133,7 @@ Usage :
- `npm run dev` : lance l'application en développement
- `npm run build` : construit l'application pour la production
- `npm run generate` : généré une sortie statique si ce mode est compatible avec votre usage
- `npm run generate` : generee une sortie statique si ce mode est compatible avec votre usage
- `npm run preview` : prévisualisé le build Nuxt
- `npm run lint` : execute ESLint
- `npm run lint:fix` : applique les corrections ESLint automatiques : collecte périodique CPU, mémoire et réseau
- `npm run lint:fix` : applique les corrections ESLint automatiques

View File

@@ -15,7 +15,6 @@
--color-m-success: rgb(var(--m-success));
--color-m-accent: rgb(var(--m-accent));
--color-m-warning: rgb(var(--m-warning));
--color-m-succes: rgb(var(--m-success));
--font-display: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
}
@@ -124,7 +123,10 @@
background-clip: text;
}
}
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--m-border)) rgb(var(--m-bg));
}
@keyframes fade-in-up {
from {
opacity: 0;

View File

@@ -31,7 +31,6 @@
</template>
<script setup lang="ts">
import { ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue"
import backupOptions from "~/server/config/backup-options.json"

View File

@@ -77,7 +77,6 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue"
import { Icon as IconifyIcon } from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth"
@@ -108,6 +107,12 @@ type ScriptResult = {
downloadFolders: string[]
}
type ApiErrorLike = {
data?: {
statusMessage?: string
}
}
const emit = defineEmits<{
result: [payload: ScriptResult]
}>()
@@ -116,7 +121,6 @@ const active = ref<string | null>(null)
const loading = ref(true)
const runningKey = ref<string | null>(null)
const scripts = ref<BackupScript[]>([])
const output = ref<string>("")
const message = ref<string>("")
const isError = ref(false)
@@ -125,7 +129,6 @@ const statusClass = computed(() => (isError.value ? "status-error" : "status-suc
const loadScripts = async () => {
loading.value = true
message.value = ""
output.value = ""
isError.value = false
emit("result", {
key: null,
@@ -156,7 +159,6 @@ const loadScripts = async () => {
const runScript = async (key: string) => {
active.value = key
runningKey.value = key
output.value = ""
message.value = ""
isError.value = false
@@ -165,30 +167,25 @@ const runScript = async (key: string) => {
method: "POST",
body: { key }
})
const resultOutput = data.output || "Aucune sortie retournee."
message.value = `${data.label} execute avec succes`
output.value = data.output || "Aucune sortie retournee."
emit("result", {
key: data.key,
label: data.label,
output: output.value,
output: resultOutput,
isError: false,
downloadFolders: data.downloadFolders || []
})
} catch (error: unknown) {
isError.value = true
const statusMessage =
const errorMessage =
typeof error === "object" &&
error !== null &&
"data" in error &&
typeof error.data === "object" &&
error.data !== null &&
"statusMessage" in error.data &&
typeof error.data.statusMessage === "string"
? error.data.statusMessage
typeof (error as ApiErrorLike).data?.statusMessage === "string"
? (error as ApiErrorLike).data?.statusMessage
: null
message.value = statusMessage || "Erreur execution script"
output.value = ""
message.value = errorMessage || "Erreur execution script"
emit("result", {
key,
label: scripts.value.find((item) => item.key === key)?.label || key,

View File

@@ -1,8 +1,14 @@
<script setup>
<script setup lang="ts">
import {Icon as IconifyIcon} from "@iconify/vue"
import { apiFetch } from "~/composables/useApiAuth"
const { data: messages, error } = await useFetch('/api/discord/messages', {
interface DiscordMessage {
id: string
content: string
author: { username: string }
}
const { data: messages, error } = await useFetch<DiscordMessage[]>('/api/discord/messages', {
$fetch: apiFetch,
server: false
})

View File

@@ -44,7 +44,6 @@
</template>
<script setup lang="ts">
import {computed, ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue"
import { apiRequest } from "~/composables/useApiAuth"
@@ -66,9 +65,14 @@ async function testDownload() {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const blob = await res.blob()
const end = performance.now()
const size = blob.size
const reader = res.body!.getReader()
let size = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.length
}
const seconds = (end - start) / 1000
download.value = Math.round((size * 8) / seconds / 1000000)
}
@@ -88,7 +92,7 @@ async function testUpload() {
async function testPing() {
const start = performance.now()
const response = await fetch('/api/ping')
const response = await apiRequest('/api/ping')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}

View File

@@ -47,7 +47,6 @@
<script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
interface StatusRow {

View File

@@ -24,25 +24,32 @@
v-else
:key="`${row.label}-${row.url}`"
class="status-row"
:class="row.status === 200 ? 'row-ok' : 'row-error'"
:class="row.ok ? 'row-ok' : 'row-error'"
>
<div class="flex items-center gap-3">
<span class="status-dot" :class="row.status === 200 ? 'dot-ok' : 'dot-error'" />
<span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
<div class="status-copy">
<div class="flex items-center gap-3">
<span class="status-dot" :class="row.ok ? 'dot-ok' : 'dot-error'" />
<span class="font-display text-sm font-semibold text-m-text">
{{ row.label }}
</span>
</div>
<p class="status-detail">
{{ row.detail }}
</p>
</div>
<div class="status-meta">
<span class="font-mono text-xs" :class="row.ok ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row) }}
</span>
<span class="status-time">
{{ formatCheckedAt(row.checkedAt) }}
</span>
</div>
<span class="font-mono text-xs" :class="row.status === 200 ? 'text-m-success' : 'text-m-error'">
{{ statusLabel(row.status) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
import {onBeforeUnmount, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
interface StatusRow {
@@ -51,6 +58,7 @@ interface StatusRow {
ok: boolean
status: number
checkedAt: string
detail: string
error?: string
}
@@ -58,6 +66,10 @@ interface StatusResponse {
results: StatusRow[]
}
interface BackupScriptRunResponse {
ok: boolean
}
const props = withDefaults(
defineProps<{
endpoint?: string
@@ -72,12 +84,27 @@ const props = withDefaults(
const rows = ref<StatusRow[]>([])
const loading = ref(true)
const initialized = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let statusTimer: ReturnType<typeof setInterval> | null = null
let scriptTimer: ReturnType<typeof setInterval> | null = null
const statusLabel = (status: number) => {
if (status === 200) return "HTTP 200"
if (status === 0) return "Injoignable"
return `KO (${status})`
const statusLabel = (row: StatusRow) => {
if (row.ok) return "OK"
if (row.status === 0) return "DOWN"
return `KO (${row.status})`
}
const formatCheckedAt = (checkedAt: string) => {
const date = new Date(checkedAt)
if (Number.isNaN(date.getTime())) {
return checkedAt
}
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})
}
const checkStatus = async () => {
@@ -95,6 +122,7 @@ const checkStatus = async () => {
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
detail: "Lecture du statut impossible",
error: error instanceof Error ? error.message : String(error)
}
]
@@ -104,15 +132,34 @@ const checkStatus = async () => {
}
}
const runStatusScript = async () => {
try {
await apiFetch<BackupScriptRunResponse>("/api/backup-script", {
method: "POST",
body: { key: "check-statut-recette" }
})
await checkStatus()
} catch (error) {
console.error("Erreur execution check statut recette:", error)
}
}
onMounted(() => {
checkStatus()
timer = setInterval(checkStatus, props.refreshMs)
runStatusScript()
statusTimer = setInterval(checkStatus, props.refreshMs)
scriptTimer = setInterval(runStatusScript, 5 * 60 * 1000)
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
timer = null
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
if (scriptTimer) {
clearInterval(scriptTimer)
scriptTimer = null
}
})
</script>
@@ -149,6 +196,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 3.2rem;
padding: 0.85rem 1rem;
border-radius: 14px;
@@ -157,6 +205,30 @@ onBeforeUnmount(() => {
transition: all 0.2s ease;
}
.status-copy {
min-width: 0;
}
.status-detail {
margin: 0.35rem 0 0;
color: rgb(var(--m-muted));
font-size: 0.78rem;
line-height: 1.4;
}
.status-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
flex-shrink: 0;
}
.status-time {
font-size: 0.72rem;
color: rgb(var(--m-muted));
}
.row-ok {
border-color: rgb(var(--m-success) / 0.08);
}

View File

@@ -139,7 +139,6 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"
import type { SystemMetrics } from "~/types/system"
type MetricKey = "cpu" | "ram"

View File

@@ -45,7 +45,6 @@
</template>
<script setup lang="ts">
import {computed} from "vue"
import type { SystemMetrics } from "~/types/system";
const props = defineProps<{

View File

@@ -1,51 +1,3 @@
function toHeadersObject(headers?: HeadersInit): Record<string, string> {
if (!headers) {
return {}
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries())
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers)
}
return { ...headers }
}
function getDownloadFileName(contentDisposition: string | null, fallback: string) {
if (!contentDisposition) {
return fallback
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1])
}
const asciiMatch = contentDisposition.match(/filename="([^"]+)"/i)
if (asciiMatch?.[1]) {
return asciiMatch[1]
}
return fallback
}
export function useApiAuthHeader() {
const runtimeConfig = useRuntimeConfig()
const token = runtimeConfig.public.apiSecretKey
if (!token) {
return {}
}
// Tous les appels frontend vers /api/* reutilisent ce header commun.
return {
Authorization: `Bearer ${token}`
}
}
export const apiFetch = $fetch.create({})
export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
@@ -53,38 +5,16 @@ export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
}
export async function downloadApiFile(url: string, fileNameFallback: string) {
// Les telechargements passent aussi par fetch pour pouvoir recuperer
// le contenu et le nom de fichier renvoye par l'API.
const response = await apiRequest(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const fileName = getDownloadFileName(
response.headers.get("content-disposition"),
fileNameFallback
)
const link = document.createElement("a")
link.href = objectUrl
link.download = fileName
link.href = url
link.download = fileNameFallback
link.style.display = "none"
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(objectUrl)
}
export function withApiAuth(init: RequestInit = {}) {
// Fusionne le header d'auth avec d'eventuels headers deja fournis.
return {
...init,
headers: {
...useApiAuthHeader(),
...toHeadersObject(init.headers)
}
}
return { ...init }
}

View File

@@ -50,7 +50,7 @@
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-value">{{ environmentLabel }}</p>
<p class="status-description">
Acces rapide au monitoring, aux sauvegardes et aux cartes systeme.
</p>
@@ -123,7 +123,7 @@
<div class="sidebar-divider"/>
<div class="status-card">
<p class="status-label">Environnement</p>
<p class="status-value">Production</p>
<p class="status-value">{{ environmentLabel }}</p>
<p class="status-description">
Navigation rapide vers les vues principales de supervision.
</p>
@@ -146,7 +146,6 @@
</template>
<script setup lang="ts">
import {ref} from "vue"
import {Icon as IconifyIcon} from "@iconify/vue"
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
@@ -154,6 +153,7 @@ const {
public: {appVersion}
} = useRuntimeConfig()
const isMenuOpen = ref(false)
const environmentLabel = import.meta.dev ? "Developpement" : "Production"
const navItems = [
{
to: "/",
@@ -170,6 +170,13 @@ const navItems = [
icon: "mdi:database-arrow-up-outline"
}
]
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") isMenuOpen.value = false
}
document.addEventListener("keydown", handler)
onBeforeUnmount(() => document.removeEventListener("keydown", handler))
})
</script>
<style scoped>

View File

@@ -20,13 +20,13 @@ const getRepoVersion = () => {
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
devtools: { enabled: process.env.NODE_ENV !== "production" },
css: ["~/assets/css/main.css"],
app: {
head: {
link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com ", crossorigin: "" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap"
@@ -35,13 +35,15 @@ export default defineNuxtConfig({
}
},
runtimeConfig: {
authCookieSecure: process.env.AUTH_COOKIE_SECURE === "true",
apiSecretKey: process.env.API_SECRET_KEY,
discordBotToken: process.env.DISCORD_BOT_TOKEN,
discordChannelId: process.env.DISCORD_CHANNEL_ID,
public: {
appVersion: getRepoVersion(),
apiKey: process.env.API_SECRET_KEY
appVersion: getRepoVersion()
}
},
vite: {
plugins: [tailwindcss()]
}
})
})

208
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
{
"name": "disk-monitor",
"name": "supervisor",
"type": "module",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
@@ -13,14 +16,13 @@
},
"dependencies": {
"@iconify/vue": "^5.0.0",
"@nuxt/eslint": "^1.15.2",
"nuxt": "^4.3.1"
},
"devDependencies": {
"@nuxt/eslint": "^1.15.2",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/release-notes-generator": "^14.1.0",
"@tailwindcss/vite": "^4.2.1",
"semantic-release": "^25.0.3",

View File

@@ -1,5 +1,4 @@
<template>
<NuxtLayout name="default">
<div class="dashboard-container">
<header class="dashboard-header">
<div class="header-copy">
@@ -105,16 +104,11 @@
</section>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import { ref } from "vue"
import BackupRun from "~/components/BackupRun.vue"
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
definePageMeta({ layout: false })
type ScriptResult = {
key: string | null
label: string

View File

@@ -1,5 +1,4 @@
<template>
<NuxtLayout name="default">
<div class="dashboard-container">
<header class="dashboard-header">
<div>
@@ -62,16 +61,12 @@
</div>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import {computed, onMounted, ref} from "vue"
import { apiFetch } from "~/composables/useApiAuth"
import type { SystemMetrics } from "~/types/system";
definePageMeta({layout: false})
type DiskSourceResult = {
key: string
label: string
@@ -193,7 +188,7 @@ const runScript = async () => {
const loadSystemMetrics = async () => {
try {
systemMetrics.value = await $fetch<SystemMetrics>("/api/system")
systemMetrics.value = await apiFetch<SystemMetrics>("/api/system")
} catch {
systemMetrics.value = null
} finally {
@@ -334,14 +329,6 @@ onBeforeUnmount(() => {
grid-template-columns: 1fr;
}
.backup-selector {
order: 2;
}
.backup-list-mobile {
order: 3;
}
.speedtest-card-mobile {
order: 4;
}

View File

@@ -1,2 +1,2 @@
User-Agent: *
Disallow:
Disallow: /

View File

@@ -3,12 +3,10 @@ import {
shellQuote,
resolveFolderRemoteDir,
REMOTE_ROOT,
isSafeFolder,
} from "../utils/ssh.ts"
import {process} from "std-env";
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES)
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const MAX_FILES_PER_FOLDER = Math.max(1, Number(process.env.BACKUPS_MAX_FILES) || 50)
function isMissingPathError(error: unknown): boolean {
@@ -32,27 +30,23 @@ function parseLines(output: string): string[] {
.filter(Boolean)
}
function quoteDir(pathValue: string) {
return shellQuote(pathValue)
}
async function listRemoteFiles(remoteDir: string): Promise<string[]> {
const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n ${MAX_FILES_PER_FOLDER}`
`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n ${MAX_FILES_PER_FOLDER}`
)
return parseLines(output)
}
async function listRemoteDirs(remoteRoot: string): Promise<string[]> {
const output = await runSsh(
`cd ${quoteDir(remoteRoot)} && for d in */; do [ -d "$d" ] && printf '%s\n' "\${d%/}"; done`
`cd ${shellQuote(remoteRoot)} && for d in */; do [ -d "$d" ] && printf '%s\n' "\${d%/}"; done`
)
return parseLines(output)
}
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(
`cd ${quoteDir(remoteDir)} && ls -1A | sort -r | head -n 1`
`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`
)
const files = parseLines(output)
return files[0] || null

View File

@@ -4,10 +4,9 @@ import {
resolveFolderRemoteDir
} from "../utils/ssh.ts"
import {process} from "std-env";
import backupOptions from "../config/backup-options.json"
export const BACKUP_HOUR = process.env.BACKUPS_HOUR
export const BACKUP_HOUR = Number(process.env.BACKUPS_HOUR) || 19
type BackupTarget = {
name: string
@@ -141,7 +140,7 @@ export default defineEventHandler(async () => {
latestBackupAt: null,
backupDate: null,
expectedBackupDate: expectedDateKey,
error: error instanceof Error ? error.message : String(error)
error: "Erreur lors de la verification"
}
}
})

View File

@@ -0,0 +1,29 @@
# Créer un bot discord
Allez sur le portail des développeurs Discord : https://discord.com/developers/applications
1. Cliquez sur "New Application" et donnez un nom à votre application.
2. Dans le menu de gauche, cliquez sur "Bot" puis sur "Add Bot".
3. Vous pouvez personnaliser votre bot en lui donnant un nom, une image de profil, etc.
4. Sous la section "Token", cliquez sur "Copy" pour copier le token de votre bot. Gardez ce token secret, car il permet à quiconque de contrôler votre bot.
5. Dans L'onglet "Installation", on peut décocher "Installation pour un utlisateur" si vous voulez installer le bot que sur des serveurs.
6. Tout en bas dans "Paramètres d'installation par défaut", cliquez sur "applications.commands" et sélectionnez "bot" pour donner les permissions nécessaires à votre bot.
7. Ensuite donner les permissions que vous souhaitez à votre bot en cochant les cases correspondantes. Pour l'utilisation pour Supervisor cocher "Voir les anciens messages"
8. Enregistrez les modifications.
9. Puis vous pouvez copier le d'installation et le copier pour inviter le bot au discord

View File

@@ -1,6 +1,7 @@
export default defineEventHandler(async () => {
const token = process.env.DISCORD_BOT_TOKEN
const channel = process.env.DISCORD_CHANNEL_ID
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const token = config.discordBotToken
const channel = config.discordChannelId
if (!token || !channel) {
throw createError({
@@ -19,7 +20,11 @@ export default defineEventHandler(async () => {
}
)
return messages
return (messages as any[]).map((m) => ({
id: m.id,
content: m.content,
author: { username: m.author?.username ?? "Inconnu" }
}))
} catch (error) {
console.error("Erreur Discord messages:", error)

View File

@@ -1,10 +1,16 @@
import { exec } from "child_process"
import { execFile } from "node:child_process"
type DiskSource = {
key: "remote" | "local"
label: string
}
type CommandSpec = {
command: string
args: string[]
cwd?: string
}
const diskSources: DiskSource[] = [
{
key: "remote",
@@ -16,33 +22,28 @@ const diskSources: DiskSource[] = [
}
]
function getDefaultCommand(source: DiskSource) {
function getCommand(source: DiskSource): CommandSpec {
const localScriptDir = process.env.DISK_LOCAL_SCRIPT_DIR || "/home/malio/Malio-ops/CheckStorage"
const remoteHost = process.env.DISK_REMOTE_HOST || "malio-b"
const remoteScriptDir = process.env.DISK_REMOTE_SCRIPT_DIR || "/home/malio-b/Malio-ops/CheckStorage"
if (source.key === "local") {
return `cd ${localScriptDir} && bash check-storage.sh`
return {
command: "bash",
args: ["check-storage.sh"],
cwd: localScriptDir
}
}
return `ssh ${remoteHost} "cd ${remoteScriptDir} && ./check-storage.sh"`
return {
command: "ssh",
args: [remoteHost, `cd ${remoteScriptDir} && ./check-storage.sh`]
}
}
function getEnvCommand(source: DiskSource) {
const envKey = `DISK_COMMAND_${source.key.toUpperCase()}`
const legacyEnvKey =
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
return (
process.env[envKey] ||
(legacyEnvKey ? process.env[legacyEnvKey] : undefined) ||
getDefaultCommand(source)
)
}
function runShellCommand(command: string): Promise<string> {
function runCommand({ command, args, cwd }: CommandSpec): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
execFile(command, args, { cwd }, (error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
return
@@ -56,12 +57,7 @@ export default defineEventHandler(async () => {
const results = await Promise.all(
diskSources.map(async (source) => {
try {
const envCommand = getEnvCommand(source)
if (!envCommand) {
throw new Error(`Commande disque manquante pour ${source.key}`)
}
const output = await runShellCommand(envCommand)
const output = await runCommand(getCommand(source))
return {
key: source.key,
label: source.label,

View File

@@ -3,11 +3,11 @@ import {
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
isSafeFolder,
isSafeFile
} from "../utils/ssh.ts"
import { spawn } from "node:child_process"
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`)
const fileName = output.trim()
@@ -30,6 +30,9 @@ export default defineEventHandler(async (event) => {
if (folderNames.length === 0) {
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
}
if (!REMOTE_HOST) {
throw createError({ statusCode: 503, statusMessage: "Service non configure" })
}
if (folderNames.some((folder) => !isSafeFolder(folder))) {
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
@@ -45,7 +48,7 @@ export default defineEventHandler(async (event) => {
}
const fileName = await getLatestRemoteFile(remoteDir)
if (!fileName) {
if (!fileName || !isSafeFile(fileName)) {
continue
}
@@ -93,6 +96,6 @@ export default defineEventHandler(async (event) => {
console.error(`Erreur archive SSH (${code}): ${stderr}`)
}
})
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout)
})

View File

@@ -3,13 +3,11 @@ import {
shellQuote,
resolveFolderRemoteDir,
REMOTE_HOST,
isSafeFolder,
isSafeFile
} from "../utils/ssh.ts"
import { spawn } from "node:child_process"
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
function buildContentDisposition(fileName: string) {
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
@@ -20,6 +18,10 @@ export default defineEventHandler(async (event) => {
const folderName = typeof folder === "string" ? folder : null
const fileName = typeof file === "string" ? file : null
if (!REMOTE_HOST) {
throw createError({ statusCode: 503, statusMessage: "Service non configure" })
}
if (!folderName || !fileName) {
throw createError({ statusCode: 400, statusMessage: "Paramètres manquants" })
}
@@ -61,6 +63,6 @@ export default defineEventHandler(async (event) => {
console.error(`Erreur téléchargement SSH (${code}): ${stderr}`)
}
})
event.node.res.on("close", () => child.kill())
return sendStream(event, child.stdout)
})

View File

@@ -1,11 +1,14 @@
export default defineEventHandler(async (event) => {
const req = event.node.req
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 // 100MB
let received = 0
for await (const chunk of req) {
received += chunk.length
if (received > MAX_UPLOAD_BYTES) {
event.node.res.destroy()
throw createError({statusCode: 413, statusMessage: "Fichier trop volumineux"})
}
}
return { received }
})
return {received}
})

View File

@@ -1,33 +1,160 @@
import targets from "../config/version-status-targets.json"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
type StatusEntry = {
checkedAt: string
status: "OK" | "DOWN"
host: string
detail: string
}
type StatusResult = {
label: string
url: string
ok: boolean
status: number
checkedAt: string
detail: string
}
const DEFAULT_RECETTE_SCRIPTS_DIR = "/home/malio/Malio-ops/RecetteScripts"
function parseEnvFile(content: string) {
const values: Record<string, string> = {}
for (const rawLine of content.split("\n")) {
const line = rawLine.trim()
if (!line || line.startsWith("#")) {
continue
}
const separatorIndex = line.indexOf("=")
if (separatorIndex === -1) {
continue
}
const key = line.slice(0, separatorIndex).trim()
const value = line.slice(separatorIndex + 1).trim()
if (!key) {
continue
}
values[key] = value.replace(/^['"]|['"]$/g, "")
}
return values
}
function getLogFileName(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `app_health_${year}-${month}-${day}.log`
}
function parseStatusLine(line: string): StatusEntry | null {
const parts = line.split(" | ")
if (parts.length < 4) {
return null
}
const [checkedAt, status, host, ...detailParts] = parts
if ((status !== "OK" && status !== "DOWN") || !host) {
return null
}
return {
checkedAt,
status,
host,
detail: detailParts.join(" | ")
}
}
function buildStatusResult(entry: StatusEntry): StatusResult {
return {
label: entry.host,
url: `http://${entry.host}/`,
ok: entry.status === "OK",
status: entry.status === "OK" ? 200 : 0,
checkedAt: entry.checkedAt,
detail: entry.detail
}
}
export default defineEventHandler(async () => {
const results = await Promise.all(
targets.map(async (target) => {
try {
const response = await fetch(target.url, {
method: "GET",
headers: { Accept: "application/json" }
})
const recetteScriptsDir = process.env.RECETTE_SCRIPTS_DIR || DEFAULT_RECETTE_SCRIPTS_DIR
const envFilePath = join(recetteScriptsDir, ".env")
return {
label: target.label,
url: target.url,
ok: response.status === 200,
status: response.status,
checkedAt: new Date().toISOString()
}
} catch (error) {
return {
label: target.label,
url: target.url,
ok: false,
status: 0,
checkedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error)
}
try {
const envFileContent = await readFile(envFilePath, "utf8")
const envValues = parseEnvFile(envFileContent)
const logDir = envValues.APP_LOG_DIR
if (!logDir) {
throw createError({
statusCode: 500,
statusMessage: "Variable APP_LOG_DIR manquante"
})
}
const logFilePath = join(logDir, getLogFileName(new Date()))
const logFileContent = await readFile(logFilePath, "utf8")
const latestEntriesByHost = new Map<string, StatusEntry>()
for (const line of logFileContent.split("\n")) {
const entry = parseStatusLine(line)
if (!entry) {
continue
}
})
)
return { results }
latestEntriesByHost.set(entry.host, entry)
}
const configuredHosts = (envValues.APP_URLS || "")
.split(/\s+/)
.map((host) => host.trim())
.filter(Boolean)
const orderedResults = configuredHosts
.map((host) => latestEntriesByHost.get(host))
.filter((entry): entry is StatusEntry => Boolean(entry))
.map(buildStatusResult)
const remainingResults = Array.from(latestEntriesByHost.entries())
.filter(([host]) => !configuredHosts.includes(host))
.map(([, entry]) => buildStatusResult(entry))
const results = [...orderedResults, ...remainingResults]
if (results.length === 0) {
throw createError({
statusCode: 503,
statusMessage: "Aucun statut disponible"
})
}
return {
results
}
} catch (error) {
console.error("Erreur lecture status recette:", error)
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"statusMessage" in error
) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: "Erreur lors de l'opération"
})
}
})

View File

@@ -1,5 +0,0 @@
[
{ "label": "Ferme", "url": "http://ferme.malio-dev.fr/api/version" },
{ "label": "SIRH", "url": "http://sirh.malio-dev.fr/api/version" },
{ "label": "Inventory", "url": "http://inventory.malio-dev.fr/api/health" }
]

View File

@@ -1,3 +1,9 @@
// SECURITE:
// Ce middleware pose automatiquement le cookie d'authentification pour tout
// visiteur de l'interface web. Ce comportement repose sur l'hypothèse que
// Supervisor n'est exposé qu'à un réseau de confiance ou derrière un contrôle
// d'accès externe. Si l'application devient publiquement accessible, ce
// mécanisme ne constitue pas une authentification utilisateur.
export default defineEventHandler((event) => {
const path = event.path || event.node.req.url || ""
@@ -16,7 +22,7 @@ export default defineEventHandler((event) => {
return
}
const secureCookie = process.env.AUTH_COOKIE_SECURE === "true"
const secureCookie = runtimeConfig.authCookieSecure
setCookie(event, "api_auth_token", expectedToken, {
httpOnly: true,

View File

@@ -1,3 +1,5 @@
import { shellQuote } from "./ssh"
export type BackupScript = {
key: string
label: string
@@ -32,10 +34,10 @@ const getDefaultBackupScriptCommands = (): Record<string, string> => {
process.env.VAULTWARDEN_SCRIPTS_DIR || "/home/matt/vaultwarden/Malio-ops/BackupVaultWarden"
return {
"backup-bdd-recette": `cd ${recetteScriptsDir} && bash backup-bdd-recette.sh`,
"check-statut-recette": `cd ${recetteScriptsDir} && bash check-statut-recette.sh`,
"backup-bdd-recette": `cd ${shellQuote(recetteScriptsDir)} && bash backup-bdd-recette.sh`,
"check-statut-recette": `cd ${shellQuote(recetteScriptsDir)} && bash check-statut-recette.sh`,
"backup-vaultwarden":
`ssh ${vaultwardenHost} "cd ${vaultwardenScriptsDir} && bash backup-vaultwarden.sh"`
`ssh ${shellQuote(vaultwardenHost)} "cd ${shellQuote(vaultwardenScriptsDir)} && bash backup-vaultwarden.sh"`
}
}

View File

@@ -1,20 +1,23 @@
import { execFile } from "node:child_process"
import {process} from "std-env";
import {execFile} from "node:child_process"
import folderMap from "#server/config/backup-folders.json";
export const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST
export const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
export const FOLDER_MAP = folderMap as Record<string, string>
export const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
export const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
export const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
export function runSsh(command: string): Promise<string> {
if (!REMOTE_HOST) {
return Promise.reject(new Error("BACKUPS_REMOTE_HOST is not configured"))
}
return new Promise((resolve, reject) => {
execFile(
"ssh",
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
{ maxBuffer: 10 * 1024 * 1024 },
{maxBuffer: 10 * 1024 * 1024},
(error, stdout, stderr) => {
if (error) {
reject(stderr || error.message)
@@ -42,7 +45,7 @@ export async function resolveFolderRemoteDir(folderName: string): Promise<string
return direct
}
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
const nested = `${REMOTE_ROOT}/bdd-recette/${folderName}`
if (await remoteDirExists(nested)) {
return nested
}