CI : Deployment
Some checks failed
release / build (push) Failing after 45s
release / verify-windows (push) Has been skipped

This commit is contained in:
jeanotx32
2026-08-11 17:10:08 +02:00
parent b529417940
commit 105d07b82b
16 changed files with 1662 additions and 86 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
node_modules
**/node_modules
dist
**/dist
**/dist-bundle
*.tsbuildinfo
data
.env
.env.local
.git
.github
.gitea
*.log
*.sqlite*
deploy
README.md
DEPLOY.md
docker-compose.yml

42
.env.production.example Normal file
View File

@@ -0,0 +1,42 @@
# Configuration de production — copier vers .env à côté de docker-compose.yml
#
# cp .env.production.example .env && chmod 600 .env
# --- Image ------------------------------------------------------------------
# Renseigner l'hôte de ton instance Gitea et le propriétaire du paquet.
IMAGE=gitea.exemple.com/jeanbon/stream-control-server:latest
# --- Publication du port ----------------------------------------------------
# Interface d'écoute. Mets l'IP LAN/VPN de la machine si elle a aussi une patte
# publique — en HTTP, ce port ne doit jamais être joignable depuis Internet.
BIND_ADDR=0.0.0.0
HOST_PORT=8080
# --- Secrets ----------------------------------------------------------------
# Mot de passe du dashboard.
ADMIN_PASSWORD=
# Clé de signature des sessions. Générer :
# openssl rand -hex 32
SESSION_SECRET=
# Jeton d'enrôlement présenté par les agents à leur première connexion.
# Générer : openssl rand -hex 24
# Laisser vide pour désactiver l'auto-enregistrement (provisionnement manuel).
ENROLLMENT_TOKEN=
# --- Réglages ---------------------------------------------------------------
# Fréquence de remontée d'état des agents (ms).
STATUS_INTERVAL_MS=2000
# Délai sans nouvelle avant de déclarer un agent hors-ligne (ms).
AGENT_TIMEOUT_MS=15000
# Délai d'attente d'une réponse d'agent à une commande (ms).
COMMAND_TIMEOUT_MS=15000
# Durée de validité d'une session dashboard (ms) — 12 h par défaut.
SESSION_TTL_MS=43200000
# Nombre d'entrées de journal conservées en base.
LOG_RETENTION=2000

View File

