diff --git a/release/RedSyncLauncher/App.axaml b/release/RedSyncLauncher/App.axaml index 0e337d19..e302e3f0 100644 --- a/release/RedSyncLauncher/App.axaml +++ b/release/RedSyncLauncher/App.axaml @@ -71,5 +71,25 @@ + + + + + diff --git a/release/RedSyncLauncher/Build-Launcher-Exe.ps1 b/release/RedSyncLauncher/Build-Launcher-Exe.ps1 index 9b264498..4b8eb52c 100644 --- a/release/RedSyncLauncher/Build-Launcher-Exe.ps1 +++ b/release/RedSyncLauncher/Build-Launcher-Exe.ps1 @@ -14,8 +14,9 @@ try { -p:IncludeNativeLibrariesForSelfExtract=true ` -o $out Copy-Item (Join-Path $out "RedSyncLauncher.exe") $here -Force - Copy-Item (Join-Path $here "servers.json") $here -Force + # servers.json already lives next to the project; no copy needed Write-Host "Built RedSyncLauncher.exe in $here" + exit 0 } finally { Pop-Location } diff --git a/release/RedSyncLauncher/LauncherPaths.cs b/release/RedSyncLauncher/LauncherPaths.cs index 150cbd31..49c74348 100644 --- a/release/RedSyncLauncher/LauncherPaths.cs +++ b/release/RedSyncLauncher/LauncherPaths.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -20,6 +22,7 @@ internal static class JsonUtil internal static class LauncherPaths { public const int DefaultPort = 2077; + public const int DefaultMaxPlayers = 2000; public const string AppName = "RedSync Launcher"; public static string AppRoot @@ -93,13 +96,80 @@ public void Save() } } -internal sealed class ServerEntry +internal sealed class ServerEntry : INotifyPropertyChanged { + private int _playersOnline = -1; + private int _maxPlayers = LauncherPaths.DefaultMaxPlayers; + private string _linkState = "…"; + public string Name { get; set; } = ""; public string Host { get; set; } = ""; public int Port { get; set; } = LauncherPaths.DefaultPort; public string Region { get; set; } = "-"; public string Description { get; set; } = ""; + + /// Configured capacity (default 2000). Overridden by live /status when available. + public int MaxPlayers + { + get => _maxPlayers; + set + { + if (_maxPlayers == value) return; + _maxPlayers = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(PlayersDisplay)); + } + } + + /// Optional status HTTP port. Default = game Port + 2. + public int? StatusPort { get; set; } + + /// Optional full status URL (overrides Host/StatusPort). + public string? StatusUrl { get; set; } + + [JsonIgnore] + public int PlayersOnline + { + get => _playersOnline; + set + { + if (_playersOnline == value) return; + _playersOnline = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(PlayersDisplay)); + } + } + + [JsonIgnore] + public string LinkState + { + get => _linkState; + set + { + if (_linkState == value) return; + _linkState = value; + OnPropertyChanged(); + } + } + + [JsonIgnore] + public string PlayersDisplay => + PlayersOnline < 0 ? "— / " + MaxPlayers : $"{PlayersOnline} / {MaxPlayers}"; + + [JsonIgnore] + public int ResolvedStatusPort => StatusPort is > 0 ? StatusPort.Value : Port + 2; + + public string ResolveStatusUrl() + { + if (!string.IsNullOrWhiteSpace(StatusUrl)) + return StatusUrl.Trim(); + return $"http://{Host}:{ResolvedStatusPort}/status"; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } internal sealed class ServersFile @@ -115,7 +185,15 @@ public static List Load() { var data = JsonSerializer.Deserialize(File.ReadAllText(path), JsonUtil.Options); if (data?.Servers is { Count: > 0 }) + { + foreach (var s in data.Servers) + { + if (s.MaxPlayers <= 0) + s.MaxPlayers = LauncherPaths.DefaultMaxPlayers; + } + return data.Servers; + } } } catch @@ -131,6 +209,7 @@ public static List Load() Host = "88.214.59.166", Port = LauncherPaths.DefaultPort, Region = "USA", + MaxPlayers = LauncherPaths.DefaultMaxPlayers, Description = "Official RedSync co-op server. PvP, events, proximity voice.", } }; diff --git a/release/RedSyncLauncher/MainWindow.axaml b/release/RedSyncLauncher/MainWindow.axaml index 235a8e20..1ae0c727 100644 --- a/release/RedSyncLauncher/MainWindow.axaml +++ b/release/RedSyncLauncher/MainWindow.axaml @@ -94,41 +94,81 @@ - + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/release/RedSyncLauncher/MainWindow.axaml.cs b/release/RedSyncLauncher/MainWindow.axaml.cs index dea7300a..24daaaa1 100644 --- a/release/RedSyncLauncher/MainWindow.axaml.cs +++ b/release/RedSyncLauncher/MainWindow.axaml.cs @@ -3,7 +3,10 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Net.Http; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; @@ -17,11 +20,17 @@ public partial class MainWindow : Window { private static readonly IBrush OkBrush = Brush.Parse("#5cff8a"); private static readonly IBrush BadBrush = Brush.Parse("#ff3040"); + private static readonly HttpClient StatusHttp = new() + { + Timeout = TimeSpan.FromSeconds(3), + }; private readonly AppConfig _cfg; private readonly Random _glitchRng = new(); private readonly DispatcherTimer _titleGlitch; + private readonly DispatcherTimer _playersPoll; private List _servers = new(); + private int _pollBusy; public MainWindow() { @@ -35,12 +44,21 @@ public MainWindow() _titleGlitch.Tick += (_, _) => PulseTitleGlitch(); _titleGlitch.Start(); + _playersPoll = new DispatcherTimer { Interval = TimeSpan.FromSeconds(12) }; + _playersPoll.Tick += (_, _) => _ = PollPlayerCountsAsync(); + _playersPoll.Start(); + Opened += async (_, _) => { await Task.Delay(350); await MaybePromptInstallAsync(); + await PollPlayerCountsAsync(); + }; + Closed += (_, _) => + { + _titleGlitch.Stop(); + _playersPoll.Stop(); }; - Closed += (_, _) => _titleGlitch.Stop(); } private void PulseTitleGlitch() @@ -71,28 +89,125 @@ private void RefreshServerList() ServerGrid.ItemsSource = null; ServerGrid.ItemsSource = _servers; + PlayersGrid.ItemsSource = null; + PlayersGrid.ItemsSource = _servers; if (_servers.Count > 0) ServerGrid.SelectedIndex = 0; - SetStatus($"{_servers.Count} relay(s) on the Blackwall edge."); + SetStatus($"{_servers.Count} relay(s) on the Blackwall edge · cap {LauncherPaths.DefaultMaxPlayers}."); RefreshDepsIndicator(); + _ = PollPlayerCountsAsync(); } - private ServerEntry? SelectedServer => ServerGrid.SelectedItem as ServerEntry; + private ServerEntry? SelectedServer => + (MainTabs?.SelectedIndex == 1 ? PlayersGrid.SelectedItem : ServerGrid.SelectedItem) as ServerEntry + ?? ServerGrid.SelectedItem as ServerEntry; private void OnServerSelectionChanged(object? sender, SelectionChangedEventArgs e) + => UpdateDesc(SelectedServer); + + private void OnPlayersSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (PlayersGrid.SelectedItem is ServerEntry s) + { + ServerGrid.SelectedItem = s; + UpdateDesc(s); + } + } + + private void UpdateDesc(ServerEntry? s) { - var s = SelectedServer; DescLabel.Text = s == null ? "Select a relay. Blackwall interference expected." : (string.IsNullOrWhiteSpace(s.Description) - ? $"{s.Host}:{s.Port}" - : s.Description); + ? $"{s.Host}:{s.Port} · {s.PlayersDisplay}" + : $"{s.Description} · {s.PlayersDisplay}"); } private void OnServerDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) => _ = ConnectAsync(); - private void OnRefreshClick(object? sender, RoutedEventArgs e) => RefreshServerList(); + private void OnRefreshClick(object? sender, RoutedEventArgs e) + { + RefreshServerList(); + _ = PollPlayerCountsAsync(); + } + + private async Task PollPlayerCountsAsync() + { + if (Interlocked.Exchange(ref _pollBusy, 1) == 1) + return; + + try + { + var list = _servers.ToList(); + if (list.Count == 0) + return; + + if (PlayersPollLabel != null) + PlayersPollLabel.Text = "// scanning relays…"; + + var tasks = list.Select(PollOneServerAsync).ToArray(); + await Task.WhenAll(tasks); + + var online = list.Count(s => s.LinkState is "ONLINE" or "FULL"); + var totalPlayers = list.Where(s => s.PlayersOnline >= 0).Sum(s => s.PlayersOnline); + if (PlayersPollLabel != null) + PlayersPollLabel.Text = $"// {totalPlayers} playing · {online}/{list.Count} relays up"; + SetStatus($"Live headcount: {totalPlayers} player(s) across {online} online relay(s)."); + } + catch (Exception ex) + { + if (PlayersPollLabel != null) + PlayersPollLabel.Text = "// poll error"; + SetStatus($"Player poll failed: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _pollBusy, 0); + } + } + + private static async Task PollOneServerAsync(ServerEntry server) + { + var url = server.ResolveStatusUrl(); + try + { + using var resp = await StatusHttp.GetAsync(url); + var body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + server.PlayersOnline = -1; + server.LinkState = "DOWN"; + }); + return; + } + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var players = root.TryGetProperty("players", out var p) && p.TryGetInt32(out var n) ? n : 0; + var max = root.TryGetProperty("maxPlayers", out var m) && m.TryGetInt32(out var mx) && mx > 0 + ? mx + : server.MaxPlayers; + var full = root.TryGetProperty("full", out var f) && f.ValueKind == JsonValueKind.True; + + await Dispatcher.UIThread.InvokeAsync(() => + { + server.MaxPlayers = max; + server.PlayersOnline = players; + server.LinkState = full ? "FULL" : "ONLINE"; + }); + } + catch + { + await Dispatcher.UIThread.InvokeAsync(() => + { + server.PlayersOnline = -1; + server.LinkState = "DOWN"; + }); + } + } private void OnAddCustomClick(object? sender, RoutedEventArgs e) { diff --git a/release/RedSyncLauncher/servers.json b/release/RedSyncLauncher/servers.json index dd034ab2..de649bc2 100644 --- a/release/RedSyncLauncher/servers.json +++ b/release/RedSyncLauncher/servers.json @@ -4,8 +4,19 @@ "name": "RedSync Official (Night City)", "host": "88.214.59.166", "port": 2077, + "statusPort": 2079, + "maxPlayers": 2000, "region": "USA", "description": "Official USA relay — PvP, Cerberus, Smasher, proximity voice. Blackwall optional." + }, + { + "name": "RedSync BETA (test builds)", + "host": "88.214.59.166", + "port": 2177, + "statusPort": 2179, + "maxPlayers": 2000, + "region": "USA", + "description": "Staging / beta — push new builds here. Live Official stays on :2077." } ] } diff --git a/release/RedSyncLauncher/www/index.html b/release/RedSyncLauncher/www/index.html index 35ede8b1..f6fc4b64 100644 --- a/release/RedSyncLauncher/www/index.html +++ b/release/RedSyncLauncher/www/index.html @@ -392,14 +392,15 @@

Beyond the Blackwall

REDSYNC

-

Cyberpunk 2077 multiplayer. Install, pick the USA relay, jack in.

+

Cyberpunk 2077 multiplayer. Install, pick Official or BETA, jack in. Live player counts in the launcher.

DOWNLOAD LAUNCHER

Short page: /rs · - Run RedSyncLauncher.exe after unzip · + Run RedSyncLauncher.exe · RELAYS + PLAYERS ONLINE tabs · + Official :2077 · BETA :2177 · cap 2000 · same package (alt name)

diff --git a/release/ops/Deploy-BetaServer.ps1 b/release/ops/Deploy-BetaServer.ps1 new file mode 100644 index 00000000..6d728960 --- /dev/null +++ b/release/ops/Deploy-BetaServer.ps1 @@ -0,0 +1,48 @@ +# Deploy RedSync BETA as a real Pelican/Wings server (not standalone docker). +# Uses xerox.pri when it works; otherwise falls back to password prompts. +param( + [string]$KeyPath = "c:\Users\mattb\Downloads\xerox.pri", + [string]$HostName = "88.214.59.166", + [string]$User = "root", + [int]$BetaPort = 2177, + [string]$RepoDir = "$env:TEMP\RedSync-ops" +) + +$ErrorActionPreference = "Stop" +$remote = "${User}@${HostName}" + +if (-not (Test-Path (Join-Path $RepoDir "release\ops\create-pelican-beta-panel.sh"))) { + if (Test-Path $RepoDir) { Remove-Item -Recurse -Force $RepoDir } + git clone --depth 1 -b cursor/pelican-beta-server-5b75 https://github.com/nobody71004/RedSync.git $RepoDir +} else { + git -C $RepoDir fetch origin cursor/pelican-beta-server-5b75 + git -C $RepoDir checkout cursor/pelican-beta-server-5b75 + git -C $RepoDir pull origin cursor/pelican-beta-server-5b75 +} + +# Prefer key, but DO NOT use BatchMode (that blocked password auth last time). +$sshBase = @("-o", "StrictHostKeyChecking=accept-new") +if (Test-Path $KeyPath) { + $sshBase = @("-i", $KeyPath, "-o", "IdentitiesOnly=yes") + $sshBase + Write-Host "Using key: $KeyPath (password prompt is OK if key is rejected)" +} else { + Write-Host "Key not found at $KeyPath — will use password auth" +} + +$scriptLocal = Join-Path $RepoDir "release\ops\create-pelican-beta-panel.sh" +if (-not (Test-Path $scriptLocal)) { throw "Missing $scriptLocal — pull the branch first" } + +Write-Host "Uploading create-pelican-beta-panel.sh ..." +& ssh.exe @sshBase $remote "rm -rf /tmp/redsync-ops-beta; mkdir -p /tmp/redsync-ops-beta" +if ($LASTEXITCODE -ne 0) { throw "ssh mkdir failed (exit $LASTEXITCODE)" } + +& scp.exe @sshBase $scriptLocal "${remote}:/tmp/redsync-ops-beta/create-pelican-beta-panel.sh" +if ($LASTEXITCODE -ne 0) { throw "scp failed (exit $LASTEXITCODE) — panel create did NOT run" } + +Write-Host "Creating Pelican BETA server on :$BetaPort ..." +& ssh.exe @sshBase -t $remote "sed -i 's/\r`$//' /tmp/redsync-ops-beta/create-pelican-beta-panel.sh; chmod +x /tmp/redsync-ops-beta/create-pelican-beta-panel.sh; BETA_PORT=$BetaPort bash /tmp/redsync-ops-beta/create-pelican-beta-panel.sh" +if ($LASTEXITCODE -ne 0) { throw "create-pelican-beta-panel.sh failed (exit $LASTEXITCODE)" } + +Write-Host "" +Write-Host "Open https://panel.xbuniverse.duckdns.org and look for 'RedSync BETA'." +Write-Host "Launcher: 88.214.59.166:$BetaPort" diff --git a/release/ops/Publish-Cdn.ps1 b/release/ops/Publish-Cdn.ps1 new file mode 100644 index 00000000..36bad7b2 --- /dev/null +++ b/release/ops/Publish-Cdn.ps1 @@ -0,0 +1,150 @@ +#Requires -Version 5.1 +# Rebuild RedSyncLauncher.zip and publish to the VPS CDN/media dirs. +param( + [string]$KeyPath = "c:\Users\mattb\Downloads\xerox.pri", + [string]$HostName = "88.214.59.166", + [string]$User = "root", + [string]$RepoDir = "" +) + +$ErrorActionPreference = "Stop" + +if (-not $RepoDir) { + $here = Split-Path -Parent $MyInvocation.MyCommand.Path + $guess = Resolve-Path (Join-Path $here "..\..") -ErrorAction SilentlyContinue + if ($guess -and (Test-Path (Join-Path $guess.Path "release\RedSyncLauncher\RedSyncLauncher.csproj"))) { + $RepoDir = $guess.Path + } else { + $RepoDir = Join-Path $env:TEMP "RedSync-ops" + } +} + +$release = Join-Path $RepoDir "release" +$launcher = Join-Path $release "RedSyncLauncher" +$csproj = Join-Path $launcher "RedSyncLauncher.csproj" +if (-not (Test-Path $csproj)) { + throw ("Launcher project not found under {0}. Clone branch cursor/max-players-launcher-tab-5b75 first." -f $launcher) +} + +Write-Host "==> Building RedSyncLauncher.exe (win-x64 self-contained)" +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $launcher "Build-Launcher-Exe.ps1") +$exe = Join-Path $launcher "RedSyncLauncher.exe" +if (-not (Test-Path $exe)) { + throw "Build failed: missing $exe (Build-Launcher-Exe.ps1 exit=$LASTEXITCODE)" +} +Write-Host ("==> Built OK: {0} ({1:N0} bytes)" -f $exe, (Get-Item $exe).Length) + +$stage = Join-Path $env:TEMP ("redsync-cdn-stage-" + [guid]::NewGuid().ToString("n")) +$outDir = Join-Path $env:TEMP "redsync-cdn" +New-Item -ItemType Directory -Force -Path $stage | Out-Null +New-Item -ItemType Directory -Force -Path $outDir | Out-Null + +$packageFiles = @( + "Install-All.ps1", "Install-All.bat", + "Install-Client.ps1", "Install-Client.bat", + "Install-NPV-Template.ps1", "Install-NPV-Template.bat", + "NPV-DEFAULT-V.txt", "GamePath.ps1", + "Verify-Install.ps1", "Verify-Install.bat", + "Clean-Legacy.ps1", "Clean-Legacy.bat", + "Repair-ScriptCache.ps1", "Repair-ScriptCache.bat", + "Fix-ScriptCompile.ps1", "Fix-ScriptCompile.bat", + "VoiceClient.ps1", + "game-path.cfg.example", "server-address.cfg.example" +) + +Write-Host "==> Staging package" +foreach ($f in $packageFiles) { + $src = Join-Path $release $f + if (-not (Test-Path $src)) { throw "Missing $src" } + Copy-Item -LiteralPath $src -Destination (Join-Path $stage $f) -Force +} +foreach ($extra in @("admin.secret.example", "DO-NOT-COPY-FROM-OTHER-PC.txt")) { + $src = Join-Path $release $extra + if (Test-Path $src) { + Copy-Item -LiteralPath $src -Destination (Join-Path $stage $extra) -Force + } +} +Copy-Item -LiteralPath (Join-Path $release "client") -Destination (Join-Path $stage "client") -Recurse -Force +Copy-Item -LiteralPath (Join-Path $release "client-deps") -Destination (Join-Path $stage "client-deps") -Recurse -Force +Copy-Item -LiteralPath $exe -Destination (Join-Path $stage "RedSyncLauncher.exe") -Force +Copy-Item -LiteralPath (Join-Path $launcher "servers.json") -Destination (Join-Path $stage "servers.json") -Force +Copy-Item -LiteralPath (Join-Path $launcher "README.txt") -Destination (Join-Path $stage "README-LAUNCHER.txt") -Force + +$startHere = @( + "RedSync Launcher Package", + "========================", + ("Built: {0}" -f (Get-Date -Format "yyyy-MM-dd HH:mm")), + "Official: 88.214.59.166:2077 (max 2000)", + "BETA: 88.214.59.166:2177 (max 2000)", + "", + "1. Unzip", + "2. Run RedSyncLauncher.exe", + "3. Install deps if prompted", + "4. RELAYS tab to connect; PLAYERS ONLINE tab for live headcount" +) -join "`r`n" +Set-Content -LiteralPath (Join-Path $stage "START-HERE.txt") -Value $startHere -Encoding Ascii + +$clientZip = Join-Path $outDir "RedSync-Tester-Client.zip" +$launcherZip = Join-Path $outDir "RedSyncLauncher.zip" +$fullZip = Join-Path $outDir "RedSync-Tester-Package.zip" +Remove-Item -LiteralPath $clientZip, $launcherZip, $fullZip -Force -ErrorAction SilentlyContinue + +Write-Host "==> Zipping $clientZip" +if (Test-Path $clientZip) { Remove-Item -LiteralPath $clientZip -Force } +Add-Type -AssemblyName System.IO.Compression.FileSystem +[System.IO.Compression.ZipFile]::CreateFromDirectory($stage, $clientZip) +Copy-Item -LiteralPath $clientZip -Destination $launcherZip -Force + +$stage2 = Join-Path $env:TEMP ("redsync-cdn-full-" + [guid]::NewGuid().ToString("n")) +New-Item -ItemType Directory -Force -Path $stage2 | Out-Null +Copy-Item -Path (Join-Path $stage "*") -Destination $stage2 -Recurse -Force +if (Test-Path (Join-Path $release "server")) { + Copy-Item -LiteralPath (Join-Path $release "server") -Destination (Join-Path $stage2 "server") -Recurse -Force +} +Write-Host "==> Zipping $fullZip" +if (Test-Path $fullZip) { Remove-Item -LiteralPath $fullZip -Force } +[System.IO.Compression.ZipFile]::CreateFromDirectory($stage2, $fullZip) + +$html = Join-Path $launcher "www\index.html" +if (-not (Test-Path $html)) { throw "Missing $html" } + +$remote = "${User}@${HostName}" +$sshBase = New-Object System.Collections.Generic.List[string] +$sshBase.Add("-o") | Out-Null +$sshBase.Add("StrictHostKeyChecking=accept-new") | Out-Null +if (Test-Path $KeyPath) { + $sshBase.Insert(0, "IdentitiesOnly=yes") | Out-Null + $sshBase.Insert(0, "-o") | Out-Null + $sshBase.Insert(0, $KeyPath) | Out-Null + $sshBase.Insert(0, "-i") | Out-Null + Write-Host "Using key $KeyPath (password OK if key rejected)" +} +$sshArgs = $sshBase.ToArray() + +$media = "/opt/music-player/xbuniverse_server/videos" +$www = "/opt/redsync-data/www" + +Write-Host "==> Uploading zips + HTML to $HostName" +& ssh.exe @sshArgs $remote ("mkdir -p '{0}' '{1}'" -f $media, $www) +if ($LASTEXITCODE -ne 0) { throw "ssh mkdir failed" } + +& scp.exe @sshArgs $launcherZip $clientZip $fullZip ("{0}:{1}/" -f $remote, $media) +if ($LASTEXITCODE -ne 0) { throw "scp zips failed" } + +& scp.exe @sshArgs $html ("{0}:{1}/RedSync.html" -f $remote, $media) +& scp.exe @sshArgs $html ("{0}:{1}/rs.html" -f $remote, $media) +& scp.exe @sshArgs $html ("{0}:{1}/RedSync.html" -f $remote, $www) +& scp.exe @sshArgs $html ("{0}:{1}/rs.html" -f $remote, $www) +& scp.exe @sshArgs $launcherZip ("{0}:{1}/RedSyncLauncher.zip" -f $remote, $www) + +# Single-line remote check (avoid CRLF breaking bash on the VPS) +$remoteCheck = "ls -lh $media/RedSyncLauncher.zip $media/rs.html; curl -sI http://127.0.0.1:5005/api/media/RedSyncLauncher.zip | sed -n '1,8p'; curl -sI http://127.0.0.1:5005/api/media/rs.html | sed -n '1,8p'" +& ssh.exe @sshArgs $remote $remoteCheck + +Remove-Item -Recurse -Force $stage, $stage2 -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "Published:" +Write-Host " https://xbuniverse.duckdns.org/api/media/RedSyncLauncher.zip" +Write-Host " https://xbuniverse.duckdns.org/rs" +Write-Host " https://xbuniverse.duckdns.org/api/media/RedSync.html" diff --git a/release/ops/README.md b/release/ops/README.md index d241cf1c..62e955fb 100644 --- a/release/ops/README.md +++ b/release/ops/README.md @@ -1,4 +1,29 @@ -# RedSync ops — short URL, anti-regression, 24/7 heal bot +# RedSync ops — short URL, anti-regression, 24/7 heal bot, beta server + +## 0) Pelican BETA server (real Wings/panel clone) + +Keeps Official on `:2077` and creates a second **Pelican panel** server with the +same egg/resources on `:2177` for test builds. + +On Windows (uses your VPS key): + +```powershell +powershell -ExecutionPolicy Bypass -File "$env:TEMP\RedSync-ops\release\ops\Deploy-BetaServer.ps1" +``` + +Or on the VPS as root: + +```bash +bash create-pelican-beta-panel.sh +``` + +- Panel: https://panel.xbuniverse.duckdns.org → **RedSync BETA** +- Volume: `/var/lib/pelican/volumes//` +- Removes standalone docker `redsync-beta` if present +- Launcher: `servers.json` → `88.214.59.166:2177` + +> `create-pelican-beta.sh` is the old standalone Docker twin (not panel-managed). Prefer `create-pelican-beta-panel.sh`. + ## 1) Shorter hosted HTML URL diff --git a/release/ops/add-xerox-admin.sh b/release/ops/add-xerox-admin.sh new file mode 100755 index 00000000..0e2893e9 --- /dev/null +++ b/release/ops/add-xerox-admin.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Add XEROX as a SEPARATE admin from XEROX710 on Official + BETA. +# Desktop can use XEROX; other machine keeps XEROX710 — both can be online. +set -euo pipefail + +python3 - <<'PY' +import hashlib, json, secrets, base64 +from pathlib import Path + +def gen_secret() -> str: + raw = secrets.token_bytes(18) + s = base64.b64encode(raw).decode("ascii").rstrip("=") + return s.replace("+", "x").replace("/", "y") + +def sha256_hex(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + +vols = [ + ("Official", Path("/var/lib/pelican/volumes/baecb693-decc-479e-8f3d-fb9e6aa1cbc9")), + ("BETA", Path("/var/lib/pelican/volumes/030d21e5-0dab-4f11-8a37-ea4b67eb5008")), +] + +# One secret for XEROX on both servers (easier for desktop), or per-server. +# Per-server is safer / matches how XEROX710 works after rotate. +for label, vol in vols: + if not vol.is_dir(): + print(f"skip missing {label} {vol}") + continue + admins_path = vol / "admins.json" + secrets_path = vol / "admin-secrets.txt" + admins = json.loads(admins_path.read_text(encoding="utf-8")) if admins_path.is_file() else [] + # normalize list of dicts + by_name = {} + for a in admins: + n = (a.get("Name") or a.get("name") or "").strip() + if n: + by_name[n.lower()] = {"Name": n, "SecretHash": a.get("SecretHash") or a.get("secretHash"), "Secret": None} + + secret = gen_secret() + by_name["xerox"] = {"Name": "XEROX", "SecretHash": sha256_hex(secret), "Secret": None} + + # keep stable order: XEROX710, XEROX, lucifer420, others + preferred = ["XEROX710", "XEROX", "lucifer420"] + out = [] + seen = set() + for p in preferred: + k = p.lower() + if k in by_name: + out.append(by_name[k]) + seen.add(k) + for k, v in sorted(by_name.items(), key=lambda kv: kv[1]["Name"].lower()): + if k not in seen: + out.append(v) + + admins_path.write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8") + + # rewrite secrets file lines for known admins; preserve others + lines = [] + if secrets_path.is_file(): + for line in secrets_path.read_text(encoding="utf-8", errors="replace").splitlines(): + if not line.strip() or line.strip().startswith("#"): + lines.append(line) + continue + if "=" not in line: + lines.append(line) + continue + name, _, _rest = line.partition("=") + if name.strip().lower() == "xerox": + continue # replace below + lines.append(line) + lines.append(f"XEROX={secret}") + secrets_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + print(f"===== {label} =====") + print(f"added/updated admin XEROX") + print(f"XEROX={secret}") + print(f"admins: {', '.join(a['Name'] for a in out)}") + print() + +print("Restart both servers so they reload admins.json:") +print(" docker restart baecb693-decc-479e-8f3d-fb9e6aa1cbc9 030d21e5-0dab-4f11-8a37-ea4b67eb5008") +PY + +chown pelican:pelican \ + /var/lib/pelican/volumes/baecb693-decc-479e-8f3d-fb9e6aa1cbc9/admins.json \ + /var/lib/pelican/volumes/baecb693-decc-479e-8f3d-fb9e6aa1cbc9/admin-secrets.txt \ + /var/lib/pelican/volumes/030d21e5-0dab-4f11-8a37-ea4b67eb5008/admins.json \ + /var/lib/pelican/volumes/030d21e5-0dab-4f11-8a37-ea4b67eb5008/admin-secrets.txt \ + 2>/dev/null || true + +docker restart baecb693-decc-479e-8f3d-fb9e6aa1cbc9 030d21e5-0dab-4f11-8a37-ea4b67eb5008 +sleep 4 +echo +echo "===== verify allowlists =====" +echo "Official:"; python3 -c "import json;print([a['Name'] for a in json.load(open('/var/lib/pelican/volumes/baecb693-decc-479e-8f3d-fb9e6aa1cbc9/admins.json'))])" +echo "BETA:"; python3 -c "import json;print([a['Name'] for a in json.load(open('/var/lib/pelican/volumes/030d21e5-0dab-4f11-8a37-ea4b67eb5008/admins.json'))])" +echo +grep -E '^XEROX=' /var/lib/pelican/volumes/*/admin-secrets.txt || true diff --git a/release/ops/check-pelican-beta.sh b/release/ops/check-pelican-beta.sh new file mode 100755 index 00000000..dd000e46 --- /dev/null +++ b/release/ops/check-pelican-beta.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Status of Official + RedSync BETA after panel create/start. +set -euo pipefail +BETA_UUID="${1:-030d21e5-0dab-4f11-8a37-ea4b67eb5008}" +SRC_UUID="${2:-baecb693-decc-479e-8f3d-fb9e6aa1cbc9}" + +echo "===== ports =====" +ss -lntu 2>/dev/null | grep -E ':2077\b|:2177\b' || echo "(no 2077/2177 listeners)" + +echo +echo "===== docker ps -a (redsync / uuids / 2177) =====" +docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}' +echo +echo "--- filtered ---" +docker ps -a --format '{{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}' | grep -iE "${BETA_UUID}|${SRC_UUID}|2177|2077|redsync" || echo "(no matches)" + +echo +echo "===== beta container logs (if any) =====" +if docker ps -a --format '{{.Names}}' | grep -qx "$BETA_UUID"; then + docker logs --tail 40 "$BETA_UUID" 2>&1 || true +else + echo "no container named $BETA_UUID" + # Wings sometimes prefixes + CID=$(docker ps -aq --filter "name=${BETA_UUID}" | head -1 || true) + if [[ -n "$CID" ]]; then + echo "found filter name CID=$CID" + docker ps -a --filter "id=$CID" --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' + docker logs --tail 40 "$CID" 2>&1 || true + fi +fi + +echo +echo "===== volume =====" +ls -la "/var/lib/pelican/volumes/${BETA_UUID}" 2>/dev/null | head -25 || echo "missing volume" +test -f "/var/lib/pelican/volumes/${BETA_UUID}/RedSync.Server" && echo "RedSync.Server: present" || echo "RedSync.Server: MISSING" + +echo +echo "===== panel DB =====" +sudo -u www-data php -r ' +require "/var/www/pelican/vendor/autoload.php"; +$app=require "/var/www/pelican/bootstrap/app.php"; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +foreach (App\Models\Server::orderBy("id")->get() as $s) { + $a=$s->allocation; + echo sprintf("#%d %-20s uuid=%s status=%s %s:%s mem=%s\n", + $s->id,$s->name,$s->uuid,var_export($s->status,true), + $a->ip??"-",$a->port??"-",$s->memory); +} +' 2>&1 + +echo +echo "===== wings recent =====" +journalctl -u wings -n 40 --no-pager 2>/dev/null | tail -40 || true diff --git a/release/ops/create-pelican-beta-panel.sh b/release/ops/create-pelican-beta-panel.sh new file mode 100755 index 00000000..2c70f32c --- /dev/null +++ b/release/ops/create-pelican-beta-panel.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# Create a REAL Pelican panel server (Wings-managed) cloned from live RedSync. +# Removes standalone docker `redsync-beta`, allocates :2177, same resources as Official. +set -euo pipefail + +BETA_PORT="${BETA_PORT:-2177}" +SRC_UUID="${SRC_UUID:-baecb693-decc-479e-8f3d-fb9e6aa1cbc9}" +PANEL_ROOT="${PANEL_ROOT:-/var/www/pelican}" +BETA_DATA_FALLBACK="${BETA_DATA_FALLBACK:-/opt/redsync-beta}" +STATE_DIR="${STATE_DIR:-/opt/redsync-data/ops}" +STATE_FILE="$STATE_DIR/beta-pelican-server.json" +CREATE_JSON="$(mktemp /tmp/redsync-beta-create-XXXXXX.json)" +chmod 666 "$CREATE_JSON" + +log() { printf '[pelican-beta] %s\n' "$*"; } +die() { printf '[pelican-beta] ERROR: %s\n' "$*" >&2; exit 1; } + +[[ "$(id -u)" -eq 0 ]] || die "run as root" +[[ -f "$PANEL_ROOT/artisan" ]] || die "pelican panel not found at $PANEL_ROOT" +command -v php >/dev/null || die "php required" +command -v docker >/dev/null || die "docker required" +command -v rsync >/dev/null || die "rsync required" +command -v python3 >/dev/null || die "python3 required" + +mkdir -p "$STATE_DIR" + +# --- free :2177 from standalone docker twin --- +if docker ps -a --format '{{.Names}}' | grep -qx redsync-beta; then + log "removing standalone docker redsync-beta (not a Pelican server)" + docker rm -f redsync-beta >/dev/null || true + sleep 1 +fi + +if ss -lntu 2>/dev/null | grep -qE ":${BETA_PORT}\\b"; then + die "host port ${BETA_PORT} still in use — free it before creating the Pelican server" +fi + +TMP_PHP="$(mktemp /tmp/create-redsync-beta-XXXXXX.php)" +# www-data must be able to read this when we sudo -u www-data php ... +chmod 644 "$TMP_PHP" +trap 'rm -f "$TMP_PHP" "$CREATE_JSON"' EXIT + +# Phase 1: create server record + Wings container (no file copy yet) +cat > "$TMP_PHP" <<'PHP' +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +use App\Models\Allocation; +use App\Models\Server; +use App\Services\Servers\ServerCreationService; + +function out(string $m): void { fwrite(STDOUT, "[pelican-beta] {$m}\n"); } +function fail(string $m): void { fwrite(STDERR, "[pelican-beta] ERROR: {$m}\n"); exit(1); } + +if ($phase === 'start') { + $uuid = getenv('BETA_UUID') ?: ''; + $uuid !== '' || fail('BETA_UUID required for start phase'); + $server = Server::query()->where('uuid', $uuid)->first() ?: fail("server $uuid not found"); + try { + $server->status = null; + $server->skip_scripts = true; + $server->save(); + } catch (Throwable $e) { + out('warn status: ' . $e->getMessage()); + } + + $repoClass = null; + foreach ([ + 'App\\Repositories\\Daemon\\DaemonServerRepository', + 'App\\Repositories\\Wings\\DaemonServerRepository', + ] as $c) { + if (class_exists($c)) { $repoClass = $c; break; } + } + $repoClass || fail('DaemonServerRepository class not found'); + out("using $repoClass"); + + $repo = app($repoClass)->setServer($server->fresh()); + try { + if (method_exists($repo, 'sync')) { + $repo->sync(); + out('wings sync ok'); + } + } catch (Throwable $e) { + out('warn sync: ' . $e->getMessage()); + } + try { + // Ensure Wings container exists + if (method_exists($repo, 'create')) { + try { + $repo->create(false); + out('wings create ok'); + } catch (Throwable $e) { + out('create note: ' . $e->getMessage()); + } + } + $repo = app($repoClass)->setServer($server->fresh()); + if (method_exists($repo, 'power')) { + $repo->power('start'); + out('wings power(start) ok'); + } elseif (method_exists($repo, 'send')) { + $repo->send('start'); + out('wings send(start) ok'); + } else { + fail('repository has no power/send'); + } + } catch (Throwable $e) { + out('warn start: ' . $e->getMessage()); + out('Start RedSync BETA manually in the panel, or run start-pelican-beta.sh'); + } + exit(0); +} + +$dup = Server::query() + ->where(function ($q) { + $q->where('name', 'RedSync BETA') + ->orWhere('name', 'like', 'RedSync BETA%'); + }) + ->first(); +if ($dup) { + out("already exists: {$dup->name} uuid={$dup->uuid}"); + file_put_contents($outFile, json_encode([ + 'id' => $dup->id, + 'uuid' => $dup->uuid, + 'name' => $dup->name, + 'port' => $betaPort, + 'alreadyExisted' => true, + ])); + exit(0); +} + +$src = Server::query()->where('uuid', $srcUuid)->first(); +if (!$src) { + $src = Server::query()->where('image', 'like', '%redsync%')->first(); +} +$src || fail("source RedSync server not found"); +$src->loadMissing(['allocation', 'egg', 'serverVariables.variable']); +$srcAlloc = $src->allocation ?: fail('source has no allocation'); + +out("source {$src->name} uuid={$src->uuid} mem={$src->memory} disk={$src->disk} cpu={$src->cpu}"); +out("alloc {$srcAlloc->ip}:{$srcAlloc->port} egg={$src->egg_id} image={$src->image}"); + +$alloc = Allocation::query() + ->where('node_id', $src->node_id) + ->where('ip', $srcAlloc->ip) + ->where('port', $betaPort) + ->first(); +if ($alloc && $alloc->server_id) { + fail("{$srcAlloc->ip}:{$betaPort} already assigned to server_id={$alloc->server_id}"); +} +if (!$alloc) { + $alloc = Allocation::query()->create([ + 'node_id' => $src->node_id, + 'ip' => $srcAlloc->ip, + 'port' => $betaPort, + 'ip_alias' => $srcAlloc->ip_alias, + 'server_id' => null, + 'notes' => 'RedSync BETA', + ]); + out("created allocation id={$alloc->id}"); +} else { + out("reusing allocation id={$alloc->id}"); +} + +$environment = []; +foreach ($src->serverVariables as $sv) { + $key = $sv->variable->env_variable ?? null; + if ($key) { + $environment[$key] = (string) ($sv->variable_value ?? ''); + } +} + +try { + $server = app(ServerCreationService::class)->handle([ + 'name' => 'RedSync BETA', + 'description' => 'Staging / test builds. Official stays on :2077.', + 'owner_id' => $src->owner_id, + 'egg_id' => $src->egg_id, + 'node_id' => $src->node_id, + 'allocation_id' => $alloc->id, + 'memory' => $src->memory, + 'swap' => $src->swap, + 'disk' => $src->disk, + 'io' => $src->io, + 'cpu' => $src->cpu, + 'threads' => $src->threads, + 'oom_killer' => (bool) $src->oom_killer, + 'startup' => $src->startup ?: './RedSync.Server', + 'image' => $src->image, + 'database_limit' => $src->database_limit, + 'allocation_limit' => max((int) $src->allocation_limit, 1), + 'backup_limit' => $src->backup_limit, + 'skip_scripts' => true, + 'start_on_completion' => false, + 'environment' => $environment, + ]); +} catch (Throwable $e) { + fail('create failed: ' . $e->getMessage()); +} + +$payload = [ + 'id' => $server->id, + 'uuid' => $server->uuid, + 'name' => $server->name, + 'port' => $betaPort, + 'allocation_id' => $alloc->id, + 'memory' => $server->memory, + 'disk' => $server->disk, + 'cpu' => $server->cpu, + 'image' => $server->image, + 'volume' => '/var/lib/pelican/volumes/' . $server->uuid, + 'clonedFromUuid' => $src->uuid, + 'panel' => 'https://panel.xbuniverse.duckdns.org', + 'alreadyExisted' => false, +]; +file_put_contents($outFile, json_encode($payload, JSON_PRETTY_PRINT) . "\n"); +out('created uuid=' . $server->uuid); +echo json_encode($payload) . "\n"; +PHP + +log "phase 1: create Pelican server record + Wings instance" +chmod 644 "$TMP_PHP" +# Prefer a path www-data can always read (some hosts isolate /tmp). +PANEL_PHP="/var/www/pelican/storage/app/create-redsync-beta.php" +cp -f "$TMP_PHP" "$PANEL_PHP" +chown www-data:www-data "$PANEL_PHP" +chmod 644 "$PANEL_PHP" +sudo -u www-data -E env \ + BETA_PORT="$BETA_PORT" \ + SRC_UUID="$SRC_UUID" \ + CREATE_JSON="$CREATE_JSON" \ + PHASE=create \ + php "$PANEL_PHP" + +# www-data may not write CREATE_JSON if in /tmp with sticky bits — ensure readable +[[ -s "$CREATE_JSON" ]] || die "create phase produced no JSON ($CREATE_JSON)" +cp -f "$CREATE_JSON" "$STATE_FILE" +BETA_UUID="$(python3 -c "import json;print(json.load(open('$STATE_FILE'))['uuid'])")" +VOLUME="$(python3 -c "import json;print(json.load(open('$STATE_FILE')).get('volume',''))")" +[[ -n "$BETA_UUID" ]] || die "missing beta uuid" +VOLUME="${VOLUME:-/var/lib/pelican/volumes/$BETA_UUID}" + +log "beta uuid=$BETA_UUID" +log "volume=$VOLUME" + +# Wait for Wings volume +for i in $(seq 1 40); do + [[ -d "$VOLUME" ]] && break + sleep 0.5 +done +mkdir -p "$VOLUME" + +SRC_VOLUME="/var/lib/pelican/volumes/$SRC_UUID" +COPY_FROM="$SRC_VOLUME" +if [[ -f "$BETA_DATA_FALLBACK/RedSync.Server" ]]; then + COPY_FROM="$BETA_DATA_FALLBACK" +fi +[[ -d "$COPY_FROM" ]] || die "no source files at $COPY_FROM" + +log "phase 2: copy game files $COPY_FROM -> $VOLUME" +rsync -a --delete \ + --exclude 'logs/' \ + --exclude '*.log' \ + --exclude 'core.*' \ + "$COPY_FROM"/ "$VOLUME"/ +chown -R pelican:pelican "$VOLUME" + +log "phase 3: mark installed + start via Wings" +sudo -u www-data -E env \ + BETA_UUID="$BETA_UUID" \ + PHASE=start \ + php "$PANEL_PHP" +rm -f "$PANEL_PHP" + +echo +log "===== verify =====" +sleep 4 +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}' | head -1 +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}' | grep -iE 'redsync|2177|baecb693' || true +echo +ss -lntu 2>/dev/null | grep -E ":${BETA_PORT}\\b|:2077\\b" || true +echo +cat "$STATE_FILE" +echo +log "Panel: https://panel.xbuniverse.duckdns.org (look for RedSync BETA)" +log "Connect: 88.214.59.166:${BETA_PORT}" +log "Official untouched: 88.214.59.166:2077" diff --git a/release/ops/create-pelican-beta.sh b/release/ops/create-pelican-beta.sh new file mode 100755 index 00000000..8256f9ff --- /dev/null +++ b/release/ops/create-pelican-beta.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Clone the live RedSync Pelican game server into a side-by-side BETA instance. +# - Same CPU/memory limits as production +# - New host port (default 2177 TCP+UDP) +# - Independent data dir (copy of prod volume) +# - Does NOT stop or modify the live :2077 server +# +# Usage (on VPS as root): +# bash create-pelican-beta.sh +# BETA_PORT=2177 bash create-pelican-beta.sh +set -euo pipefail + +BETA_PORT="${BETA_PORT:-2177}" +BETA_NAME="${BETA_NAME:-redsync-beta}" +BETA_DATA="${BETA_DATA:-/opt/redsync-beta}" +PROD_IMAGE_FILTER="${PROD_IMAGE_FILTER:-redsync-pelican}" +STATE_DIR="${STATE_DIR:-/opt/redsync-data/ops}" +STATE_FILE="$STATE_DIR/beta-server.json" + +log() { printf '[beta] %s\n' "$*"; } +die() { printf '[beta] ERROR: %s\n' "$*" >&2; exit 1; } + +[[ "$(id -u)" -eq 0 ]] || die "run as root" +command -v docker >/dev/null || die "docker required" +command -v python3 >/dev/null || die "python3 required" + +# --- locate production container (publishes host 2077, redsync-pelican image) --- +PROD_CID="$(docker ps -q --filter "publish=2077" --filter "ancestor=${PROD_IMAGE_FILTER}:latest" | head -1 || true)" +if [[ -z "$PROD_CID" ]]; then + PROD_CID="$(docker ps -q --filter "ancestor=${PROD_IMAGE_FILTER}:latest" | head -1 || true)" +fi +if [[ -z "$PROD_CID" ]]; then + PROD_CID="$(docker ps --format '{{.ID}} {{.Ports}} {{.Image}}' | awk '/2077/ && /redsync/ {print $1; exit}')" +fi +[[ -n "$PROD_CID" ]] || die "could not find production redsync-pelican container on :2077" + +PROD_NAME="$(docker inspect -f '{{.Name}}' "$PROD_CID" | sed 's#^/##')" +PROD_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$PROD_CID")" +log "prod container: $PROD_NAME ($PROD_CID)" +log "prod image: $PROD_IMAGE" + +# --- copy resource limits --- +read -r MEM NANO CPUSHARES <<< "$(docker inspect -f '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.CpuShares}}' "$PROD_CID")" +MEM="${MEM:-0}" +NANO="${NANO:-0}" +CPUSHARES="${CPUSHARES:-0}" +log "resources: Memory=${MEM} NanoCpus=${NANO} CpuShares=${CPUSHARES}" + +# --- find writable game volume (first bind mount that looks like pelican/volume data) --- +PROD_MOUNT="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/home/container"}}{{.Source}}{{end}}{{end}}' "$PROD_CID")" +if [[ -z "$PROD_MOUNT" ]]; then + PROD_MOUNT="$(docker inspect -f '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}' "$PROD_CID" | awk '/home\/container|pelican|volumes/ {print $1; exit}')" +fi +[[ -n "$PROD_MOUNT" && -d "$PROD_MOUNT" ]] || die "could not resolve prod volume mount (got: ${PROD_MOUNT:-empty})" +log "prod volume: $PROD_MOUNT" + +# --- port free? --- +if ss -lntu 2>/dev/null | grep -qE ":${BETA_PORT}\\b"; then + if docker ps --format '{{.Names}} {{.Ports}}' | grep -qE "^${BETA_NAME} .*${BETA_PORT}"; then + log "beta already bound on ${BETA_PORT} — will recreate container, keep data" + else + die "host port ${BETA_PORT} already in use by something else" + fi +fi + +# --- clone data (exclude bulky logs/archives; keep admins/security/bans/dlls) --- +mkdir -p "$BETA_DATA" +if [[ ! -f "$BETA_DATA/.beta-cloned" ]]; then + log "cloning volume -> $BETA_DATA (first run)" + rsync -a --delete \ + --exclude 'logs/' \ + --exclude '*/logs/' \ + --exclude '*.log' \ + --exclude 'core.*' \ + "$PROD_MOUNT"/ "$BETA_DATA"/ + # Mark as beta in a sidecar (does not break game files) + cat > "$BETA_DATA/.redsync-beta" </dev/null || echo 0)" +PROD_GID="$(stat -c '%g' "$PROD_MOUNT" 2>/dev/null || echo 0)" +chown -R "${PROD_UID}:${PROD_GID}" "$BETA_DATA" || true + +# --- docker run args for resources --- +DOCKER_RES=() +if [[ "$MEM" != "0" ]]; then + DOCKER_RES+=(--memory="$MEM") +fi +if [[ "$NANO" != "0" ]]; then + DOCKER_RES+=(--cpus="$(python3 - </dev/null +fi + +log "starting $BETA_NAME on 0.0.0.0:${BETA_PORT} (tcp+udp)" + +# Wings injects its own entrypoint; for a side-by-side beta we start RedSync.Server +# directly (same as the live process: ./RedSync.Server in /home/container). +start_beta() { + local entry="$1" + shift + docker run -d \ + --name "$BETA_NAME" \ + --restart unless-stopped \ + --hostname redsync-beta \ + "${DOCKER_RES[@]}" \ + "${ENV_ARGS[@]}" \ + -p "${BETA_PORT}:${BETA_PORT}/tcp" \ + -p "${BETA_PORT}:${BETA_PORT}/udp" \ + -v "${BETA_DATA}:/home/container" \ + -w /home/container \ + ${USER_SPEC:+--user "$USER_SPEC"} \ + --entrypoint "$entry" \ + "$PROD_IMAGE" \ + "$@" >/dev/null +} + +# Prefer binary name present in the cloned volume. +if [[ -x "$BETA_DATA/RedSync.Server" || -f "$BETA_DATA/RedSync.Server" ]]; then + start_beta ./RedSync.Server +elif [[ -f "$BETA_DATA/RedSync.Server.dll" ]]; then + start_beta dotnet RedSync.Server.dll +else + # Last resort: image default entrypoint + docker run -d \ + --name "$BETA_NAME" \ + --restart unless-stopped \ + --hostname redsync-beta \ + "${DOCKER_RES[@]}" \ + "${ENV_ARGS[@]}" \ + -p "${BETA_PORT}:${BETA_PORT}/tcp" \ + -p "${BETA_PORT}:${BETA_PORT}/udp" \ + -v "${BETA_DATA}:/home/container" \ + -w "${WORKDIR:-/home/container}" \ + ${USER_SPEC:+--user "$USER_SPEC"} \ + "$PROD_IMAGE" >/dev/null +fi + +sleep 4 +if ! docker ps --format '{{.Names}}' | grep -qx "$BETA_NAME"; then + log "container not running — logs:" + docker logs --tail 60 "$BETA_NAME" || true + die "beta container exited — see docker logs $BETA_NAME" +fi + +# If process listens on 2077 inside despite SERVER_PORT, remap once. +if ! ss -lntu 2>/dev/null | grep -qE ":${BETA_PORT}\\b"; then + if docker exec "$BETA_NAME" sh -c "ss -lntu 2>/dev/null | grep -q ':2077'" 2>/dev/null; then + log "process bound 2077 inside container — recreating with host ${BETA_PORT}->2077 map" + docker rm -f "$BETA_NAME" >/dev/null + docker run -d \ + --name "$BETA_NAME" \ + --restart unless-stopped \ + --hostname redsync-beta \ + "${DOCKER_RES[@]}" \ + -e SERVER_PORT=2077 \ + -p "${BETA_PORT}:2077/tcp" \ + -p "${BETA_PORT}:2077/udp" \ + -v "${BETA_DATA}:/home/container" \ + -w /home/container \ + ${USER_SPEC:+--user "$USER_SPEC"} \ + --entrypoint ./RedSync.Server \ + "$PROD_IMAGE" >/dev/null + sleep 3 + fi +fi + +# Firewall (best effort) +if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -qi 'Status: active'; then + ufw allow "${BETA_PORT}/tcp" comment "RedSync beta" || true + ufw allow "${BETA_PORT}/udp" comment "RedSync beta" || true +fi + +# --- persist state --- +mkdir -p "$STATE_DIR" +python3 - </dev/null | grep -E ":${BETA_PORT}\\b" || log "WARNING: ${BETA_PORT} not in ss yet (check docker logs)" +echo +log "BETA endpoint: 88.214.59.166:${BETA_PORT}" +log "PROD untouched: 88.214.59.166:2077" +log "Data: $BETA_DATA" +log "State: $STATE_FILE" +echo +log "Push a new build into beta:" +log " scp RedSync.Server* root@VPS:$BETA_DATA/" +log " docker restart $BETA_NAME" +echo +log "Recent beta logs:" +docker logs --tail 25 "$BETA_NAME" || true diff --git a/release/ops/discover-pelican-redsync.sh b/release/ops/discover-pelican-redsync.sh new file mode 100755 index 00000000..6be2db97 --- /dev/null +++ b/release/ops/discover-pelican-redsync.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Discover Pelican/Wings layout for the live RedSync server (read-only). +set -euo pipefail + +echo "===== wings / pelican services =====" +systemctl is-active wings pelican-queue 2>/dev/null || true +systemctl status wings --no-pager -n 5 2>/dev/null | head -20 || true + +echo +echo "===== panel / wings paths =====" +for d in /var/www/pelican /var/www/html/pelican /var/www/pterodactyl /etc/pelican /etc/pterodactyl /var/lib/pelican; do + [[ -e "$d" ]] && echo "FOUND $d" && ls -la "$d" 2>/dev/null | head -15 +done + +echo +echo "===== wings config (sanitized) =====" +for f in /etc/pelican/config.yml /etc/pterodactyl/config.yml; do + if [[ -f "$f" ]]; then + echo "-- $f" + grep -E '^(app|api|system|docker|remote|token_id|token|uuid|root_directory|allowed_mounts)' -n "$f" 2>/dev/null | sed -E 's/(token|key|password|secret).*/\1: ***redacted***/I' || true + grep -nE 'root_directory|data|uuid|api' "$f" 2>/dev/null | head -40 || true + fi +done + +echo +echo "===== live redsync container =====" +CID=$(docker ps -q --filter publish=2077 --filter ancestor=redsync-pelican:latest | head -1) +echo "CID=$CID" +if [[ -n "$CID" ]]; then + docker inspect "$CID" --format 'Name={{.Name}} Image={{.Config.Image}} Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}}' + echo "Env (selected):" + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CID" | grep -iE 'SERVER_|P_SERVER|STARTUP|USER|HOME|TZ|PEL|WINGS' || true + echo "Labels:" + docker inspect -f '{{json .Config.Labels}}' "$CID" | python3 -m json.tool 2>/dev/null | head -80 || docker inspect -f '{{json .Config.Labels}}' "$CID" + echo "Mounts:" + docker inspect -f '{{range .Mounts}}{{println .Type .Source "->" .Destination}}{{end}}' "$CID" +fi + +echo +echo "===== standalone redsync-beta (should move into pelican) =====" +docker ps -a --filter name=redsync-beta --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true + +echo +echo "===== pelican DB / artisan =====" +for root in /var/www/pelican /var/www/html/pelican /var/www/pterodactyl; do + if [[ -f "$root/artisan" ]]; then + echo "ARTISAN=$root/artisan" + (cd "$root" && php artisan --version 2>/dev/null || true) + (cd "$root" && php artisan list 2>/dev/null | grep -iE 'server|alloc|nest|egg' | head -40 || true) + if [[ -f "$root/.env" ]]; then + echo ".env keys (names only):" + grep -E '^[A-Z0-9_]+=' "$root/.env" | cut -d= -f1 | head -60 + grep -E '^(APP_URL|DB_|QUEUE)' "$root/.env" | sed -E 's/(PASSWORD|SECRET|KEY)=.*/\1=***redacted***/' + fi + fi +done + +echo +echo "===== mysql/mariadb servers matching redsync (if local DB) =====" +if command -v mysql >/dev/null; then + # best-effort; may fail without creds + mysql -NBe "SHOW DATABASES;" 2>/dev/null | head || true +fi + +echo +echo "===== allocations / wings server folders =====" +ls -la /var/lib/pelican/volumes 2>/dev/null | head -30 || ls -la /var/lib/pterodactyl/volumes 2>/dev/null | head -30 || true +find /var/lib/pelican /var/lib/pterodactyl -maxdepth 3 -type d -iname '*redsync*' 2>/dev/null | head || true diff --git a/release/ops/fix-pelican-beta-page.sh b/release/ops/fix-pelican-beta-page.sh new file mode 100755 index 00000000..1bf6eff8 --- /dev/null +++ b/release/ops/fix-pelican-beta-page.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Diagnose / fix Pelican "error while loading page" on RedSync BETA. +set -euo pipefail +PANEL=/var/www/pelican +BETA_UUID=030d21e5-0dab-4f11-8a37-ea4b67eb5008 +OFFICIAL_UUID=baecb693-decc-479e-8f3d-fb9e6aa1cbc9 + +echo "===== 1) storage / cache (common 500 cause) =====" +mkdir -p \ + "$PANEL/storage/framework/cache/data" \ + "$PANEL/storage/framework/sessions" \ + "$PANEL/storage/framework/views" \ + "$PANEL/storage/logs" \ + "$PANEL/bootstrap/cache" +chown -R www-data:www-data "$PANEL/storage" "$PANEL/bootstrap/cache" +chmod -R ug+rwX "$PANEL/storage" "$PANEL/bootstrap/cache" +cd "$PANEL" +sudo -u www-data php artisan optimize:clear 2>&1 | tail -20 || true +sudo -u www-data php artisan filament:optimize-clear 2>&1 | tail -10 || true + +echo +echo "===== 2) recent panel log =====" +LOG_DIR="$PANEL/storage/logs" +LOG="" +for f in \ + "$LOG_DIR/laravel-$(date +%Y-%m-%d).log" \ + "$LOG_DIR/laravel-$(date -u +%Y-%m-%d).log" \ + "$LOG_DIR/laravel.log"; do + [[ -f "$f" ]] && LOG="$f" && break +done +if [[ -z "$LOG" ]]; then + LOG="$(ls -1t "$LOG_DIR"/laravel-*.log 2>/dev/null | head -1 || true)" +fi +if [[ -n "$LOG" ]]; then + echo "Using: $LOG" + echo "----- ERROR / Exception (last 80 matching lines) -----" + grep -nE 'ERROR|Exception|SQLSTATE|Typed property|Undefined|file_put_contents|Filament|Livewire|Daemon|Wings|030d21e5' "$LOG" | tail -n 80 || true + echo "----- last 40 lines -----" + tail -n 40 "$LOG" +else + ls -la "$LOG_DIR" || true + journalctl -u php*-fpm -n 40 --no-pager 2>/dev/null | tail -40 || true + journalctl -u nginx -n 20 --no-pager 2>/dev/null | tail -20 || true +fi + +echo +echo "===== 3) BETA server row in DB =====" +sudo -u www-data php -r ' +require "/var/www/pelican/vendor/autoload.php"; +$app=require "/var/www/pelican/bootstrap/app.php"; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$s=App\Models\Server::where("uuid","030d21e5-0dab-4f11-8a37-ea4b67eb5008")->first(); +if(!$s){echo "BETA missing from DB\n"; exit(0);} +echo "id={$s->id} name={$s->name} status=".var_export($s->status,true)." node={$s->node_id} egg={$s->egg_id}\n"; +echo "memory={$s->memory} disk={$s->disk} cpu={$s->cpu} image={$s->image}\n"; +echo "startup={$s->startup}\n"; +foreach($s->allocations as $a){ echo "alloc {$a->ip}:{$a->port} primary=".(($s->allocation_id===$a->id)?"yes":"no")."\n"; } +try { + foreach($s->variables as $v){ echo "var {$v->env_variable}={$v->server_value}\n"; } +} catch(Throwable $e) { + echo "vars warn: {$e->getMessage()}\n"; +} +' 2>&1 + +echo +echo "===== 4) Wings container =====" +docker ps -a --filter "name=$BETA_UUID" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' +docker logs --tail 25 "$BETA_UUID" 2>&1 || true + +echo +echo "===== 5) sync BETA with Wings =====" +sudo -u www-data php -r ' +require "/var/www/pelican/vendor/autoload.php"; +$app=require "/var/www/pelican/bootstrap/app.php"; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$s=App\Models\Server::where("uuid","030d21e5-0dab-4f11-8a37-ea4b67eb5008")->firstOrFail(); +$cls=class_exists("App\\Repositories\\Daemon\\DaemonServerRepository") + ?"App\\Repositories\\Daemon\\DaemonServerRepository" + :"App\\Repositories\\Wings\\DaemonServerRepository"; +try { + app($cls)->setServer($s)->sync(); + echo "sync ok\n"; +} catch(Throwable $e) { + echo "sync FAIL: {$e->getMessage()}\n"; +} +try { + $s->status=null; $s->save(); + echo "cleared status\n"; +} catch(Throwable $e) { + echo "status warn: {$e->getMessage()}\n"; +} +' 2>&1 + +echo +echo "===== 6) HTTP smoke =====" +curl -sI -k https://panel.xbuniverse.duckdns.org/ | head -8 || true +echo +echo "Open BETA again in the panel. If still broken, paste section 2 ERROR lines" +echo "(or run: bash /tmp/pelican-log-tail.sh)." diff --git a/release/ops/fix-pelican-cache.sh b/release/ops/fix-pelican-cache.sh new file mode 100755 index 00000000..96f199cf --- /dev/null +++ b/release/ops/fix-pelican-cache.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Fix Pelican panel 500: missing storage/framework/cache/data dirs. +set -euo pipefail +PANEL="${PANEL_ROOT:-/var/www/pelican}" +[[ -d "$PANEL" ]] || { echo "panel not found: $PANEL" >&2; exit 1; } + +echo "Fixing storage permissions under $PANEL" +mkdir -p \ + "$PANEL/storage/framework/cache/data" \ + "$PANEL/storage/framework/sessions" \ + "$PANEL/storage/framework/views" \ + "$PANEL/storage/logs" \ + "$PANEL/storage/app/public" \ + "$PANEL/bootstrap/cache" + +chown -R www-data:www-data "$PANEL/storage" "$PANEL/bootstrap/cache" +chmod -R ug+rwX "$PANEL/storage" "$PANEL/bootstrap/cache" + +# Clear broken cache entries / rebuild +cd "$PANEL" +sudo -u www-data php artisan optimize:clear 2>/dev/null || true +sudo -u www-data php artisan cache:clear 2>/dev/null || true +sudo -u www-data php artisan view:clear 2>/dev/null || true +sudo -u www-data php artisan config:clear 2>/dev/null || true + +# Ensure cache path exists again after clear +mkdir -p "$PANEL/storage/framework/cache/data" +chown -R www-data:www-data "$PANEL/storage/framework/cache" + +echo +echo "Storage tree:" +ls -la "$PANEL/storage/framework/cache" || true +ls -la "$PANEL/storage/framework/cache/data" | head || true + +echo +echo "Quick HTTP check:" +curl -sI -k https://panel.xbuniverse.duckdns.org/ | head -8 || true +echo +echo "Done. Reload the panel in your browser." diff --git a/release/ops/fix-status-and-official.sh b/release/ops/fix-status-and-official.sh new file mode 100755 index 00000000..8cb1dd46 --- /dev/null +++ b/release/ops/fix-status-and-official.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Ensure Official is up; show status ports + probe /status. +set -euo pipefail + +OFFICIAL=baecb693-decc-479e-8f3d-fb9e6aa1cbc9 +BETA=030d21e5-0dab-4f11-8a37-ea4b67eb5008 + +echo "===== containers =====" +docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -iE 'NAMES|baecb693|030d21e5' || true + +echo +echo "===== start Official via Wings if missing =====" +if ! docker ps --format '{{.Names}}' | grep -qx "$OFFICIAL"; then + echo "Official not running — create/start via panel API" + PANEL_PHP=/var/www/pelican/storage/app/start-official.php + cat > "$PANEL_PHP" <<'PHP' +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$uuid = 'baecb693-decc-479e-8f3d-fb9e6aa1cbc9'; +$server = App\Models\Server::where('uuid', $uuid)->firstOrFail(); +$repoClass = class_exists('App\\Repositories\\Daemon\\DaemonServerRepository') + ? 'App\\Repositories\\Daemon\\DaemonServerRepository' + : 'App\\Repositories\\Wings\\DaemonServerRepository'; +$repo = app($repoClass)->setServer($server); +try { $repo->create(true); echo "create(start) ok\n"; } +catch (Throwable $e) { + echo "create note: {$e->getMessage()}\n"; + try { app($repoClass)->setServer($server->fresh())->power('start'); echo "power(start) ok\n"; } + catch (Throwable $e2) { echo "FAIL {$e2->getMessage()}\n"; exit(1); } +} +PHP + chown www-data:www-data "$PANEL_PHP" + sudo -u www-data php "$PANEL_PHP" + rm -f "$PANEL_PHP" + sleep 6 +fi + +echo +echo "===== docker ports =====" +docker ps --format '{{.Names}} {{.Ports}}' | grep -iE 'baecb693|030d21e5' || true + +echo +echo "===== recent logs Official =====" +docker logs --tail 30 "$OFFICIAL" 2>&1 || true +echo +echo "===== recent logs BETA =====" +docker logs --tail 30 "$BETA" 2>&1 || true + +echo +echo "===== in-container listen (BETA) =====" +docker exec "$BETA" sh -c 'ss -lnt 2>/dev/null || netstat -lnt 2>/dev/null' | grep -E ':2177|:2179' || true + +if docker ps --format '{{.Names}}' | grep -qx "$OFFICIAL"; then + echo "===== in-container listen (Official) =====" + docker exec "$OFFICIAL" sh -c 'ss -lnt 2>/dev/null || netstat -lnt 2>/dev/null' | grep -E ':2077|:2079' || true +fi + +echo +echo "===== host probes =====" +for url in \ + http://127.0.0.1:2079/status \ + http://127.0.0.1:2179/status +do + echo "-- $url" + curl -sS --max-time 3 "$url" || echo "FAIL" + echo +done diff --git a/release/ops/pelican-log-tail.sh b/release/ops/pelican-log-tail.sh new file mode 100644 index 00000000..a425647b --- /dev/null +++ b/release/ops/pelican-log-tail.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Show today's Pelican/Laravel exceptions (Filament "error while loading page"). +set -euo pipefail +PANEL=/var/www/pelican +LOG_DIR="$PANEL/storage/logs" +TODAY="laravel-$(date -u +%Y-%m-%d).log" +# prefer local date too +LOCAL="laravel-$(date +%Y-%m-%d).log" + +pick="" +for f in "$LOG_DIR/$TODAY" "$LOG_DIR/$LOCAL" "$LOG_DIR/laravel.log"; do + [[ -f "$f" ]] && pick="$f" && break +done +# fall back to newest laravel-*.log +if [[ -z "$pick" ]]; then + pick="$(ls -1t "$LOG_DIR"/laravel-*.log 2>/dev/null | head -1 || true)" +fi + +echo "Using log: ${pick:-NONE}" +[[ -n "$pick" ]] || exit 1 + +echo +echo "===== last ERROR / Exception blocks =====" +# Print last ~120 matching lines with a bit of context +grep -nE 'ERROR|Exception|local\.ERROR|production\.ERROR|file_put_contents|SQLSTATE|Typed property|Undefined|Wing|Daemon|030d21e5|ServerResource|Filament' "$pick" | tail -n 80 || true + +echo +echo "===== last 60 log lines =====" +tail -n 60 "$pick" + +echo +echo "===== php-fpm / nginx recent errors =====" +journalctl -u 'php*-fpm' -n 30 --no-pager 2>/dev/null | tail -30 || true +grep -i error /var/log/nginx/error.log 2>/dev/null | tail -20 || true diff --git a/release/ops/publish-status-ports.sh b/release/ops/publish-status-ports.sh new file mode 100755 index 00000000..ad2aec88 --- /dev/null +++ b/release/ops/publish-status-ports.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Publish launcher status ports on Pelican RedSync servers: +# Official :2077 → status :2079 +# BETA :2177 → status :2179 +# Adds allocations + docker publish via Wings sync. +set -euo pipefail + +PANEL=/var/www/pelican +php_script=/var/www/pelican/storage/app/publish-status-ports.php + +cat > "$php_script" <<'PHP' +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +use App\Models\Allocation; +use App\Models\Server; +use App\Repositories\Daemon\DaemonServerRepository; + +$map = [ + 'baecb693-decc-479e-8f3d-fb9e6aa1cbc9' => 2079, + '030d21e5-0dab-4f11-8a37-ea4b67eb5008' => 2179, +]; + +foreach ($map as $uuid => $statusPort) { + $server = Server::where('uuid', $uuid)->first(); + if (!$server) { + echo "skip missing $uuid\n"; + continue; + } + $primary = $server->allocation; + if (!$primary) { + echo "skip no primary $uuid\n"; + continue; + } + + $alloc = Allocation::firstOrCreate( + [ + 'node_id' => $server->node_id, + 'ip' => $primary->ip, + 'port' => $statusPort, + ], + [ + 'ip_alias' => $primary->ip_alias, + 'notes' => 'RedSync launcher status /health', + ] + ); + + if ($alloc->server_id && $alloc->server_id !== $server->id) { + echo "port $statusPort already owned by server_id={$alloc->server_id}\n"; + continue; + } + + $alloc->server_id = $server->id; + if (isset($alloc->is_locked)) { + $alloc->is_locked = true; + } + $alloc->save(); + echo "allocated {$primary->ip}:{$statusPort} -> {$server->name}\n"; + + // Raise allocation limit if needed + if ((int) $server->allocation_limit < 2) { + $server->allocation_limit = 2; + $server->save(); + } + + try { + $repoClass = class_exists('App\\Repositories\\Daemon\\DaemonServerRepository') + ? 'App\\Repositories\\Daemon\\DaemonServerRepository' + : 'App\\Repositories\\Wings\\DaemonServerRepository'; + app($repoClass)->setServer($server->fresh())->sync(); + echo "synced {$server->name}\n"; + } catch (Throwable $e) { + echo "sync warn {$server->name}: {$e->getMessage()}\n"; + } +} + +echo "done — open firewall TCP 2079 and 2179 if needed\n"; +PHP + +chown www-data:www-data "$php_script" +chmod 644 "$php_script" +sudo -u www-data php "$php_script" +rm -f "$php_script" + +# Host firewall +if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -qi 'Status: active'; then + ufw allow 2079/tcp comment "RedSync Official status" || true + ufw allow 2179/tcp comment "RedSync BETA status" || true +fi + +echo +echo "===== verify listeners (after server restart may be required) =====" +ss -lnt | grep -E ':2079\b|:2179\b|:2077\b|:2177\b' || true +docker ps --format '{{.Names}} {{.Ports}}' | grep -iE 'baecb693|030d21e5' || true +echo +echo "Test from outside:" +echo " curl -s http://88.214.59.166:2079/status" +echo " curl -s http://88.214.59.166:2179/status" +echo +echo "Restart each RedSync server in the panel (or docker restart UUID) so the" +echo "new build binds status on listenPort+2 and Wings republishes ports." diff --git a/release/ops/restart-with-status-ports.sh b/release/ops/restart-with-status-ports.sh new file mode 100755 index 00000000..f782d8de --- /dev/null +++ b/release/ops/restart-with-status-ports.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Force Wings to recreate Official + BETA so additional status ports publish. +set -euo pipefail + +PANEL_PHP=/var/www/pelican/storage/app/restart-status-ports.php +cat > "$PANEL_PHP" <<'PHP' +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +$uuids = [ + 'baecb693-decc-479e-8f3d-fb9e6aa1cbc9', + '030d21e5-0dab-4f11-8a37-ea4b67eb5008', +]; + +$repoClass = class_exists('App\\Repositories\\Daemon\\DaemonServerRepository') + ? 'App\\Repositories\\Daemon\\DaemonServerRepository' + : 'App\\Repositories\\Wings\\DaemonServerRepository'; + +foreach ($uuids as $uuid) { + $server = App\Models\Server::where('uuid', $uuid)->first(); + if (!$server) { + echo "missing $uuid\n"; + continue; + } + echo "=== {$server->name} ($uuid) ===\n"; + foreach ($server->allocations as $a) { + echo " alloc {$a->ip}:{$a->port}\n"; + } + $repo = app($repoClass)->setServer($server->fresh()); + try { + if (method_exists($repo, 'sync')) { + $repo->sync(); + echo "sync ok\n"; + } + } catch (Throwable $e) { + echo "sync warn: {$e->getMessage()}\n"; + } + try { + // Full recreate so Docker publishes new allocations + if (method_exists($repo, 'delete')) { + try { + $repo->delete(); + echo "delete ok\n"; + } catch (Throwable $e) { + echo "delete note: {$e->getMessage()}\n"; + } + } + $repo = app($repoClass)->setServer($server->fresh()); + if (method_exists($repo, 'create')) { + $repo->create(true); + echo "create(start) ok\n"; + } elseif (method_exists($repo, 'power')) { + $repo->power('restart'); + echo "power(restart) ok\n"; + } + } catch (Throwable $e) { + echo "recreate warn: {$e->getMessage()}\n"; + try { + app($repoClass)->setServer($server->fresh())->power('restart'); + echo "power(restart) fallback ok\n"; + } catch (Throwable $e2) { + echo "FAIL: {$e2->getMessage()}\n"; + } + } +} +PHP + +chown www-data:www-data "$PANEL_PHP" +chmod 644 "$PANEL_PHP" +sudo -u www-data php "$PANEL_PHP" +rm -f "$PANEL_PHP" + +echo +echo "Waiting 8s for containers..." +sleep 8 +echo "===== docker ports =====" +docker ps --format '{{.Names}} {{.Ports}}' | grep -iE 'baecb693|030d21e5' || true +echo +echo "===== host listeners =====" +ss -lnt | grep -E ':2077\b|:2079\b|:2177\b|:2179\b' || true +echo +echo "===== local status probes =====" +curl -sS --max-time 3 http://127.0.0.1:2079/status || echo "2079 FAIL" +echo +curl -sS --max-time 3 http://127.0.0.1:2179/status || echo "2179 FAIL" +echo diff --git a/release/ops/show-admin-secrets.sh b/release/ops/show-admin-secrets.sh new file mode 100644 index 00000000..ead3d2a9 --- /dev/null +++ b/release/ops/show-admin-secrets.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Show whether XEROX710 is allowlisted and print join secrets for Official + BETA. +set -euo pipefail + +for uuid_name in \ + "baecb693-decc-479e-8f3d-fb9e6aa1cbc9:Official" \ + "030d21e5-0dab-4f11-8a37-ea4b67eb5008:BETA" +do + uuid="${uuid_name%%:*}" + name="${uuid_name##*:}" + vol="/var/lib/pelican/volumes/$uuid" + echo "===== $name ($uuid) =====" + if [[ ! -d "$vol" ]]; then + echo "missing volume" + continue + fi + echo "-- admins.json --" + cat "$vol/admins.json" 2>/dev/null || echo "(none)" + echo + echo "-- admin-secrets.txt --" + if [[ -f "$vol/admin-secrets.txt" ]]; then + # show names + secrets (operator asked) + cat "$vol/admin-secrets.txt" + else + echo "(no admin-secrets.txt)" + fi + echo + echo "-- recent auth lines --" + docker logs --tail 80 "$uuid" 2>&1 | grep -iE 'admin|secret|Rejected|XEROX|authenticated|login' | tail -20 || true + echo +done + +echo "Launcher tip: put the BETA secret in the SECRET box (or admin.secret as name~secret / admin.secret file)." +echo "Reserved name XEROX is treated as XEROX710." diff --git a/release/ops/start-pelican-beta.sh b/release/ops/start-pelican-beta.sh new file mode 100755 index 00000000..ca31268d --- /dev/null +++ b/release/ops/start-pelican-beta.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Start RedSync BETA (Pelican uuid) via DaemonServerRepository::power('start'). +set -euo pipefail +BETA_UUID="${1:-030d21e5-0dab-4f11-8a37-ea4b67eb5008}" +PANEL_PHP=/var/www/pelican/storage/app/start-redsync-beta.php + +log() { printf '[start-beta] %s\n' "$*"; } + +cat > "$PANEL_PHP" <<'PHP' +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +$uuid = getenv('BETA_UUID') ?: '030d21e5-0dab-4f11-8a37-ea4b67eb5008'; +$server = App\Models\Server::query()->where('uuid', $uuid)->first(); +$server || throw new RuntimeException("server not found: $uuid"); + +echo "server #{$server->id} {$server->name} status=" . var_export($server->status, true) . "\n"; + +try { + $server->status = null; + $server->skip_scripts = true; + $server->save(); +} catch (Throwable $e) { + echo "status warn: {$e->getMessage()}\n"; +} + +$fqcn = null; +foreach ([ + 'App\\Repositories\\Daemon\\DaemonServerRepository', + 'App\\Repositories\\Wings\\DaemonServerRepository', +] as $c) { + if (class_exists($c)) { $fqcn = $c; break; } +} +$fqcn || throw new RuntimeException('DaemonServerRepository not found'); + +$repo = app($fqcn)->setServer($server->fresh()); +echo "using $fqcn\n"; + +try { + if (method_exists($repo, 'sync')) { + $repo->sync(); + echo "sync ok\n"; + } +} catch (Throwable $e) { + echo "sync warn: {$e->getMessage()}\n"; +} + +try { + if (method_exists($repo, 'create')) { + // Ensure Wings has the container (idempotent-ish; may 409 if exists) + try { $repo->create(false); echo "create ok\n"; } + catch (Throwable $e) { echo "create note: {$e->getMessage()}\n"; } + } +} catch (Throwable $e) { + echo "create warn: {$e->getMessage()}\n"; +} + +$repo = app($fqcn)->setServer($server->fresh()); +if (method_exists($repo, 'power')) { + $repo->power('start'); + echo "power(start) ok\n"; +} elseif (method_exists($repo, 'send')) { + $repo->send('start'); + echo "send(start) ok\n"; +} else { + throw new RuntimeException('no power/send on repository'); +} +PHP + +chown www-data:www-data "$PANEL_PHP" +chmod 644 "$PANEL_PHP" +log "starting $BETA_UUID" +sudo -u www-data -E env BETA_UUID="$BETA_UUID" php "$PANEL_PHP" +rm -f "$PANEL_PHP" + +echo +log "===== verify =====" +sleep 3 +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}' | head -1 +docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}' | grep -iE '030d21e5|2177|baecb693|redsync' || true +ss -lntu 2>/dev/null | grep -E ':2177\b|:2077\b' || true +log "Panel: https://panel.xbuniverse.duckdns.org → RedSync BETA" +log "Connect: 88.214.59.166:2177" diff --git a/release/server/RedSync.Server.dll b/release/server/RedSync.Server.dll index 2a37cf76..d737ae79 100644 Binary files a/release/server/RedSync.Server.dll and b/release/server/RedSync.Server.dll differ diff --git a/server/Managed/GameServer.cs b/server/Managed/GameServer.cs index 02fc6da0..f9a98b4d 100644 --- a/server/Managed/GameServer.cs +++ b/server/Managed/GameServer.cs @@ -23,16 +23,36 @@ public class GameServer: NativeGameServer public readonly ServerSelfHealService SelfHeal; public readonly ServerValidator Security; + /// Hard join cap (default 2000). Override with REDSYNC_MAX_PLAYERS. + public int MaxPlayers { get; } + + /// UDP/TCP game listen port (also used to derive public status port). + public ushort ListenPort { get; } + /// Set by the process console `stop` command to exit the main loop cleanly. public volatile bool RequestShutdown; public GameServer(ushort listeningPort) : base(listeningPort) { + ListenPort = listeningPort; + MaxPlayers = ResolveMaxPlayers(); EntityTracker = new EntityTracker(this); EntityService = new EntityService(); NpcEvents = new NpcEventService(this); SelfHeal = new ServerSelfHealService(this); Security = new ServerValidator(this); + Logger.Info("Max players: {0}", MaxPlayers); + } + + private static int ResolveMaxPlayers() + { + var raw = Environment.GetEnvironmentVariable("REDSYNC_MAX_PLAYERS")?.Trim(); + if (int.TryParse(raw, out var n) && n > 0) + { + return Math.Min(n, 65535); + } + + return 2000; } /// diff --git a/server/Managed/PacketHandling/AuthPacketHandler.cs b/server/Managed/PacketHandling/AuthPacketHandler.cs index e9557471..e50fb57e 100644 --- a/server/Managed/PacketHandling/AuthPacketHandler.cs +++ b/server/Managed/PacketHandling/AuthPacketHandler.cs @@ -25,6 +25,7 @@ public static class RejectReason public const byte Banned = 3; public const byte EmptyName = 4; public const byte AdminSecretMissing = 5; + public const byte ServerFull = 6; } public AuthPacketHandler() @@ -80,7 +81,17 @@ protected virtual void HandleInitAuth(GameServer server, EMessageTypeServerbound server.TryCloseConnection(stale.Key); } - if (server.AdminService.IsAdmin(username)) + // Capacity check after reclaiming stale slots so reconnects are not blocked. + var online = _players!.ConnectedPlayers.Count; + if (online >= server.MaxPlayers) + { + resultPacket.auth_result = EAuthResult.ValidationFailed; + resultPacket.is_admin = RejectReason.ServerFull; + Logger.Warn( + "Rejected {0}: server full ({1}/{2})", + username, online, server.MaxPlayers); + } + else if (server.AdminService.IsAdmin(username)) { // Reserved admin name: require join secret so random players cannot claim F7/admin. if (!server.AdminService.RequiresSecret(username)) @@ -111,8 +122,8 @@ protected virtual void HandleInitAuth(GameServer server, EMessageTypeServerbound ConnectionId = connectionId, IsAdmin = true }); - Logger.Info("Admin {0} authenticated (connection {1}). Total connected: {2}", - username, connectionId, _players.ConnectedPlayers.Count); + Logger.Info("Admin {0} authenticated (connection {1}). Total connected: {2}/{3}", + username, connectionId, _players.ConnectedPlayers.Count, server.MaxPlayers); } } else @@ -131,8 +142,8 @@ protected virtual void HandleInitAuth(GameServer server, EMessageTypeServerbound ConnectionId = connectionId, IsAdmin = false }); - Logger.Info("Player {0} authenticated (connection {1}, admin=False). Total connected: {2}", - username, connectionId, _players.ConnectedPlayers.Count); + Logger.Info("Player {0} authenticated (connection {1}, admin=False). Total connected: {2}/{3}", + username, connectionId, _players.ConnectedPlayers.Count, server.MaxPlayers); } } diff --git a/server/Managed/Services/HealthHttpService.cs b/server/Managed/Services/HealthHttpService.cs index 2a0e039d..001bc7eb 100644 --- a/server/Managed/Services/HealthHttpService.cs +++ b/server/Managed/Services/HealthHttpService.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using System.Text; using System.Text.Json; using NLog; @@ -6,15 +7,16 @@ namespace RedSync.Server.Services; /// -/// Tiny HTTP health endpoint for the 24/7 watchdog / load balancers. -/// Default bind: 127.0.0.1:2079 (override with REDSYNC_HEALTH_PORT / REDSYNC_HEALTH_BIND). +/// Tiny status/health HTTP for watchdog + launcher player counts. +/// Uses TcpListener (IPAddress.Any) so Docker port-publishes work without HttpListener quirks. +/// Default port = listenPort+2 (2077→2079, 2177→2179). Override: REDSYNC_HEALTH_PORT / REDSYNC_HEALTH_BIND. /// public sealed class HealthHttpService : IDisposable { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private readonly GameServer _server; - private readonly HttpListener _listener = new(); private readonly CancellationTokenSource _cts = new(); + private TcpListener? _listener; private Thread? _thread; private long _okResponses; private DateTime _startedUtc = DateTime.UtcNow; @@ -26,27 +28,36 @@ public HealthHttpService(GameServer server) public void Start() { - var bind = Environment.GetEnvironmentVariable("REDSYNC_HEALTH_BIND")?.Trim(); - if (string.IsNullOrWhiteSpace(bind)) + var bindRaw = Environment.GetEnvironmentVariable("REDSYNC_HEALTH_BIND")?.Trim(); + IPAddress bindIp = IPAddress.Any; + if (!string.IsNullOrWhiteSpace(bindRaw) + && !bindRaw.Equals("*", StringComparison.Ordinal) + && !bindRaw.Equals("+", StringComparison.Ordinal) + && !bindRaw.Equals("0.0.0.0", StringComparison.Ordinal)) { - bind = "127.0.0.1"; + if (!IPAddress.TryParse(bindRaw, out bindIp!)) + { + bindIp = IPAddress.Any; + } } var portRaw = Environment.GetEnvironmentVariable("REDSYNC_HEALTH_PORT")?.Trim(); if (!ushort.TryParse(portRaw, out var port) || port == 0) { - port = 2079; + var derived = _server.ListenPort + 2; + port = derived is > 0 and <= 65535 ? (ushort)derived : (ushort)2079; } - var prefix = $"http://{bind}:{port}/"; try { - _listener.Prefixes.Add(prefix); + _listener = new TcpListener(bindIp, port); + _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Start(); } catch (Exception ex) { - Logger.Warn(ex, "Health HTTP endpoint disabled (could not bind {0})", prefix); + Logger.Warn(ex, "Health/status HTTP disabled (could not bind {0}:{1})", bindIp, port); + _listener = null; return; } @@ -57,7 +68,9 @@ public void Start() Name = "RedSyncHealthHttp" }; _thread.Start(); - Logger.Info("Health HTTP listening on {0}health", prefix); + Logger.Info( + "Health/status HTTP listening on {0}:{1} (maxPlayers={2})", + bindIp, port, _server.MaxPlayers); } public void Dispose() @@ -65,12 +78,7 @@ public void Dispose() try { _cts.Cancel(); - if (_listener.IsListening) - { - _listener.Stop(); - } - - _listener.Close(); + _listener?.Stop(); } catch { @@ -80,104 +88,170 @@ public void Dispose() private void ListenLoop() { + var listener = _listener; + if (listener == null) + { + return; + } + while (!_cts.IsCancellationRequested) { - HttpListenerContext? ctx = null; try { - ctx = _listener.GetContext(); + var client = listener.AcceptTcpClient(); + _ = Task.Run(() => HandleClient(client)); } - catch (HttpListenerException) + catch (ObjectDisposedException) + { + break; + } + catch (SocketException) { if (_cts.IsCancellationRequested) { break; } - - continue; - } - catch (ObjectDisposedException) - { - break; } - - if (ctx == null) + catch (Exception ex) { - continue; + Logger.Warn(ex, "Health accept failed"); } + } + } + private void HandleClient(TcpClient client) + { + using (client) + { try { - Handle(ctx); - } - catch (Exception ex) - { - Logger.Warn(ex, "Health HTTP request failed"); - try + client.ReceiveTimeout = 3000; + client.SendTimeout = 3000; + using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true); + var requestLine = reader.ReadLine() ?? ""; + // Drain headers + while (true) + { + var line = reader.ReadLine(); + if (string.IsNullOrEmpty(line)) + { + break; + } + } + + var path = "/"; + var parts = requestLine.Split(' '); + if (parts.Length >= 2) { - ctx.Response.StatusCode = 500; - ctx.Response.Close(); + path = parts[1]; + var q = path.IndexOf('?', StringComparison.Ordinal); + if (q >= 0) + { + path = path[..q]; + } + + path = path.TrimEnd('/'); + if (path.Length == 0) + { + path = "/"; + } + } + + int status; + Dictionary payload; + if (path is "/status" or "/players") + { + status = 200; + payload = BuildStatus(); + } + else if (path is "/health" or "/healthz" or "/") + { + status = 200; + payload = BuildHealth(ok: true); + } + else if (path is "/ready" or "/readyz") + { + var ok = !_server.RequestShutdown; + status = ok ? 200 : 503; + payload = BuildHealth(ok); } - catch + else { - // ignore + status = 404; + payload = new Dictionary { ["error"] = "not found" }; } + + WriteResponse(stream, status, payload); + } + catch (Exception ex) + { + Logger.Warn(ex, "Health request failed"); } } } - private void Handle(HttpListenerContext ctx) + private Dictionary BuildStatus() { - var path = ctx.Request.Url?.AbsolutePath?.TrimEnd('/') ?? ""; - if (path.Length == 0) - { - path = "/"; - } - - if (path is "/health" or "/healthz" or "/") - { - WriteHealth(ctx, ok: true); - return; - } - - if (path is "/ready" or "/readyz") + Interlocked.Increment(ref _okResponses); + var players = _server.PlayerService.ConnectedPlayers.Count; + var max = _server.MaxPlayers; + return new Dictionary { - // Ready when the process is up and console loop is alive. - WriteHealth(ctx, ok: !_server.RequestShutdown); - return; - } - - ctx.Response.StatusCode = 404; - var bytes = Encoding.UTF8.GetBytes("{\"error\":\"not found\"}"); - ctx.Response.ContentType = "application/json"; - ctx.Response.OutputStream.Write(bytes); - ctx.Response.Close(); + ["ok"] = true, + ["service"] = "RedSync.Server", + ["players"] = players, + ["maxPlayers"] = max, + ["full"] = players >= max, + ["port"] = _server.ListenPort, + ["uptimeSec"] = (int)(DateTime.UtcNow - _startedUtc).TotalSeconds, + }; } - private void WriteHealth(HttpListenerContext ctx, bool ok) + private Dictionary BuildHealth(bool ok) { Interlocked.Increment(ref _okResponses); var snapshot = _server.SelfHeal.BuildHealthSnapshot(); - var payload = new Dictionary + var players = _server.PlayerService.ConnectedPlayers.Count; + return new Dictionary { ["ok"] = ok, ["service"] = "RedSync.Server", ["protocol"] = GameServer.PROTOCOL_VERSION_CURRENT, ["uptimeSec"] = (int)(DateTime.UtcNow - _startedUtc).TotalSeconds, ["health"] = snapshot, - ["players"] = _server.PlayerService.ConnectedPlayers.Count, + ["players"] = players, + ["maxPlayers"] = _server.MaxPlayers, + ["full"] = players >= _server.MaxPlayers, + ["port"] = _server.ListenPort, ["entities"] = _server.EntityService.SpawnedEntities.Count, ["event"] = _server.NpcEvents.ActiveEventName ?? "", ["admins"] = _server.AdminService.List().Count, ["checks"] = _okResponses }; + } + private static void WriteResponse(NetworkStream stream, int status, Dictionary payload) + { + var reason = status switch + { + 200 => "OK", + 503 => "Service Unavailable", + 404 => "Not Found", + _ => "Error" + }; var json = JsonSerializer.Serialize(payload); - var bytes = Encoding.UTF8.GetBytes(json); - ctx.Response.StatusCode = ok ? 200 : 503; - ctx.Response.ContentType = "application/json; charset=utf-8"; - ctx.Response.ContentLength64 = bytes.Length; - ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); - ctx.Response.Close(); + var body = Encoding.UTF8.GetBytes(json); + var header = + $"HTTP/1.1 {status} {reason}\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "Connection: close\r\n" + + $"Content-Length: {body.Length}\r\n" + + "\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + stream.Write(headerBytes, 0, headerBytes.Length); + stream.Write(body, 0, body.Length); + stream.Flush(); } }