CI : Deployment
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal 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
42
.env.production.example
Normal 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
|
||||
204
.gitea/workflows/release.yml
Normal file
204
.gitea/workflows/release.yml
Normal 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
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-bundle/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
|
||||
250
DEPLOY.md
Normal file
250
DEPLOY.md
Normal 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
55
Dockerfile
Normal 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"]
|
||||
37
README.md
37
README.md
@@ -59,30 +59,31 @@ apparaît dans le dashboard sur <http://localhost:5173>.
|
||||
|
||||
## 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
|
||||
npm ci && npm run build
|
||||
npm start # sert l'API, le WebSocket et le dashboard compilé sur $PORT
|
||||
# Interface (machine du plan de contrôle)
|
||||
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
|
||||
installe l'unité systemd [`deploy/stream-control-server.service`](deploy/stream-control-server.service).
|
||||
`ADMIN_PASSWORD` et `SESSION_SECRET` sont obligatoires hors développement.
|
||||
Les agents ne sont volontairement pas conteneurisés : ils doivent voir l'OBS local et la
|
||||
fenêtre du navigateur.
|
||||
|
||||
### Agent Ubuntu
|
||||
Pour vérifier une installation à tout moment :
|
||||
|
||||
Copie `packages/agent/dist`, `packages/agent/node_modules` et `agent.config.json` dans
|
||||
`/opt/stream-control-agent`, puis installe
|
||||
[`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'
|
||||
```bash
|
||||
node /opt/stream-control-agent/agent.cjs --check
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
172
deploy/install-agent.ps1
Normal file
172
deploy/install-agent.ps1
Normal 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
227
deploy/install-agent.sh
Normal 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
|
||||
@@ -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
|
||||
@@ -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
45
docker-compose.yml
Normal 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
485
package-lock.json
generated
@@ -3233,9 +3233,494 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/ws": "^8.5.13",
|
||||
"esbuild": "^0.28.2",
|
||||
"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": {
|
||||
"name": "@stream-control/server",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"check": "node dist/index.js --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stream-control/shared": "*",
|
||||
@@ -20,6 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/ws": "^8.5.13",
|
||||
"esbuild": "^0.28.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
132
packages/agent/src/doctor.ts
Normal file
132
packages/agent/src/doctor.ts
Normal 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';
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
safeJsonParse,
|
||||
} from '@stream-control/shared';
|
||||
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
|
||||
import { runDiagnostics } from './doctor.ts';
|
||||
import { ObsController } from './obs.ts';
|
||||
import { StreamWatcher } from './watcher.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})`);
|
||||
connect();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user