@@ -0,0 +1,204 @@
name: release
# Construit l'image du plan de contrôle et publie le bundle d'agent.
#
# Prérequis côté Gitea :
# - un secret REGISTERYKEY (jeton d'accès avec les portées repository + package)
# - les paquets activés sur l'instance
# - un runner ubuntu-latest ayant accès au socket Docker
# - un runner windows-latest (test de fumée de l'agent)
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
env:
IMAGE_NAME: stream-control-server
PACKAGE_NAME: stream-control-agent
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
registry: ${{ steps.meta.outputs.registry }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
# --- Identité de la version -----------------------------------------
- name: Calculer version et registre
id: meta
run: |
set -euo pipefail
# Une étiquette v1.2.3 donne 1.2.3 ; sinon 0.1.0-<sha court>.
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
VERSION="${VERSION#v}"
else
VERSION="0.1.0-${GITHUB_SHA:0:7}"
fi
# L'hôte du registre est celui de l'instance Gitea. La variable
# REGISTRY_HOST permet de le forcer (instance derrière un autre nom).
REGISTRY="${{ vars.REGISTRY_HOST }}"
if [ -z "$REGISTRY" ]; then
REGISTRY="${GITHUB_SERVER_URL#http://}"
REGISTRY="${REGISTRY#https://}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT"
echo "Version $VERSION → registre $REGISTRY"
# --- Qualité ---------------------------------------------------------
- name: Installer les dépendances
run: npm ci
- name: Contrôle de types
run: npm run typecheck
- name: Compiler
run: npm run build
- name: Produire le bundle d'agent
run: npm run bundle -w @stream-control/agent
# Un bundle qui ne démarre pas ne doit jamais être publié. Le serveur est
# volontairement injoignable ici : on vérifie que le diagnostic s'exécute.
- name: Vérifier le bundle (Linux)
env:
AGENT_TOKEN: ci-smoke-test
SERVER_URL: ws://127.0.0.1:1/ws/agent
run: |
set -euo pipefail
OUT="$(node packages/agent/dist-bundle/agent.cjs --check 2>&1 || true)"
echo "$OUT"
echo "$OUT" | grep -q 'obs-websocket' || { echo 'Diagnostic incomplet'; exit 1; }
# --- Image du plan de contrôle ---------------------------------------
- name: Authentification au registre
run: |
echo "${{ secrets.REGISTERYKEY }}" \
| docker login "${{ steps.meta.outputs.registry }}" \
--username "${{ github.repository_owner }}" --password-stdin
- name: Construire et pousser l'image
run: |
set -euo pipefail
BASE="${{ steps.meta.outputs.registry }}/${{ github.repository_owner }}/${IMAGE_NAME}"
VERSION="${{ steps.meta.outputs.version }}"
docker build \
--tag "$BASE:$VERSION" \
--tag "$BASE:sha-${GITHUB_SHA:0:7}" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.source=$GITHUB_SERVER_URL/${{ github.repository }}" \
.
docker push "$BASE:$VERSION"
docker push "$BASE:sha-${GITHUB_SHA:0:7}"
# :latest ne suit que main et les étiquettes, jamais une branche de travail.
if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* ]]; then
docker tag "$BASE:$VERSION" "$BASE:latest"
docker push "$BASE:latest"
fi
# --- Bundle d'agent et scripts d'installation ------------------------
- name: Publier les artefacts d'agent
run: |
set -euo pipefail
API="${GITHUB_SERVER_URL}/api/packages/${{ github.repository_owner }}/generic/${PACKAGE_NAME}"
AUTH="${{ github.repository_owner }}:${{ secrets.REGISTERYKEY }}"
VERSION="${{ steps.meta.outputs.version }}"
publish() {
local version="$1" file="$2" path="$3"
curl -sS -f -X PUT --user "$AUTH" --upload-file "$path" \
"$API/$version/$file"
echo " publié : $version/$file"
}
# Version immuable, tracée.
publish "$VERSION" agent.cjs packages/agent/dist-bundle/agent.cjs
publish "$VERSION" install-agent.sh deploy/install-agent.sh
publish "$VERSION" install-agent.ps1 deploy/install-agent.ps1
# Alias « latest », que visent les commandes d'installation. Le registre
# générique refuse d'écraser un fichier : on supprime la version d'abord.
if [[ "$GITHUB_REF" == refs/heads/main || "$GITHUB_REF" == refs/tags/* ]]; then
curl -sS -X DELETE --user "$AUTH" "$API/latest" >/dev/null || true
publish latest agent.cjs packages/agent/dist-bundle/agent.cjs
publish latest install-agent.sh deploy/install-agent.sh
publish latest install-agent.ps1 deploy/install-agent.ps1
fi
- name: Récapitulatif
run: |
BASE="${{ steps.meta.outputs.registry }}/${{ github.repository_owner }}/${IMAGE_NAME}"
{
echo "### Publié"
echo ""
echo "| Artefact | Référence |"
echo "| --- | --- |"
echo "| Image | \`$BASE:${{ steps.meta.outputs.version }}\` |"
echo "| Agent | \`${PACKAGE_NAME}/${{ steps.meta.outputs.version }}/agent.cjs\` |"
} >> "$GITHUB_STEP_SUMMARY"
# Le bundle doit démarrer sur Windows : chemins, PowerShell, résolution de
# modules. Un test sur Linux seul ne couvre pas ces différences.
verify-windows:
runs-on: windows-latest
needs: build
steps:
- uses: actions/checkout@v4
# Le script d'installation Windows n'est exécutable que sur Windows :
# ce contrôle de syntaxe est la seule barrière avant qu'il n'atterrisse
# sur une VM de production.
- name: Valider la syntaxe du script d'installation
shell: pwsh
run: |
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path 'deploy/install-agent.ps1'), [ref]$null, [ref]$errors) | Out-Null
if ($errors) {
$errors | ForEach-Object { Write-Host "$($_.Extent.StartLineNumber): $($_.Message)" }
throw "$($errors.Count) erreur(s) de syntaxe dans install-agent.ps1"
}
Write-Host 'install-agent.ps1 : syntaxe valide.'
- name: Récupérer le bundle publié
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$url = "$env:GITHUB_SERVER_URL/api/packages/${{ github.repository_owner }}/generic/$env:PACKAGE_NAME/${{ needs.build.outputs.version }}/agent.cjs"
$pair = "${{ github.repository_owner }}:${{ secrets.REGISTERYKEY }}"
$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair))
Invoke-WebRequest -Uri $url -Headers @{ Authorization = "Basic $auth" } -OutFile agent.cjs
Write-Host "Bundle récupéré : $((Get-Item agent.cjs).Length) octets"
- name: Exécuter le diagnostic
shell: pwsh
env:
AGENT_TOKEN: ci-smoke-test
SERVER_URL: ws://127.0.0.1:1/ws/agent
run: |
$out = (& node agent.cjs --check 2>&1 | Out-String)
Write-Host $out
# Le serveur est injoignable par construction : on valide que le
# diagnostic s'est déroulé, pas son verdict.
foreach ($marker in @('obs-websocket', 'powershell')) {
if ($out -notmatch [regex]::Escape($marker)) {
throw "Diagnostic incomplet : « $marker » absent de la sortie"
}
}
Write-Host 'Bundle fonctionnel sous Windows.'

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
dist-bundle/
*.tsbuildinfo *.tsbuildinfo
.env .env
.env.local .env.local

250
DEPLOY.md Normal file
View File

@@ -0,0 +1,250 @@
# Mise en production
Déploiement de Stream Control : l'interface en Docker Compose, les agents en une
commande, les artefacts construits et publiés par Gitea Actions.
```
┌──────────────────────────┐ ┌───────────────────────────────┐
│ Gitea │ │ Machine du plan de contrôle │
│ ├─ registre d'images ───┼───────►│ docker compose up -d │
│ └─ paquets génériques │ │ → http://control.lan:8080 │
│ │ agent.cjs │ └───────────────┬───────────────┘
└─────────┼────────────────┘ │ ws:// (réseau privé)
│ install-agent.sh/.ps1 ┌──────────┴──────────┐
└─────────────────────────────►│ VM Ubuntu/Windows │
│ agent + OBS │
└─────────────────────┘
```
L'interface est conteneurisée ; **les agents ne le sont pas**. Ils doivent voir
l'OBS local et la fenêtre du navigateur, ce qu'un conteneur ne permet pas.
## Prérequis
| Où | Quoi |
| --- | --- |
| Gitea | Paquets activés, un runner `ubuntu-latest` avec accès au socket Docker, un runner `windows-latest` |
| Plan de contrôle | Docker + Docker Compose, sur le réseau privé |
| VM d'enregistrement | OBS 28+ avec obs-websocket activé. Node.js est installé par le script si absent |
---
## Étape 1 — Préparer Gitea
### Secret
Dans **Paramètres du dépôt → Actions → Secrets**, ajoute :
| Nom | Valeur |
| --- | --- |
| `REGISTERYKEY` | Jeton d'accès du compte `jeanbon`, portées **repository** et **package** |
### Variable optionnelle
Le workflow déduit l'hôte du registre de l'URL de ton instance. Si elle est
publiée sous un autre nom que celui vu par les runners, ajoute la variable
`REGISTRY_HOST` (par ex. `gitea.exemple.com`, sans schéma).
### Vérifier les runners
Dans **Site Administration → Runners**, les labels doivent inclure
`ubuntu-latest` et `windows-latest`. S'ils diffèrent, adapte les deux champs
`runs-on:` de [.gitea/workflows/release.yml](.gitea/workflows/release.yml).
---
## Étape 2 — Premier build
Pousse sur `main`, ou lance le workflow à la main. Il produit :
| Artefact | Emplacement |
| --- | --- |
| Image du plan de contrôle | `<gitea>/jeanbon/stream-control-server:latest` |
| Bundle d'agent | paquet générique `stream-control-agent`, fichier `agent.cjs` |
| Scripts d'installation | même paquet, `install-agent.sh` et `install-agent.ps1` |
Chaque build publie une version immuable (`0.1.0-<sha>`, ou `1.2.3` sur une
étiquette `v1.2.3`). L'alias `latest` ne suit que `main` et les étiquettes.
Le pipeline refuse de publier un bundle qui ne démarre pas : il exécute
`agent.cjs --check` sur Linux **et** sur Windows, et valide la syntaxe de
`install-agent.ps1` sur le runner Windows.
---
## Étape 3 — Déployer l'interface
Sur la machine du plan de contrôle :
```bash
mkdir -p /opt/stream-control && cd /opt/stream-control
# Récupérer compose et modèle d'environnement depuis le dépôt
curl -fsSLO https://gitea.exemple.com/jeanbon/stream-control/raw/branch/main/docker-compose.yml
curl -fsSL https://gitea.exemple.com/jeanbon/stream-control/raw/branch/main/.env.production.example -o .env
chmod 600 .env
```
Génère les secrets et complète `.env` :
```bash
echo "SESSION_SECRET=$(openssl rand -hex 32)"
echo "ENROLLMENT_TOKEN=$(openssl rand -hex 24)"
```
À renseigner impérativement :
| Variable | Rôle |
| --- | --- |
| `IMAGE` | `gitea.exemple.com/jeanbon/stream-control-server:latest` |
| `ADMIN_PASSWORD` | Mot de passe du dashboard |
| `SESSION_SECRET` | Clé de signature des sessions |
| `ENROLLMENT_TOKEN` | Jeton présenté par les agents à leur première connexion |
| `BIND_ADDR` | Interface d'écoute — mets l'IP LAN/VPN si la machine a une patte publique |
Puis :
```bash
docker login gitea.exemple.com -u jeanbon # jeton avec la portée package
docker compose up -d
docker compose logs -f
```
Contrôle : `curl http://localhost:8080/healthz` doit répondre
`{"ok":true,"agents":0}`.
La base SQLite vit dans `./data`, monté en volume — **c'est le seul état à
sauvegarder**. Une copie à chaud se fait avec :
```bash
docker compose exec server sh -c 'sqlite3 /app/data/stream-control.sqlite ".backup /app/data/backup.sqlite"' \
2>/dev/null || cp data/stream-control.sqlite data/backup-$(date +%F).sqlite
```
---
## Étape 4 — Déployer un agent
Une seule commande par VM. Le `<JETON_ENROLEMENT>` est l'`ENROLLMENT_TOKEN` du
serveur : l'agent s'enregistre tout seul, reçoit un jeton permanent et le
persiste.
### Ubuntu
```bash
curl -fsSL https://gitea.exemple.com/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.sh \
| sudo bash -s -- \
--registry https://gitea.exemple.com \
--server ws://control.lan:8080/ws/agent \
--token <JETON_ENROLEMENT> \
--name vm-rec-01 \
--obs-password <MDP_OBS>
```
Le script installe Node.js et `xdotool` si besoin, dépose le bundle dans
`/opt/stream-control-agent`, écrit un service systemd et lance un diagnostic.
L'agent tourne sous le compte qui a invoqué `sudo` — celui qui ouvre la session
graphique d'OBS. Force-le avec `--user` si ce n'est pas le bon.
### Windows
Dans un PowerShell **administrateur**, sur le compte qui lance OBS :
```powershell
& ([scriptblock]::Create((irm 'https://gitea.exemple.com/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.ps1'))) `
-Registry 'https://gitea.exemple.com' `
-Server 'ws://control.lan:8080/ws/agent' `
-Token '<JETON_ENROLEMENT>' `
-Name 'vm-rec-02' `
-ObsPassword '<MDP_OBS>'
```
L'agent est enregistré comme **tâche planifiée à l'ouverture de session**, pas
comme service : un service Windows tourne en session 0 et ne verrait ni OBS ni la
fenêtre du navigateur.
### Si le paquet est privé
Ajoute `--package-token <JETON>` (Linux) ou `-PackageToken '<JETON>'` (Windows),
avec un jeton en lecture sur les paquets.
### Vérifier
```bash
sudo -u <user> node /opt/stream-control-agent/agent.cjs --check # Linux
node C:\stream-control-agent\agent.cjs --check # Windows
```
Le diagnostic contrôle la configuration, la joignabilité du serveur et d'OBS, et
les prérequis du rappel plein écran (`xdotool`, `DISPLAY`, PowerShell).
---
## Mises à jour
**Interface** — le tag `latest` bouge à chaque build sur `main` :
```bash
cd /opt/stream-control && docker compose pull && docker compose up -d
```
Épingle une version précise dans `IMAGE` si tu préfères maîtriser le moment.
**Agents** — relance exactement la même commande d'installation. Le script
détecte l'installation existante, remplace le binaire et **conserve
`agent.config.json`** : l'identité de l'agent est préservée. Sans cela, l'agent
se ré-enrôlerait et apparaîtrait en double dans le dashboard.
Pour installer une version figée plutôt que `latest`, remplace `latest` par la
version voulue dans l'URL **et** passe `--version <VERSION>`.
---
## Sécurité
Ce déploiement est en **HTTP sur réseau privé**, conformément au choix retenu.
Ce que cela implique concrètement :
- Les jetons d'agent, le mot de passe du dashboard et les jetons de session
circulent **en clair**. Toute machine capable de sniffer le segment réseau peut
les récupérer et prendre la main sur les agents.
- Le port ne doit **jamais** être joignable depuis Internet. Restreins `BIND_ADDR`
à l'IP LAN ou VPN, et ferme le port au pare-feu :
```bash
ufw allow from 10.0.0.0/8 to any port 8080 proto tcp
```
- Le mot de passe obs-websocket est stocké en base et poussé aux agents ; il n'est
jamais renvoyé au navigateur (masqué en `********`).
- `agent.config.json` est en `600` et porte le jeton permanent de l'agent.
Passer en HTTPS plus tard ne demande qu'un reverse proxy devant le conteneur et
le remplacement de `ws://` par `wss://` dans les commandes d'installation —
[deploy/nginx.conf.example](deploy/nginx.conf.example) donne le bloc de conf.
La rotation des jetons se fait alors depuis le dashboard, agent par agent.
---
## Dépannage
| Symptôme | Piste |
| --- | --- |
| `docker compose up` : *manifest unknown* | `docker login` non fait, ou build jamais lancé |
| Le conteneur redémarre en boucle | `docker compose logs server` — le plus souvent `ADMIN_PASSWORD` ou `SESSION_SECRET` absent de `.env` |
| Agent absent du dashboard | `--server` erroné, ou `ENROLLMENT_TOKEN` différent de celui du serveur. `journalctl -u stream-control-agent -f` |
| Agent en ligne, OBS déconnecté | obs-websocket désactivé ou mot de passe erroné. Le diagnostic teste le port |
| Rappel plein écran sans effet (Linux) | Session Wayland au lieu de X11, ou `DISPLAY` inaccessible au service. Le diagnostic signale les deux |
| Rappel plein écran sans effet (Windows) | La tâche ne tourne pas dans la session interactive : vérifie que le compte de `-RunAsUser` est bien celui ouvert sur la VM |
| Agent en double après réinstallation | `agent.config.json` avait été supprimé : l'agent s'est ré-enrôlé. Supprime le doublon dans le dashboard |
| Workflow : *unauthorized* au push | `REGISTERYKEY` sans la portée **package**, ou expiré |
### Journaux
```bash
docker compose logs -f server # plan de contrôle
journalctl -u stream-control-agent -f # agent Linux
Get-ScheduledTaskInfo -TaskName StreamControlAgent # agent Windows
```
Le dashboard affiche aussi le journal consolidé de tous les agents, persisté en
base — souvent le point de départ le plus rapide.

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# Image du plan de contrôle : API, passerelle agents et dashboard compilé.
# Les agents ne sont PAS conteneurisés — ils tournent nativement sur les VM,
# aux côtés d'OBS (voir deploy/install-agent.sh et deploy/install-agent.ps1).
# --- Étape 1 : compilation --------------------------------------------------
FROM node:24-alpine AS builder
WORKDIR /app
# Le manifeste d'abord : cette couche est réutilisée tant que les dépendances
# ne bougent pas, même quand le code source change.
COPY package.json package-lock.json ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/server/package.json ./packages/server/
COPY packages/agent/package.json ./packages/agent/
COPY packages/web/package.json ./packages/web/
RUN npm ci
COPY tsconfig.base.json ./
COPY packages ./packages
RUN npm run build
# --- Étape 2 : exécution ----------------------------------------------------
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Installation propre sans dépendances de développement. Les manifestes de tous
# les workspaces sont nécessaires : npm ci refuse un lockfile incomplet.
COPY package.json package-lock.json ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/server/package.json ./packages/server/
COPY packages/agent/package.json ./packages/agent/
COPY packages/web/package.json ./packages/web/
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/server/dist ./packages/server/dist
COPY --from=builder /app/packages/web/dist ./packages/web/dist
# La base SQLite vit ici ; à monter en volume pour survivre aux mises à jour.
RUN mkdir -p /app/data && chown -R node:node /app/data
VOLUME ["/app/data"]
USER node
EXPOSE 8080
# Le port interne est fixe : c'est le compose qui décide de la publication.
ENV PORT=8080 \
HOST=0.0.0.0 \
DB_PATH=./data/stream-control.sqlite
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
CMD ["node", "--disable-warning=ExperimentalWarning", "packages/server/dist/index.js"]

View File

@@ -59,30 +59,31 @@ apparaît dans le dashboard sur <http://localhost:5173>.
## Déploiement ## Déploiement
### Serveur de contrôle La procédure complète est dans **[DEPLOY.md](DEPLOY.md)** : interface en Docker Compose,
agents en une commande, artefacts construits et publiés par Gitea Actions.
En résumé :
```bash ```bash
npm ci && npm run build # Interface (machine du plan de contrôle)
npm start # sert l'API, le WebSocket et le dashboard compilé sur $PORT docker compose up -d
# Agent Ubuntu
curl -fsSL <gitea>/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.sh \
| sudo bash -s -- --registry <gitea> --server ws://control.lan:8080/ws/agent --token <JETON>
# Agent Windows (PowerShell administrateur)
& ([scriptblock]::Create((irm '<gitea>/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.ps1'))) `
-Registry '<gitea>' -Server 'ws://control.lan:8080/ws/agent' -Token '<JETON>'
``` ```
Mets-le derrière un reverse proxy TLS ([exemple nginx](deploy/nginx.conf.example)) et Les agents ne sont volontairement pas conteneurisés : ils doivent voir l'OBS local et la
installe l'unité systemd [`deploy/stream-control-server.service`](deploy/stream-control-server.service). fenêtre du navigateur.
`ADMIN_PASSWORD` et `SESSION_SECRET` sont obligatoires hors développement.
### Agent Ubuntu Pour vérifier une installation à tout moment :
Copie `packages/agent/dist`, `packages/agent/node_modules` et `agent.config.json` dans ```bash
`/opt/stream-control-agent`, puis installe node /opt/stream-control-agent/agent.cjs --check
[`deploy/stream-control-agent.service`](deploy/stream-control-agent.service).
### Agent Windows
Un service Windows classique tourne en session 0 et ne verrait pas OBS. Utilise la tâche
planifiée « à l'ouverture de session » fournie :
```powershell
.\deploy\windows-agent-task.ps1 -AgentDir 'C:\stream-control-agent' -RunAsUser 'VM01\obs'
``` ```
## Configuration ## Configuration

172
deploy/install-agent.ps1 Normal file
View File

@@ -0,0 +1,172 @@
<#
.SYNOPSIS
Installe (ou met à jour) l'agent Stream Control sur une VM Windows.
.DESCRIPTION
L'agent est enregistré comme tâche planifiée « à l'ouverture de session », et
non comme service Windows : un service tourne en session 0 et ne verrait ni la
fenêtre du navigateur, ni OBS.
Réexécuter le script met à jour le binaire sans toucher à l'identité d'un agent
déjà enrôlé.
.EXAMPLE
& ([scriptblock]::Create((irm 'https://gitea.exemple.com/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.ps1'))) `
-Registry 'https://gitea.exemple.com' `
-Server 'ws://control.lan:8080/ws/agent' `
-Token '<JETON_ENROLEMENT>'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string] $Registry,
[string] $Server = '',
[string] $Token = '',
[string] $Name = $env:COMPUTERNAME,
[string] $Version = 'latest',
[string] $Owner = 'jeanbon',
[string] $PackageToken = '',
[string] $ObsHost = '127.0.0.1',
[int] $ObsPort = 4455,
[string] $ObsPassword = '',
[string] $InstallDir = 'C:\stream-control-agent',
[string] $RunAsUser = "$env:USERDOMAIN\$env:USERNAME",
[string] $TaskName = 'StreamControlAgent'
)
$ErrorActionPreference = 'Stop'
$Registry = $Registry.TrimEnd('/')
function Info($msg) { Write-Host "· $msg" }
function Die($msg) { Write-Host "$msg" -ForegroundColor Red; exit 1 }
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) { Die 'À exécuter dans un PowerShell administrateur.' }
$configFile = Join-Path $InstallDir 'agent.config.json'
$isUpgrade = Test-Path $configFile
if (-not $isUpgrade) {
if (-not $Server) { Die '-Server est obligatoire pour une première installation.' }
if (-not $Token) { Die '-Token est obligatoire pour une première installation.' }
}
Write-Host ''
Write-Host 'Installation de l''agent Stream Control'
Write-Host " compte : $RunAsUser"
Write-Host " destination : $InstallDir"
Write-Host " version : $Version"
Write-Host ''
# --- Node.js ----------------------------------------------------------------
$nodeMajor = 0
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if ($nodeCmd) {
$nodeMajor = [int]((& node -p 'process.versions.node.split(".")[0]') 2>$null)
}
if ($nodeMajor -lt 22) {
Info "Node.js 22+ requis (trouvé : $nodeMajor) — installation via winget"
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Die 'winget introuvable. Installe Node.js 22+ manuellement puis relance : https://nodejs.org/'
}
& winget install --id OpenJS.NodeJS.LTS --silent --accept-source-agreements --accept-package-agreements
# winget ne rafraîchit pas le PATH de la session courante.
$env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' +
[Environment]::GetEnvironmentVariable('Path', 'User')
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die 'Node.js installé mais absent du PATH — rouvre PowerShell et relance le script.'
}
} else {
Info "Node.js $(& node -v) présent"
}
# --- Téléchargement du bundle -----------------------------------------------
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$url = "$Registry/api/packages/$Owner/generic/stream-control-agent/$Version/agent.cjs"
Info "Téléchargement depuis $url"
$headers = @{}
if ($PackageToken) { $headers['Authorization'] = "token $PackageToken" }
$tmp = Join-Path $InstallDir 'agent.cjs.tmp'
try {
Invoke-WebRequest -Uri $url -Headers $headers -OutFile $tmp -UseBasicParsing
} catch {
Die "Téléchargement impossible ($($_.Exception.Message)). Vérifie -Registry, -Version, et -PackageToken si le paquet est privé."
}
& node --check $tmp 2>$null
if ($LASTEXITCODE -ne 0) { Die 'Le fichier téléchargé n''est pas un script Node valide.' }
Move-Item -Force $tmp (Join-Path $InstallDir 'agent.cjs')
# --- Configuration ----------------------------------------------------------
if ($isUpgrade) {
# Un agent déjà enrôlé détient un jeton permanent : l'écraser le ferait
# réapparaître comme un second agent dans le dashboard.
Info 'Configuration existante conservée (identité de l''agent préservée)'
} else {
$config = [ordered]@{
serverUrl = $Server
token = $Token
name = $Name
obs = [ordered]@{ host = $ObsHost; port = $ObsPort; password = $ObsPassword }
}
$config | ConvertTo-Json -Depth 5 | Set-Content -Path $configFile -Encoding UTF8
Info "Configuration écrite dans $configFile"
}
# Le fichier porte un jeton : lecture réservée au compte de l'agent et aux admins.
$acl = Get-Acl $configFile
$acl.SetAccessRuleProtection($true, $false)
foreach ($identity in @($RunAsUser, 'BUILTIN\Administrators', 'NT AUTHORITY\SYSTEM')) {
try {
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
$identity, 'FullControl', 'Allow')))
} catch {
Write-Host "! Règle d'accès ignorée pour $identity"
}
}
Set-Acl -Path $configFile -AclObject $acl
# --- Tâche planifiée --------------------------------------------------------
$nodePath = (Get-Command node).Source
$entry = Join-Path $InstallDir 'agent.cjs'
$action = New-ScheduledTaskAction -Execute $nodePath -Argument "`"$entry`"" -WorkingDirectory $InstallDir
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $RunAsUser
$principal = New-ScheduledTaskPrincipal -UserId $RunAsUser -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-ScheduledTask -TaskName $TaskName
# --- Vérification -----------------------------------------------------------
Write-Host ''
$env:AGENT_CONFIG = $configFile
& node $entry --check
Write-Host ''
Start-Sleep -Seconds 2
$state = (Get-ScheduledTask -TaskName $TaskName).State
if ($state -eq 'Running') {
Write-Host '✓ Agent installé et démarré.' -ForegroundColor Green
} else {
Write-Host "! Tâche dans l'état « $state ». Détails :" -ForegroundColor Yellow
Get-ScheduledTaskInfo -TaskName $TaskName | Format-List TaskName, LastRunTime, LastTaskResult
}
Write-Host @"
état : Get-ScheduledTask -TaskName $TaskName
test : node $entry --check
"@

227
deploy/install-agent.sh Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env bash
#
# Installe (ou met à jour) l'agent Stream Control sur une VM Linux.
#
# curl -fsSL https://gitea.exemple.com/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.sh \
# | sudo bash -s -- \
# --registry https://gitea.exemple.com \
# --server ws://control.lan:8080/ws/agent \
# --token <JETON_ENROLEMENT>
#
# Réexécuter le script met à jour le binaire sans toucher à l'identité d'un
# agent déjà enrôlé.
set -euo pipefail
REGISTRY=""
OWNER="jeanbon"
VERSION="latest"
PACKAGE_NAME="stream-control-agent"
PACKAGE_TOKEN=""
SERVER_URL=""
TOKEN=""
AGENT_NAME="$(hostname)"
OBS_HOST="127.0.0.1"
OBS_PORT="4455"
OBS_PASSWORD=""
RUN_USER=""
INSTALL_DIR="/opt/stream-control-agent"
SERVICE="stream-control-agent"
die() { echo "$*" >&2; exit 1; }
info() { echo "· $*"; }
usage() {
cat <<'EOF'
Installe (ou met à jour) l'agent Stream Control sur une VM Linux.
curl -fsSL https://gitea.exemple.com/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.sh \
| sudo bash -s -- \
--registry https://gitea.exemple.com \
--server ws://control.lan:8080/ws/agent \
--token <JETON_ENROLEMENT>
Réexécuter le script met à jour le binaire sans toucher à l'identité d'un agent
déjà enrôlé.
Options :
--registry URL Base de l'instance Gitea (obligatoire)
--server URL WebSocket du plan de contrôle (obligatoire à la 1re install)
--token JETON Jeton d'enrôlement (obligatoire à la 1re install)
--name NOM Nom affiché dans le dashboard (défaut : hostname)
--user UTILISATEUR Compte exécutant l'agent (défaut : l'utilisateur sudo)
--obs-host HOTE Défaut 127.0.0.1
--obs-port PORT Défaut 4455
--obs-password MDP Mot de passe obs-websocket
--version VERSION Version du paquet à installer (défaut : latest)
--owner PROPRIETAIRE Propriétaire du paquet Gitea (défaut : jeanbon)
--package-token JETON Jeton de lecture si le paquet est privé
--help
EOF
exit 0
}
while [ $# -gt 0 ]; do
case "$1" in
--registry) REGISTRY="${2:?}"; shift 2 ;;
--server) SERVER_URL="${2:?}"; shift 2 ;;
--token) TOKEN="${2:?}"; shift 2 ;;
--name) AGENT_NAME="${2:?}"; shift 2 ;;
--user) RUN_USER="${2:?}"; shift 2 ;;
--obs-host) OBS_HOST="${2:?}"; shift 2 ;;
--obs-port) OBS_PORT="${2:?}"; shift 2 ;;
--obs-password) OBS_PASSWORD="${2:?}"; shift 2 ;;
--version) VERSION="${2:?}"; shift 2 ;;
--owner) OWNER="${2:?}"; shift 2 ;;
--package-token) PACKAGE_TOKEN="${2:?}"; shift 2 ;;
--help|-h) usage ;;
*) die "Option inconnue : $1 (--help pour l'aide)" ;;
esac
done
[ "$(id -u)" -eq 0 ] || die "À exécuter en root (sudo)."
[ -n "$REGISTRY" ] || die "--registry est obligatoire."
REGISTRY="${REGISTRY%/}"
# L'agent doit tourner sous le compte qui ouvre la session graphique d'OBS :
# c'est ce qui lui donne accès à l'affichage pour le rappel plein écran.
if [ -z "$RUN_USER" ]; then
RUN_USER="${SUDO_USER:-root}"
fi
id "$RUN_USER" >/dev/null 2>&1 || die "L'utilisateur « $RUN_USER » n'existe pas."
CONFIG_FILE="$INSTALL_DIR/agent.config.json"
IS_UPGRADE=false
[ -f "$CONFIG_FILE" ] && IS_UPGRADE=true
if [ "$IS_UPGRADE" = false ]; then
[ -n "$SERVER_URL" ] || die "--server est obligatoire pour une première installation."
[ -n "$TOKEN" ] || die "--token est obligatoire pour une première installation."
fi
echo
echo "Installation de l'agent Stream Control"
echo " utilisateur : $RUN_USER"
echo " destination : $INSTALL_DIR"
echo " version : $VERSION"
echo
# --- Node.js ----------------------------------------------------------------
NODE_MAJOR=0
if command -v node >/dev/null 2>&1; then
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
fi
if [ "$NODE_MAJOR" -lt 22 ]; then
info "Node.js 22+ requis (trouvé : ${NODE_MAJOR:-aucun}) — installation depuis NodeSource"
command -v apt-get >/dev/null 2>&1 \
|| die "Distribution non gérée automatiquement : installe Node.js 22+ puis relance."
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
else
info "Node.js $(node -v) présent"
fi
# --- xdotool (rappel plein écran) -------------------------------------------
if ! command -v xdotool >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
info "Installation de xdotool (rappel plein écran)"
apt-get install -y xdotool >/dev/null
else
echo "! xdotool absent : le rappel plein écran sera indisponible."
fi
fi
# --- Téléchargement du bundle -----------------------------------------------
URL="$REGISTRY/api/packages/$OWNER/generic/$PACKAGE_NAME/$VERSION/agent.cjs"
info "Téléchargement depuis $URL"
mkdir -p "$INSTALL_DIR"
CURL_AUTH=()
[ -n "$PACKAGE_TOKEN" ] && CURL_AUTH=(--header "Authorization: token $PACKAGE_TOKEN")
curl -fsSL "${CURL_AUTH[@]}" -o "$INSTALL_DIR/agent.cjs.tmp" "$URL" \
|| die "Téléchargement impossible. Vérifie --registry, --version, et --package-token si le paquet est privé."
node --check "$INSTALL_DIR/agent.cjs.tmp" 2>/dev/null \
|| die "Le fichier téléchargé n'est pas un script Node valide."
mv "$INSTALL_DIR/agent.cjs.tmp" "$INSTALL_DIR/agent.cjs"
# --- Configuration ----------------------------------------------------------
if [ "$IS_UPGRADE" = true ]; then
# Un agent déjà enrôlé possède un jeton permanent : l'écraser avec le jeton
# d'enrôlement le ferait réapparaître comme un second agent dans le dashboard.
info "Configuration existante conservée (identité de l'agent préservée)"
else
umask 077
cat > "$CONFIG_FILE" <<EOF
{
"serverUrl": "$SERVER_URL",
"token": "$TOKEN",
"name": "$AGENT_NAME",
"obs": {
"host": "$OBS_HOST",
"port": $OBS_PORT,
"password": "$OBS_PASSWORD"
}
}
EOF
info "Configuration écrite dans $CONFIG_FILE"
fi
chown -R "$RUN_USER" "$INSTALL_DIR"
chmod 600 "$CONFIG_FILE"
# --- Service systemd --------------------------------------------------------
USER_HOME="$(getent passwd "$RUN_USER" | cut -d: -f6)"
cat > "/etc/systemd/system/$SERVICE.service" <<EOF
[Unit]
Description=Stream Control — agent OBS
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$RUN_USER
WorkingDirectory=$INSTALL_DIR
Environment=NODE_ENV=production
Environment=AGENT_CONFIG=$CONFIG_FILE
# Accès à la session graphique, nécessaire au rappel plein écran via xdotool.
Environment=DISPLAY=:0
Environment=XAUTHORITY=$USER_HOME/.Xauthority
ExecStart=$(command -v node) $INSTALL_DIR/agent.cjs
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE" >/dev/null 2>&1 || true
systemctl restart "$SERVICE"
# --- Vérification -----------------------------------------------------------
echo
sudo -u "$RUN_USER" AGENT_CONFIG="$CONFIG_FILE" \
"$(command -v node)" "$INSTALL_DIR/agent.cjs" --check || true
sleep 2
if systemctl is-active --quiet "$SERVICE"; then
echo "✓ Agent installé et démarré."
else
echo "✗ Le service n'est pas actif. Journal :"
journalctl -u "$SERVICE" -n 20 --no-pager || true
exit 1
fi
cat <<EOF
état : systemctl status $SERVICE
journal: journalctl -u $SERVICE -f
test : sudo -u $RUN_USER node $INSTALL_DIR/agent.cjs --check
EOF

View File

@@ -1,25 +0,0 @@
# Agent Ubuntu — /etc/systemd/system/stream-control-agent.service
#
# L'agent doit tourner dans la MÊME session que OBS s'il pilote un OBS graphique.
# Sur une VM headless, OBS tourne généralement sous Xvfb : garder le même User.
#
# systemctl daemon-reload && systemctl enable --now stream-control-agent
[Unit]
Description=Stream Control — agent OBS
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=obs
WorkingDirectory=/opt/stream-control-agent
Environment=NODE_ENV=production
# Chemin du fichier d'identité (jeton + agentId) — doit être inscriptible.
Environment=AGENT_CONFIG=/opt/stream-control-agent/agent.config.json
ExecStart=/usr/bin/node /opt/stream-control-agent/dist/index.js
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -1,41 +0,0 @@
# Installe l'agent Stream Control comme tâche planifiée Windows.
#
# L'agent doit s'exécuter dans la session interactive de l'utilisateur qui lance
# OBS : un service Windows classique tourne en session 0 et ne verrait pas OBS.
# On utilise donc une tâche « à l'ouverture de session » avec redémarrage auto.
#
# Usage (PowerShell administrateur) :
# .\windows-agent-task.ps1 -AgentDir 'C:\stream-control-agent' -RunAsUser 'VM01\obs'
param(
[string]$AgentDir = 'C:\stream-control-agent',
[string]$RunAsUser = "$env:USERDOMAIN\$env:USERNAME",
[string]$TaskName = 'StreamControlAgent'
)
$ErrorActionPreference = 'Stop'
$node = (Get-Command node).Source
$entry = Join-Path $AgentDir 'dist\index.js'
if (-not (Test-Path $entry)) {
throw "Agent introuvable : $entry — copie d'abord packages/agent (dist + node_modules) dans $AgentDir"
}
if (-not (Test-Path (Join-Path $AgentDir 'agent.config.json'))) {
Write-Warning "agent.config.json absent de $AgentDir : l'agent refusera de démarrer sans jeton."
}
$action = New-ScheduledTaskAction -Execute $node -Argument "`"$entry`"" -WorkingDirectory $AgentDir
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $RunAsUser
$principal = New-ScheduledTaskPrincipal -UserId $RunAsUser -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
Write-Host "Tâche « $TaskName » installée et démarrée pour $RunAsUser."
Write-Host "Journal : Get-ScheduledTaskInfo -TaskName $TaskName"

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
# Plan de contrôle Stream Control.
#
# cp .env.production.example .env puis compléter
# docker compose up -d
#
# Déploiement en HTTP sur réseau privé : le port n'est PAS destiné à être exposé
# sur Internet. Voir la section « Sécurité » de DEPLOY.md.
services:
server:
image: ${IMAGE:?définir IMAGE dans .env}
container_name: stream-control
restart: unless-stopped
env_file:
- .env
environment:
# Fixés ici pour ne pas dépendre de ce que contient .env : le port interne
# est immuable, la publication se règle par BIND_ADDR/HOST_PORT.
PORT: 8080
HOST: 0.0.0.0
DB_PATH: ./data/stream-control.sqlite
NODE_ENV: production
ports:
# BIND_ADDR restreint l'écoute à une interface : mets l'IP LAN ou VPN de
# la machine plutôt que 0.0.0.0 si elle a aussi une patte publique.
- "${BIND_ADDR:-0.0.0.0}:${HOST_PORT:-8080}:8080"
volumes:
- ./data:/app/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

485
package-lock.json generated
View File

@@ -3233,9 +3233,494 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"esbuild": "^0.28.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
}, },
"packages/agent/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/agent/node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"packages/server": { "packages/server": {
"name": "@stream-control/server", "name": "@stream-control/server",
"version": "0.1.0", "version": "0.1.0",

View File

@@ -9,8 +9,10 @@
}, },
"scripts": { "scripts": {
"build": "tsc -b", "build": "tsc -b",
"bundle": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist-bundle/agent.cjs --external:bufferutil --external:utf-8-validate --legal-comments=none",
"dev": "npm run build -w @stream-control/shared && node --experimental-strip-types --disable-warning=ExperimentalWarning --watch src/index.ts", "dev": "npm run build -w @stream-control/shared && node --experimental-strip-types --disable-warning=ExperimentalWarning --watch src/index.ts",
"start": "node dist/index.js" "start": "node dist/index.js",
"check": "node dist/index.js --check"
}, },
"dependencies": { "dependencies": {
"@stream-control/shared": "*", "@stream-control/shared": "*",
@@ -20,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"esbuild": "^0.28.2",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
} }

View File

@@ -0,0 +1,132 @@
import { execFile } from 'node:child_process';
import net from 'node:net';
import { promisify } from 'node:util';
import { CONFIG_PATH, type AgentConfig } from './config.ts';
const run = promisify(execFile);
type Verdict = 'ok' | 'warn' | 'fail';
const MARKS: Record<Verdict, string> = { ok: '✓', warn: '!', fail: '✗' };
function line(verdict: Verdict, label: string, detail: string): Verdict {
console.log(` ${MARKS[verdict]} ${label.padEnd(16)} ${detail}`);
return verdict;
}
/** Test TCP simple : le port répond-il ? */
function probeTcp(host: string, port: number, timeoutMs = 4000): Promise<string | null> {
return new Promise((resolve) => {
const socket = new net.Socket();
const done = (result: string | null) => {
socket.destroy();
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => done(null));
socket.once('timeout', () => done('délai dépassé'));
socket.once('error', (err: NodeJS.ErrnoException) => done(err.code ?? err.message));
socket.connect(port, host);
});
}
async function commandExists(command: string, args: string[]): Promise<boolean> {
try {
await run(command, args, { timeout: 8000, windowsHide: true });
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code !== 'ENOENT';
}
}
/**
* Vérifie une installation d'agent sans rien démarrer : configuration, joignabilité
* du serveur et d'OBS, prérequis du rappel plein écran.
*
* Sert aussi de test de fumée en CI (`node agent.cjs --check`) : un import cassé ou
* une régression de chemin sous Windows fait échouer la commande.
*/
export async function runDiagnostics(config: AgentConfig): Promise<number> {
console.log(`\nVérification de l'agent — ${process.platform} / Node ${process.versions.node}\n`);
const results: Verdict[] = [];
results.push(line('ok', 'configuration', CONFIG_PATH));
results.push(
line(
config.token ? 'ok' : 'fail',
'jeton',
config.token ? `présent (${config.token.slice(0, 6)}…)` : 'ABSENT',
),
);
results.push(line('ok', 'nom', config.name));
// --- Serveur de contrôle --------------------------------------------------
let serverUrl: URL | null = null;
try {
serverUrl = new URL(config.serverUrl);
} catch {
results.push(line('fail', 'serveur', `URL invalide : ${config.serverUrl}`));
}
if (serverUrl) {
const port = Number(serverUrl.port) || (serverUrl.protocol === 'wss:' ? 443 : 80);
const error = await probeTcp(serverUrl.hostname, port);
results.push(
error
? line('fail', 'serveur', `${serverUrl.host} injoignable (${error})`)
: line('ok', 'serveur', `${serverUrl.host} joignable`),
);
if (serverUrl.protocol === 'ws:' && !isLoopback(serverUrl.hostname)) {
line('warn', 'transport', 'ws:// non chiffré — à réserver à un réseau privé');
}
}
// --- OBS ------------------------------------------------------------------
const obsError = await probeTcp(config.obs.host, config.obs.port);
results.push(
obsError
? line('warn', 'obs-websocket', `${config.obs.host}:${config.obs.port} fermé (${obsError})`)
: line('ok', 'obs-websocket', `${config.obs.host}:${config.obs.port} ouvert`),
);
// --- Prérequis du rappel plein écran -------------------------------------
if (process.platform === 'linux') {
const hasXdotool = await commandExists('xdotool', ['--version']);
results.push(
hasXdotool
? line('ok', 'xdotool', 'installé')
: line('warn', 'xdotool', 'absent — apt install xdotool'),
);
const display = process.env.DISPLAY;
results.push(
display
? line('ok', 'DISPLAY', display)
: line('warn', 'DISPLAY', 'non défini — le rappel plein écran échouera'),
);
if (process.env.WAYLAND_DISPLAY) {
line('warn', 'session', 'Wayland détecté — xdotool exige X11');
}
} else if (process.platform === 'win32') {
const hasPowershell = await commandExists('powershell.exe', ['-NoProfile', '-Command', 'exit']);
results.push(
hasPowershell
? line('ok', 'powershell', 'disponible')
: line('warn', 'powershell', 'introuvable — le rappel plein écran échouera'),
);
}
const failed = results.filter((verdict) => verdict === 'fail').length;
const warned = results.filter((verdict) => verdict === 'warn').length;
console.log(
failed
? `\n${failed} problème(s) bloquant(s), ${warned} avertissement(s).\n`
: `\nAucun problème bloquant${warned ? `, ${warned} avertissement(s)` : ''}.\n`,
);
return failed > 0 ? 1 : 0;
}
function isLoopback(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
}

View File

@@ -18,6 +18,7 @@ import {
safeJsonParse, safeJsonParse,
} from '@stream-control/shared'; } from '@stream-control/shared';
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts'; import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
import { runDiagnostics } from './doctor.ts';
import { ObsController } from './obs.ts'; import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts'; import { StreamWatcher } from './watcher.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts'; import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
@@ -256,4 +257,10 @@ process.on('unhandledRejection', (reason) => {
}); });
console.log(`stream-control agent v${AGENT_VERSION}${config.name} (${process.platform})`); console.log(`stream-control agent v${AGENT_VERSION}${config.name} (${process.platform})`);
if (process.argv.includes('--check')) {
// Diagnostic seul : rien n'est démarré, aucune connexion n'est maintenue.
runDiagnostics(config).then((code) => process.exit(code));
} else {
connect(); connect();
}