feat(streaming): panel installer/updater for the xc_fanout daemon

The daemon moves to its own repo (XC_VM_Fanout, GIT_REPO_FANOUT) that ships
per-arch static binaries as GitHub Release assets. Since we're fully
migrating onto the daemon, the panel needs to fetch and update it.

- FanoutBinaryCommand (`console.php fanout_binary`): reads the installed
  version straight from the binary (`xc_fanout -version`), compares to the
  latest release, and when they differ downloads the arch-matched asset,
  verifies its SHA-256 against the release SHA256SUMS, installs it
  atomically and restarts the daemon (the service keepalive respawns it).
  `force` reinstalls the current version. Modelled on BinariesCommand.
- GIT_REPO_FANOUT constant.
- docs/xc_fanout.md: separate-repo + install/update section.

The daemon repo itself (source + release.sh + a v* release GitHub Action,
binaries gitignored) is prepared under XC_VM_Fanout/ for the user to push.
This commit is contained in:
Divarion_D
2026-08-16 20:22:34 +03:00
parent 6a97159c75
commit 881a2e795a
3 changed files with 227 additions and 3 deletions
+20 -3
View File
@@ -5,9 +5,10 @@ streams to viewers. This page explains *why* it exists and *how the panel talks
to it*. The decision records are ADR 0001/0002/0003; this is the plain-language
overview.
The daemon's source and binaries live in the separate **`XC_VM_Binaries`** repo
(`xc_fanout/`), not in the panel tree, and it is never bundled into the panel/LB
archive. It is a single static binary per architecture.
The daemon's **source** lives in its own repo, **`XC_VM_Fanout`** (`GIT_REPO_FANOUT`),
and its compiled binaries ship as **GitHub Release assets** (never committed to a
tree). It is a single static binary per architecture and is never bundled into the
panel/LB archive.
---
@@ -117,5 +118,21 @@ sockets live next to the binary in the app bin tree
layout nginx already reaches over `unix:`. Installed on MAIN first; LB rollout is
a later phase.
### Install / update
The binary is fetched from the latest `XC_VM_Fanout` release, like the other
distributed binaries:
```
console.php fanout_binary # install or update to the latest release
console.php fanout_binary force # reinstall the current version
```
`FanoutBinaryCommand` reads the installed version straight from the binary
(`xc_fanout -version`), compares it to the latest release tag, and — when they
differ — downloads the arch-matched asset (`xc_fanout-linux-<arch>`), verifies its
SHA-256 against the release's `SHA256SUMS`, installs it atomically, and kills the
running daemon so the `service` keepalive respawns it with the new binary.
See `docs/adr/0001-*`, `0002-*`, `0003-*` for the full design and the phased
cutover plan.
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace XcVm\Cli\Commands;
use XcVm\Cli\CommandInterface;
use XcVm\Core\Config\SettingsManager;
use XcVm\Core\Updates\GitHubReleases;
/**
* FanoutBinaryCommand — install/update the xc_fanout daemon binary (ADR 0003).
*
* The daemon lives in its own repo (GIT_REPO_FANOUT) and ships its per-arch
* static binaries as GitHub **release assets** (not committed to the tree). This
* command is the panel-side installer/updater, modelled on the binaries/maxmind
* updaters: it reads the installed version straight from the binary
* (`xc_fanout -version`), compares it to the latest release, and when they
* differ downloads the arch-matched asset, verifies its SHA-256, installs it
* atomically, and restarts the daemon (the `service` keepalive respawns it with
* the new binary).
*
* Usage: `console.php fanout_binary` (add `force` to reinstall the same version).
*
* @package XC_VM_CLI_Commands
* @author Divarion_D <https://github.com/Divarion-D>
* @copyright 2025-2026 Vateron Media
* @link https://github.com/Vateron-Media/XC_VM
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
*/
class FanoutBinaryCommand implements CommandInterface {
/** uname -m → release asset arch suffix. */
private const ARCH_MAP = [
'x86_64' => 'amd64',
'amd64' => 'amd64',
'aarch64' => 'arm64',
'arm64' => 'arm64',
'armv7l' => 'armv7',
'armv7' => 'armv7',
'armhf' => 'armv7',
'i386' => '386',
'i686' => '386',
];
public function getName(): string {
return 'fanout_binary';
}
public function getDescription(): string {
return 'Install/update the xc_fanout daemon binary from its release';
}
public function execute(array $rArgs): int {
if (posix_getpwuid(posix_geteuid())['name'] !== 'root') {
echo "Please run as root!\n";
return 1;
}
$rForce = in_array('force', $rArgs, true);
$rMachine = trim(php_uname('m'));
$rArch = self::ARCH_MAP[$rMachine] ?? null;
if ($rArch === null) {
echo "Unsupported architecture: {$rMachine}\n";
return 1;
}
$rDir = BIN_PATH . 'xc_fanout/';
$rBinary = $rDir . 'xc_fanout';
$rInstalled = $this->installedVersion($rBinary);
$rChannel = 'stable';
$rSettings = SettingsManager::getAll();
if (!empty($rSettings['update_channel']) && in_array($rSettings['update_channel'], ['stable', 'unstable'], true)) {
$rChannel = $rSettings['update_channel'];
}
try {
$rGit = new GitHubReleases(GIT_OWNER, GIT_REPO_FANOUT, $rChannel);
$rGit->setTimeout(20);
$rReleases = $rGit->getReleases();
} catch (\Exception $e) {
echo 'Failed to check xc_fanout releases: ' . $e->getMessage() . "\n";
return 1;
}
if (empty($rReleases[0])) {
echo "Failed to resolve the latest xc_fanout release.\n";
return 1;
}
$rTag = trim($rReleases[0]);
$rLatest = ltrim($rTag, 'vV');
if (!$rForce && $rInstalled !== null && $rInstalled === $rLatest) {
echo "xc_fanout is up to date ({$rInstalled}).\n";
return 0;
}
echo 'xc_fanout: installed=' . ($rInstalled ?? 'none') . ', latest=' . $rLatest . " → updating\n";
$rBase = 'https://github.com/' . GIT_OWNER . '/' . GIT_REPO_FANOUT . '/releases/download/' . rawurlencode($rTag) . '/';
$rAsset = 'xc_fanout-linux-' . $rArch;
if (!is_dir($rDir) && !@mkdir($rDir, 0755, true)) {
echo "Failed to create {$rDir}\n";
return 1;
}
$rTmp = $rDir . '.xc_fanout.new';
if (!$this->download($rBase . $rAsset, $rTmp)) {
echo "Failed to download {$rAsset}\n";
@unlink($rTmp);
return 1;
}
$rExpected = $this->expectedSha256($rBase . 'SHA256SUMS', $rAsset);
if ($rExpected === null) {
echo "Failed to fetch SHA256SUMS\n";
@unlink($rTmp);
return 1;
}
if (!hash_equals($rExpected, hash_file('sha256', $rTmp))) {
echo "Checksum mismatch for {$rAsset} — aborting\n";
@unlink($rTmp);
return 1;
}
@chmod($rTmp, 0755);
$rNewVer = trim((string) shell_exec(escapeshellarg($rTmp) . ' -version 2>/dev/null'));
if ($rNewVer === '') {
echo "Downloaded binary does not run on this host — aborting\n";
@unlink($rTmp);
return 1;
}
if (!@rename($rTmp, $rBinary)) { // atomic replace
echo "Failed to install {$rBinary}\n";
@unlink($rTmp);
return 1;
}
@chown($rBinary, 'xc_vm');
@chgrp($rBinary, 'xc_vm');
// Restart: kill the running daemon so the service keepalive respawns it
// with the new binary. Harmless no-op if it isn't running yet.
shell_exec("pkill -u xc_vm -f 'bin/xc_fanout/xc_fanout' 2>/dev/null");
echo "xc_fanout {$rNewVer} installed.\n";
return 0;
}
/** Installed version straight from the binary, or null if absent/unrunnable. */
private function installedVersion(string $rBinary): ?string {
if (!is_file($rBinary) || !is_executable($rBinary)) {
return null;
}
$rOut = trim((string) shell_exec(escapeshellarg($rBinary) . ' -version 2>/dev/null'));
return $rOut !== '' ? ltrim($rOut, 'vV') : null;
}
/** Download a URL to a file (following redirects). */
private function download(string $rUrl, string $rDest): bool {
$rFp = @fopen($rDest, 'wb');
if (!$rFp) {
return false;
}
$rCurl = curl_init();
curl_setopt_array($rCurl, [
CURLOPT_URL => $rUrl,
CURLOPT_FILE => $rFp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 20,
CURLOPT_TIMEOUT => 120,
CURLOPT_FAILONERROR => true,
CURLOPT_USERAGENT => 'XC_VM',
]);
$rOk = curl_exec($rCurl);
$rCode = curl_getinfo($rCurl, CURLINFO_HTTP_CODE);
curl_close($rCurl);
fclose($rFp);
return $rOk !== false && $rCode >= 200 && $rCode < 300 && filesize($rDest) > 0;
}
/** Expected sha256 for $rAsset from a SHA256SUMS file (`<hash> <name>`). */
private function expectedSha256(string $rUrl, string $rAsset): ?string {
$rCurl = curl_init();
curl_setopt_array($rCurl, [
CURLOPT_URL => $rUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => 'XC_VM',
]);
$rBody = curl_exec($rCurl);
$rCode = curl_getinfo($rCurl, CURLINFO_HTTP_CODE);
curl_close($rCurl);
if (!is_string($rBody) || $rCode < 200 || $rCode >= 300) {
return null;
}
foreach (explode("\n", $rBody) as $rLine) {
$rParts = preg_split('/\s+/', trim($rLine), 2);
if (count($rParts) === 2 && ltrim(trim($rParts[1]), '*./') === $rAsset) {
return strtolower(trim($rParts[0]));
}
}
return null;
}
}
+1
View File
@@ -36,6 +36,7 @@ define('GIT_OWNER', 'Vateron-Media');
define('GIT_REPO_MAIN', 'XC_VM');
define('GIT_REPO_UPDATE', 'XC_VM_Update');
define('GIT_REPO_BIN', 'XC_VM_Binaries');
define('GIT_REPO_FANOUT', 'XC_VM_Fanout'); // xc_fanout daemon: source repo, binaries as release assets
define('GIT_REPO_PROXY', 'XC_VM_Proxy');
// ── Miscellaneous Settings ─────────────────────────────────────