diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a960531 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..506d3b7 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..5236a11 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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-. + 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.' diff --git a/.gitignore b/.gitignore index 9f2d27b..2336c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-bundle/ *.tsbuildinfo .env .env.local diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..3952872 --- /dev/null +++ b/DEPLOY.md @@ -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 | `/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-`, 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 `` 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 \ + --name vm-rec-01 \ + --obs-password +``` + +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 '' ` + -Name 'vm-rec-02' ` + -ObsPassword '' +``` + +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 ` (Linux) ou `-PackageToken ''` (Windows), +avec un jeton en lecture sur les paquets. + +### Vérifier + +```bash +sudo -u 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 `. + +--- + +## 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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c0e26de --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 3118952..c058f4f 100644 --- a/README.md +++ b/README.md @@ -59,30 +59,31 @@ apparaît dans le dashboard sur . ## 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 /api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.sh \ + | sudo bash -s -- --registry --server ws://control.lan:8080/ws/agent --token + +# Agent Windows (PowerShell administrateur) +& ([scriptblock]::Create((irm '/api/packages/jeanbon/generic/stream-control-agent/latest/install-agent.ps1'))) ` + -Registry '' -Server 'ws://control.lan:8080/ws/agent' -Token '' ``` -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 diff --git a/deploy/install-agent.ps1 b/deploy/install-agent.ps1 new file mode 100644 index 0000000..7098a34 --- /dev/null +++ b/deploy/install-agent.ps1 @@ -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 '' +#> + +[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 + +"@ diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh new file mode 100644 index 0000000..f2a7f5e --- /dev/null +++ b/deploy/install-agent.sh @@ -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 +# +# 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 + +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" < "/etc/systemd/system/$SERVICE.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 <=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", diff --git a/packages/agent/package.json b/packages/agent/package.json index 0354c78..62231e8 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -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" } } diff --git a/packages/agent/src/doctor.ts b/packages/agent/src/doctor.ts new file mode 100644 index 0000000..38b05ec --- /dev/null +++ b/packages/agent/src/doctor.ts @@ -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 = { 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 { + 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 { + 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 { + 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'; +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index e7d0623..4ce04bd 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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(); +}