From 1daaf4af78154a4d0e9b75ba43411d20fbab9ac1 Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 9 Jul 2026 20:45:33 -0500 Subject: [PATCH] =?UTF-8?q?vPOD:=20virtual=20launcher=20=E2=80=94=20test?= =?UTF-8?q?=20Site=20Management=20/=20product=20deploys=20without=20a=20co?= =?UTF-8?q?ckpit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vPOD now also impersonates the pod's TeslaLauncher service, so the console's Manage Site works against it unmodified: - LauncherRpcServer: ILauncherService over OFB + framed JSON on TCP 53290, mirroring TeslaLauncherService (concurrent sessions, out-of-band install zip on a second connection, the 99%-not-100 completion convention). Packages extract to %LocalAppData%\vPOD\Games; postinstall.bat is logged but never executed. - PodProvisioning: pod side of SecureConfig (RQST beacon, RPLY decrypt, RSA session-key exchange), display-only — never touches the NIC/registry. The console's Configure flow mints the key exactly as for a real pod; console Reconfigure (ClearStore) drops the key and re-enters beacon mode. - VirtualLauncher: installed-app registry (persisted), simulated launch PIDs, volume, install progress; console Shutdown/Restart power-cycles the pod. - Form gets a Launcher/Site Management column (passcode display, RPC status, install progress, app list, Reprovision); Power Off darkens the launcher side too; new -nomanage flag disables it. vPOD references the shared Tesla.Contract/Tesla.SecureConfig projects (server side of the existing contract only, no new RPCs). Loopback tests drive the real PodManagerConnection and PodConfigurationServer against the new code (VPodLauncherServerTests, VPodProvisioningTests) — suite now 99 green. Co-Authored-By: Claude Fable 5 --- .../TeslaConsole.DiffTests.csproj | 5 + .../VPodLauncherServerTests.cs | 268 ++++++++++++ .../VPodProvisioningTests.cs | 83 ++++ Console/vPOD/LauncherRpcServer.cs | 402 ++++++++++++++++++ Console/vPOD/PodArguments.cs | 9 + Console/vPOD/PodProvisioning.cs | 378 ++++++++++++++++ Console/vPOD/README.md | 80 +++- Console/vPOD/VPodForm.cs | 383 ++++++++++++++++- Console/vPOD/VirtualLauncher.cs | 398 +++++++++++++++++ Console/vPOD/vPOD.csproj | 22 + 10 files changed, 2024 insertions(+), 4 deletions(-) create mode 100644 Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs create mode 100644 Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs create mode 100644 Console/vPOD/LauncherRpcServer.cs create mode 100644 Console/vPOD/PodProvisioning.cs create mode 100644 Console/vPOD/VirtualLauncher.cs diff --git a/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj b/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj index b32b04a..ee40fc5 100644 --- a/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj +++ b/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj @@ -29,6 +29,8 @@ + + @@ -53,6 +55,9 @@ + + diff --git a/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs new file mode 100644 index 0000000..db42df7 --- /dev/null +++ b/Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs @@ -0,0 +1,268 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using Tesla.Net; +using VPod; +using Xunit; + +namespace TeslaConsole.DiffTests +{ + /// + /// End-to-end loopback exercise of vPOD's virtual launcher: the REAL + /// console-side client (PodManagerConnection) against vPOD's LauncherRpcServer + /// on an ephemeral port. Covers the OFB/CONF handshake, the ILauncherService + /// dispatch surface, and the out-of-band InstallProduct transfer — including + /// the 99%-complete convention the console's install retry loop depends on. + /// This validates both ends: the client the console ships and the server vPOD + /// uses to stand in for a pod's TeslaLauncher service. + /// + public class VPodLauncherServerTests : IDisposable + { + private readonly string mDataDir; + private readonly VirtualLauncher mLauncher; + private readonly LauncherRpcServer mServer; + private readonly PodManagerConnection mClient; + private readonly byte[] mKey; + private readonly int mPort; + + public VPodLauncherServerTests() + { + mDataDir = Path.Combine(Path.GetTempPath(), "vpod-test-" + Guid.NewGuid().ToString("N")); + mLauncher = new VirtualLauncher(mDataDir); + mKey = new byte[32]; + new Random(1234).NextBytes(mKey); + mPort = GetFreePort(); + mServer = new LauncherRpcServer(mLauncher, mPort); + mServer.Start(mKey); + mClient = new PodManagerConnection(); + mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey); + } + + public void Dispose() + { + try { mClient.Close(); } catch { } + mServer.Stop(); + try { Directory.Delete(mDataDir, recursive: true); } catch { } + } + + [Fact] + public void Handshake_And_Ping_RoundTrip() + { + Assert.True(mClient.IsOpen); + var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc); + Assert.Equal(now, mClient.Ping(now)); + } + + [Fact] + public void Handshake_With_Wrong_Key_Is_Rejected() + { + var wrongKey = new byte[32]; + new Random(9999).NextBytes(wrongKey); + using (var badClient = new PodManagerConnection()) + { + Assert.Throws( + () => badClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), wrongKey)); + } + } + + [Fact] + public void InstallApp_Appears_In_GetInstalledApps_And_FullUpdate() + { + var app = SampleApp("Red Planet 4.11"); + mClient.InstallApp(app); + + var installed = mClient.GetInstalledApps(); + Assert.Single(installed); + Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey); + Assert.Equal(app.ExeFile, installed[0].ExeFile); + Assert.Equal(app.Arguments, installed[0].Arguments); + Assert.True(installed[0].AutoRestart); + + var launchable = mClient.GetLaunchableApps(); + Assert.Single(launchable); + Assert.Equal("Red Planet 4.11", launchable[0].DisplayName); + + var full = mClient.FullUpdate(); + Assert.Single(full.InstalledApps); + Assert.Empty(full.LaunchedApps); + } + + [Fact] + public void LaunchApp_Simulates_Pids_And_Kill_Removes_Them() + { + var app = SampleApp("BattleTech 4.11"); + mClient.InstallApp(app); + + int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey); + Assert.True(pid > 0); + var launched = mClient.GetLaunchedApps(); + Assert.Single(launched); + Assert.Equal(pid, launched[0].ProcessId); + Assert.Equal(app.LaunchPair.LaunchKey, launched[0].LaunchKey); + + mClient.KillApp(app.LaunchPair.LaunchKey, pid); + Assert.Empty(mClient.GetLaunchedApps()); + + mClient.LaunchApp(app.LaunchPair.LaunchKey); + mClient.LaunchApp(app.LaunchPair.LaunchKey); + mClient.KillAllApps(); + Assert.Empty(mClient.GetLaunchedApps()); + } + + [Fact] + public void VolumeLevel_RoundTrips() + { + mClient.VolumeLevel = 0.25f; + Assert.Equal(0.25f, mClient.VolumeLevel); + Assert.Equal(0.25f, mClient.FullUpdate().VolumeLevel); + } + + [Fact] + public void InstallProduct_Streams_Extracts_And_Completes_At_99() + { + string zipPath = MakeTestZip("TestGame", "readme.txt", "hello vpod"); + + Guid callId = mClient.InstallProduct(zipPath); + Assert.NotEqual(Guid.Empty, callId); + + var progress = PollUntilCompleted(callId); + // 99, not 100: the console's InstallProductWorker only breaks its + // 3-attempt retry loop on exactly 99. + Assert.Equal(99, progress.PercentComplete); + Assert.Equal("Complete", progress.Status); + + string extracted = Path.Combine(mDataDir, "Games", "TestGame", "readme.txt"); + Assert.True(File.Exists(extracted), "expected extracted file at " + extracted); + Assert.Equal("hello vpod", File.ReadAllText(extracted)); + + // The main connection survived the concurrent out-of-band transfer. + Assert.True(mClient.IsOpen); + var now = DateTime.UtcNow; + mClient.Ping(now); + } + + [Fact] + public void UninstallApp_Removes_Registration_And_Product_Directory() + { + string zipPath = MakeTestZip("TestGame", "game.exe", "not really an exe"); + Guid callId = mClient.InstallProduct(zipPath); + PollUntilCompleted(callId); + + string productDir = Path.Combine(mDataDir, "Games", "TestGame"); + var app = new LaunchData + { + LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Test Game" }, + WorkingDirectory = productDir, + ExeFile = Path.Combine(productDir, "game.exe"), + Arguments = "", + AutoRestart = false + }; + mClient.InstallApp(app); + Assert.Single(mClient.GetInstalledApps()); + Assert.True(Directory.Exists(productDir)); + + mClient.UninstallApp(app.LaunchPair.LaunchKey); + Assert.Empty(mClient.GetInstalledApps()); + Assert.False(Directory.Exists(productDir), "product directory should be cleaned up"); + } + + [Fact] + public void Shutdown_Raises_ShutdownRequested() + { + bool? restartArg = null; + using (var signalled = new ManualResetEventSlim()) + { + mLauncher.ShutdownRequested += restart => + { + restartArg = restart; + signalled.Set(); + }; + mClient.Shutdown(doRestart: true); + Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ShutdownRequested was not raised"); + Assert.True(restartArg); + } + } + + [Fact] + public void ClearStore_Wipes_Apps_And_Requests_Reprovisioning() + { + mClient.InstallApp(SampleApp("Doomed App")); + using (var signalled = new ManualResetEventSlim()) + { + mLauncher.ReprovisionRequested += signalled.Set; + mClient.ClearStore(); // one-way: no response frame + Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ReprovisionRequested was not raised"); + } + Assert.Empty(mLauncher.GetInstalledApps()); + } + + [Fact] + public void Installed_Apps_Persist_Across_Launcher_Restart() + { + var app = SampleApp("Persistent App"); + mClient.InstallApp(app); + + var reloaded = new VirtualLauncher(mDataDir); + var installed = reloaded.GetInstalledApps(); + Assert.Single(installed); + Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey); + Assert.Equal("Persistent App", installed[0].LaunchPair.DisplayName); + } + + // ---- helpers ---- + + private static LaunchData SampleApp(string name) + { + return new LaunchData + { + LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = name }, + WorkingDirectory = @"C:\Games\Sample", + ExeFile = @"C:\Games\Sample\game.exe", + Arguments = "-net 1501 -res 1920 1080", + AutoRestart = true + }; + } + + private string MakeTestZip(string folder, string fileName, string content) + { + string zipPath = Path.Combine(mDataDir, "product-" + Guid.NewGuid().ToString("N") + ".zip"); + using (var fs = File.Create(zipPath)) + using (var zip = new ZipArchive(fs, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry(folder + "/" + fileName); + using (var writer = new StreamWriter(entry.Open())) + { + writer.Write(content); + } + } + return zipPath; + } + + private OutOfBandProgress PollUntilCompleted(Guid callId) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + while (DateTime.UtcNow < deadline) + { + var progress = mClient.GetOutOfBandProgress(callId); + if (progress.IsCompleted) + { + return progress; + } + Thread.Sleep(50); + } + throw new TimeoutException("Install did not complete in time."); + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + } +} diff --git a/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs b/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs new file mode 100644 index 0000000..799b119 --- /dev/null +++ b/Console/tests/TeslaConsole.DiffTests/VPodProvisioningTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Net; +using System.Threading; +using Tesla; +using VPod; +using Xunit; + +namespace TeslaConsole.DiffTests +{ + /// + /// End-to-end loopback exercise of vPOD's pod-side SecureConfig provisioning + /// against the REAL console-side implementation (Tesla.PodConfigurationServer, + /// the exact code the console runs behind Manage Site's "Configure" button): + /// RQST beacon reception, the AES "RPLY" config broadcast, and the OFB+RSA + /// session-key exchange must all interoperate. + /// + /// Uses the protocol's fixed ports (UDP 53291/53292, TCP 53292) and real UDP + /// broadcasts on loopback — do not run while a console or another vPOD is + /// provisioning on this machine. + /// + public class VPodProvisioningTests + { + [Fact] + public void Console_Provisions_VPod_And_Both_Hold_The_Same_Session_Key() + { + byte[] mac = PodProvisioning.MacForHost(7); + var provisioning = new PodProvisioning(mac); + + byte[] beaconMac = null; + string beaconRequestId = null; + using (var beaconSeen = new ManualResetEventSlim()) + using (var podProvisioned = new ManualResetEventSlim()) + { + byte[] podKey = null; + provisioning.ConfigReceived += _ => { }; + provisioning.Provisioned += key => + { + podKey = key; + podProvisioned.Set(); + }; + + // The console side: listens on UDP 53291 for RQST beacons; the + // delegate fires on vPOD's first beacon (sent at Start). + var consoleServer = new PodConfigurationServer(53291, 53292, (m, id) => + { + beaconMac = m; + beaconRequestId = id; + beaconSeen.Set(); + }); + + try + { + provisioning.Start(); + Assert.True(beaconSeen.Wait(TimeSpan.FromSeconds(15)), + "console never received vPOD's RQST beacon"); + Assert.Equal(mac, beaconMac); + Assert.Equal(provisioning.RequestId, beaconRequestId); + + // The console side of Configure: broadcast the AES-encrypted + // network config and run the RSA key exchange against the pod. + byte[] consoleKey = consoleServer.SendEncryptionKey( + provisioning.Passphrase, + IPAddress.Loopback, + IPAddress.Parse("255.255.255.0"), + IPAddress.Any, + IPAddress.Any, + "vpod-test", + TimeSpan.FromSeconds(30)); + + Assert.True(podProvisioned.Wait(TimeSpan.FromSeconds(15)), + "vPOD never completed provisioning"); + Assert.NotNull(consoleKey); + Assert.Equal(32, consoleKey.Length); + Assert.Equal(consoleKey, podKey); + } + finally + { + provisioning.Stop(); + } + } + } + } +} diff --git a/Console/vPOD/LauncherRpcServer.cs b/Console/vPOD/LauncherRpcServer.cs new file mode 100644 index 0000000..362b7e6 --- /dev/null +++ b/Console/vPOD/LauncherRpcServer.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading; +using Tesla; +using Tesla.Net; + +namespace VPod; + +/// +/// vPOD's stand-in for the pod's TeslaLauncher service: the OFB-encrypted, +/// framed-JSON ILauncherService RPC server on TCP 53290 that the console's +/// Site Management / SitePanel talk to (client: PodManagerConnection). +/// Mirrors Launcher/TeslaLauncherService.HandleConsoleClient: +/// +/// - OFB/CONF handshake on the provisioned 32-byte session key +/// (NegotiateCryptoStreams is symmetric, so the shared implementation +/// serves the pod side too). +/// - Loop: PodRpc.ReadRequest -> dispatch by method name -> WriteResponse. +/// - After answering InitiateInstallProduct, the same connection carries the +/// product zip out-of-band ([8-byte Int64 size][raw bytes]) and then closes; +/// the console polls GetOutOfBandProgress on its main connection meanwhile, +/// so multiple concurrent client connections are required. +/// - Install completion reports 99% (not 100) — the console's +/// InstallProductWorker breaks its retry loop only on 99. +/// +/// All state lives in ; unlike the real service, +/// postinstall.bat from a package is logged but never executed. +/// +internal sealed class LauncherRpcServer +{ + public const int ManagePort = 53290; + + private readonly VirtualLauncher mLauncher; + private readonly int mPort; + private byte[] mSessionKey; + private TcpListener mListener; + private Thread mAcceptThread; + private volatile bool mRunning; + + private readonly object mClientsLock = new object(); + private readonly List mClients = new List(); + + public event Action Log; + public event Action ConnectionsChanged; // number of active console sessions + + public bool IsListening => mRunning; + + public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort) + { + mLauncher = launcher; + mPort = port; + } + + /// Starts listening with the given provisioned session key. Throws if + /// the port cannot be bound (e.g. a real TeslaLauncher on the same machine). + public void Start(byte[] sessionKey) + { + if (mRunning) + { + return; + } + mSessionKey = sessionKey; + mListener = new TcpListener(IPAddress.Any, mPort); + mListener.Start(); + mRunning = true; + mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-launcher-accept" }; + mAcceptThread.Start(); + Log?.Invoke($"Launcher RPC listening on TCP {mPort}."); + } + + public void Stop() + { + if (!mRunning) + { + return; + } + mRunning = false; + try { mListener?.Stop(); } catch { } + lock (mClientsLock) + { + foreach (TcpClient client in mClients) + { + try { client.Close(); } catch { } + } + mClients.Clear(); + } + ConnectionsChanged?.Invoke(0); + Log?.Invoke("Launcher RPC stopped."); + } + + private void AcceptLoop() + { + while (mRunning) + { + TcpClient client; + try + { + client = mListener.AcceptTcpClient(); + } + catch + { + break; // listener stopped + } + // Unlike the Munga side, the console legitimately opens several + // concurrent connections (main session + out-of-band installs). + Thread worker = new Thread(() => HandleClient(client)) { IsBackground = true, Name = "vPOD-launcher-session" }; + worker.Start(); + } + } + + private void HandleClient(TcpClient client) + { + string remote; + try + { + remote = client.Client.RemoteEndPoint?.ToString() ?? "?"; + } + catch + { + remote = "?"; + } + lock (mClientsLock) + { + mClients.Add(client); + ConnectionsChanged?.Invoke(mClients.Count); + } + try + { + using (client) + { + NetworkStream netStream = client.GetStream(); + // Same session timeouts as the real service: an idle console + // connection is dropped after 30 s and the console reconnects. + netStream.WriteTimeout = 10000; + netStream.ReadTimeout = 30000; + + if (!PodConfigurationServer.NegotiateCryptoStreams(netStream, mSessionKey, out Stream outStream, out Stream inStream)) + { + Log?.Invoke($"{remote}: CONF mismatch — session key mismatch, dropping connection."); + return; + } + Log?.Invoke($"Console session started from {remote}."); + SessionLoop(remote, inStream, outStream); + } + } + catch (Exception ex) + { + Log?.Invoke($"{remote}: session error: {ex.Message}"); + } + finally + { + lock (mClientsLock) + { + mClients.Remove(client); + ConnectionsChanged?.Invoke(mClients.Count); + } + Log?.Invoke($"Console session from {remote} ended."); + } + } + + private void SessionLoop(string remote, Stream inStream, Stream outStream) + { + while (mRunning) + { + RpcRequest request; + try + { + request = PodRpc.ReadRequest(inStream); + } + catch + { + return; // disconnected (EOF/IO/timeout) + } + if (request == null) + { + return; + } + + string method = request.Method ?? "???"; + IReadOnlyList args = request.Args ?? new List(); + // GetOutOfBandProgress is polled 4x/second during installs — don't log it. + if (method != "GetOutOfBandProgress" && method != "Ping") + { + Log?.Invoke($"<- {method}"); + } + + object result = null; + string error = null; + try + { + result = Dispatch(method, args); + } + catch (Exception ex) + { + error = ex.Message; + Log?.Invoke($"{method} ERROR: {ex.Message}"); + } + + try + { + PodRpc.WriteResponse(outStream, result, error); + } + catch + { + return; // disconnected while writing + } + + // The product zip follows the InitiateInstallProduct response on this + // same connection, then the console closes it. + if (method == "InitiateInstallProduct" && error == null && result is Guid installGuid) + { + ReceiveInstallFile(inStream, installGuid); + return; + } + } + } + + /// Maps the console's method names (dispatch-by-name, including the + /// get_/set_ property accessors) onto the VirtualLauncher. Mirrors the real + /// service's DispatchCommandAsync. + private object Dispatch(string method, IReadOnlyList args) + { + switch (method) + { + case "Ping": + return args.Count > 0 && args[0].ValueKind != JsonValueKind.Null + ? mLauncher.Ping(args[0].GetDateTime()) + : DateTime.Now; + + case "GetInstalledApps": + return mLauncher.GetInstalledApps(); + + case "GetLaunchableApps": + return mLauncher.GetLaunchableApps(); + + case "GetLaunchedApps": + return mLauncher.GetLaunchedApps(); + + case "FullUpdate": + return mLauncher.FullUpdate(); + + case "GetOutOfBandProgress": + return mLauncher.GetOutOfBandProgress(args[0].GetGuid()); + + case "InitiateInstallProduct": + return mLauncher.InitiateInstallProduct(); + + case "InstallApp": + mLauncher.InstallApp(args[0].Deserialize(PodRpc.JsonOptions)); + return null; + + case "UninstallApp": + mLauncher.UninstallApp(args[0].GetGuid()); + return null; + + case "RemoveApp": + mLauncher.RemoveApp(args[0].GetGuid()); + return null; + + case "LaunchApp": + return mLauncher.LaunchApp(args[0].GetGuid()); + + case "KillApp": + mLauncher.KillApp(args[0].GetGuid(), args[1].GetInt32()); + return null; + + case "KillAllOfType": + mLauncher.KillAllOfType(args[0].GetGuid()); + return null; + + case "KillAllApps": + mLauncher.KillAllApps(); + return null; + + case "Shutdown": + mLauncher.Shutdown(args[0].GetBoolean()); + return null; + + case "ClearStore": + mLauncher.ClearStore(); + return null; + + case "get_VolumeLevel": + return mLauncher.VolumeLevel; + + case "set_VolumeLevel": + mLauncher.VolumeLevel = args[0].GetSingle(); + return null; + + default: + Log?.Invoke($"Unknown command \"{method}\" — answering null."); + return null; + } + } + + /// Receives the out-of-band product zip and extracts it into the + /// virtual games root, reporting progress exactly like the real service: + /// 0-50% receive, 50-95% extract, 99% "Complete" (IsCompleted). + private void ReceiveInstallFile(Stream stream, Guid callId) + { + string tempZip = null; + try + { + byte[] sizeBuffer = ReadExact(stream, 8); + long fileSize = BitConverter.ToInt64(sizeBuffer, 0); + Log?.Invoke($"Install {callId:N}: receiving {fileSize:N0} bytes..."); + mLauncher.UpdateProgress(callId, 0, "Receiving file..."); + + tempZip = Path.Combine(mLauncher.DataDirectory, $"install_{callId:N}.zip"); + using (FileStream fs = File.Create(tempZip)) + { + byte[] buffer = new byte[65536]; + long received = 0; + while (received < fileSize) + { + int toRead = (int)Math.Min(buffer.Length, fileSize - received); + int read = stream.Read(buffer, 0, toRead); + if (read == 0) + { + throw new IOException("Connection closed during file transfer."); + } + fs.Write(buffer, 0, read); + received += read; + mLauncher.UpdateProgress(callId, (int)(received * 50 / fileSize), "Receiving file..."); + } + } + + mLauncher.UpdateProgress(callId, 50, "Extracting..."); + string gamesRoot = Path.GetFullPath(mLauncher.GamesRoot); + Directory.CreateDirectory(gamesRoot); + using (ZipArchive zip = ZipFile.OpenRead(tempZip)) + { + int total = zip.Entries.Count; + int done = 0; + foreach (ZipArchiveEntry entry in zip.Entries) + { + string destPath = Path.GetFullPath(Path.Combine(gamesRoot, entry.FullName)); + // Zip-slip protection, as in the real service. + if (!destPath.StartsWith(gamesRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destPath); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)); + entry.ExtractToFile(destPath, overwrite: true); + } + done++; + mLauncher.UpdateProgress(callId, 50 + done * 45 / Math.Max(total, 1), "Extracting..."); + } + } + Log?.Invoke($"Install {callId:N}: extracted to {gamesRoot}"); + + // The real service runs (then deletes) a packaged postinstall.bat here. + // vPOD never executes package scripts on the host machine. + string postInstall = Path.Combine(gamesRoot, "postinstall.bat"); + if (File.Exists(postInstall)) + { + mLauncher.UpdateProgress(callId, 96, "Skipping postinstall (vPOD)..."); + Log?.Invoke($"Install {callId:N}: postinstall.bat present — NOT executed (vPOD), removed."); + try { File.Delete(postInstall); } catch { } + } + + mLauncher.UpdateProgress(callId, 99, "Complete", isCompleted: true); + Log?.Invoke($"Install {callId:N}: complete."); + } + catch (Exception ex) + { + mLauncher.UpdateProgress(callId, 0, $"Failed: {ex.Message}", isCompleted: true); + Log?.Invoke($"Install {callId:N} FAILED: {ex.Message}"); + } + finally + { + try { if (tempZip != null) File.Delete(tempZip); } catch { } + } + } + + private static byte[] ReadExact(Stream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = stream.Read(buffer, offset, count - offset); + if (read == 0) + { + throw new EndOfStreamException("Connection closed mid-read."); + } + offset += read; + } + return buffer; + } +} diff --git a/Console/vPOD/PodArguments.cs b/Console/vPOD/PodArguments.cs index 163269c..5335f5d 100644 --- a/Console/vPOD/PodArguments.cs +++ b/Console/vPOD/PodArguments.cs @@ -14,6 +14,10 @@ namespace VPod; /// -app rp|bt which ApplicationID to report (RP by default; also /// switchable live in the UI) /// -host <id> the responding host id reported in state responses +/// +/// vPOD-only (not a real game-client option): +/// -nomanage disable the virtual launcher / site-management side +/// (no provisioning beacons, no TCP 53290 listener) /// internal sealed class PodArguments { @@ -25,6 +29,8 @@ internal sealed class PodArguments public int HostId { get; private set; } = 1; + public bool NoManage { get; private set; } + public static PodArguments Parse(string[] args) { PodArguments result = new PodArguments(); @@ -63,6 +69,9 @@ internal sealed class PodArguments i++; } break; + case "-nomanage": + result.NoManage = true; + break; } } return result; diff --git a/Console/vPOD/PodProvisioning.cs b/Console/vPOD/PodProvisioning.cs new file mode 100644 index 0000000..bce0cea --- /dev/null +++ b/Console/vPOD/PodProvisioning.cs @@ -0,0 +1,378 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Tesla; + +namespace VPod; + +/// +/// The pod side of the SecureConfig first-boot provisioning protocol, display-only: +/// unlike a real pod (the Launcher's PodSecureConfigurator) it never touches the +/// NIC, registry or hostname — the network config the console assigns is only +/// surfaced to the UI. The wire behaviour matches the real pod, so the console's +/// Manage Site "Configure" flow works unmodified: +/// +/// 1. Broadcast a "RQST" beacon (MAC + 3-char RequestId) to UDP 53291 every 10 s +/// — the console shows a "Configure <RequestId>" button. +/// 2. Operator enters the pod's network settings and the 5-char passphrase shown +/// in vPOD's window; the console broadcasts an AES-encrypted "RPLY" (network +/// config) to UDP 53292, key = PBKDF2(passphrase). +/// 3. The console TCP-connects to the pod's (entered) address on 53292; after the +/// OFB/CONF handshake on the passphrase key, the pod sends an RSA public key +/// and receives the RSA-encrypted 32-byte session key — the key that unlocks +/// the launcher RPC channel (TCP 53290) from then on. +/// +/// Reuses the shared TeslaSecureConfiguration pieces where they are public +/// (UdpBeacon, BasicConfigResponse, NegotiateCryptoStreams); the passphrase KDF +/// salt and the "RQST"/"RPLY" tags are internal there and duplicated below. +/// +internal sealed class PodProvisioning +{ + public const int ConsoleRequestPort = 53291; // console listens for RQST beacons + public const int PodReplyPort = 53292; // pod listens for RPLY + the TCP key exchange + + // Mirrors of internals in SecureConfig/SecureConfig.cs (PodConfigurationServer): + // the PBKDF2 salt for the passphrase-derived AES key, and the pod-side + // passphrase/request-id alphabet + lengths (SetupPod validates passphrase == 5). + private static readonly byte[] sPassphraseSalt = new byte[32] + { + 23, 171, 81, 217, 236, 209, 212, 116, 169, 9, + 74, 52, 39, 251, 31, 242, 222, 196, 249, 241, + 166, 216, 158, 218, 21, 17, 71, 101, 50, 231, + 231, 239 + }; + private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + private const int RequestIdLength = 3; + private const int PassphraseLength = 5; + + private readonly byte[] mMacAddress; + private readonly object mLock = new object(); + private UdpBeacon mBeacon; + private UdpClient mReplyListener; + private TcpListener mKeyExchangeListener; + private Thread mWorker; + private volatile bool mRunning; + // Bumped by every Start/Stop so a worker from a previous session can neither + // tear down nor complete a newer one (e.g. quick power-off/power-on cycles). + private volatile int mGeneration; + + public event Action Log; + public event Action ConfigReceived; // display-only network config + public event Action Provisioned; // the 32-byte session key + + public string RequestId { get; private set; } + public string Passphrase { get; private set; } + public bool IsRunning => mRunning; + + /// The pod's stable fake MAC: locally-administered "VPOD" + host id, so + /// the console recognizes the same virtual pod across reprovisions (Site.FindPod). + public static byte[] MacForHost(int hostId) + { + return new byte[6] { 0x02, 0x56, 0x50, 0x4F, 0x44, (byte)hostId }; + } + + public PodProvisioning(byte[] macAddress) + { + mMacAddress = macAddress; + } + + public void Start() + { + int generation; + lock (mLock) + { + if (mRunning) + { + return; + } + mRunning = true; + generation = ++mGeneration; + RequestId = GenerateRandomString(RequestIdLength); + Passphrase = GenerateRandomString(PassphraseLength); + + byte[] payload = new byte[mMacAddress.Length + RequestIdLength]; + mMacAddress.CopyTo(payload, 0); + Encoding.ASCII.GetBytes(RequestId).CopyTo(payload, mMacAddress.Length); + mBeacon = new UdpBeacon(Encoding.UTF8.GetBytes("RQST"), payload, 10000.0, ConsoleRequestPort, null); + mBeacon.Start(); + + mWorker = new Thread(() => ProvisionWorker(generation)) { IsBackground = true, Name = "vPOD-provision" }; + mWorker.Start(); + } + Log?.Invoke($"Provisioning: beaconing RQST (Request ID {RequestId}, passphrase {Passphrase})."); + } + + public void Stop() + { + lock (mLock) + { + mGeneration++; // orphan any live worker + if (!mRunning) + { + return; + } + mRunning = false; + try { mBeacon?.Stop(); } catch { } + mBeacon = null; + try { mReplyListener?.Close(); } catch { } + mReplyListener = null; + try { mKeyExchangeListener?.Stop(); } catch { } + mKeyExchangeListener = null; + } + } + + private bool IsCurrent(int generation) + { + return mRunning && generation == mGeneration; + } + + private void ProvisionWorker(int generation) + { + try + { + // ---- Phase 1: wait for the console's RPLY (proves the operator typed + // our passphrase) and surface the assigned network config. ---- + byte[] weakKey = DeriveKeyFromPassphrase(Passphrase); + BasicConfigResponse config = ReceiveReply(weakKey, generation); + if (config == null || !IsCurrent(generation)) + { + return; // stopped + } + Log?.Invoke($"Provisioning: RPLY received — assigned IP {config.Address} / {config.Mask}" + + (string.IsNullOrEmpty(config.HostName) ? "" : $", host \"{config.HostName}\"") + + " (display only, not applied)."); + ConfigReceived?.Invoke(config); + lock (mLock) + { + try { mBeacon?.Stop(); } catch { } + mBeacon = null; + } + + // ---- Phase 2: accept the console's TCP key exchange on 53292. ---- + TcpListener listener; + lock (mLock) + { + if (!IsCurrent(generation)) + { + return; + } + try + { + listener = new TcpListener(IPAddress.Any, PodReplyPort); + listener.Start(); + } + catch (Exception ex) + { + Log?.Invoke($"Provisioning: cannot listen on TCP {PodReplyPort}: {ex.Message}"); + return; + } + mKeyExchangeListener = listener; + } + Log?.Invoke("Provisioning: waiting for the console's key exchange on TCP " + PodReplyPort + "..."); + while (IsCurrent(generation)) + { + TcpClient client = null; + try + { + client = listener.AcceptTcpClient(); + byte[] sessionKey = ExchangeSessionKey(client, weakKey); + if (sessionKey == null) + { + Log?.Invoke("Provisioning: key-exchange handshake failed (wrong passphrase key?); still waiting."); + continue; + } + if (!IsCurrent(generation)) + { + return; + } + Log?.Invoke("Provisioning: session key received — pod is provisioned."); + Provisioned?.Invoke(sessionKey); + return; + } + catch (Exception ex) + { + if (IsCurrent(generation)) + { + Log?.Invoke("Provisioning: key exchange error: " + ex.Message); + } + else + { + return; // listener stopped + } + } + finally + { + try { client?.Close(); } catch { } + } + } + } + finally + { + StopGeneration(generation); + } + } + + /// Tears the session down only if it is still the one this worker + /// belongs to — a newer Start() must not be disturbed by an old worker exiting. + private void StopGeneration(int generation) + { + lock (mLock) + { + if (generation != mGeneration) + { + return; + } + mRunning = false; + try { mBeacon?.Stop(); } catch { } + mBeacon = null; + try { mReplyListener?.Close(); } catch { } + mReplyListener = null; + try { mKeyExchangeListener?.Stop(); } catch { } + mKeyExchangeListener = null; + } + } + + /// Listens on UDP 53292 for an "RPLY" datagram that decrypts and + /// verifies under our passphrase key. Cancellable mirror of the shared + /// UdpBeaconListener (which cannot be stopped once blocked in Receive): + /// packet = "RPLY"(4) + IV(16) + AES-CBC(config + SHA1(config)). + private BasicConfigResponse ReceiveReply(byte[] weakKey, int generation) + { + UdpClient udp; + lock (mLock) + { + if (!IsCurrent(generation)) + { + return null; + } + try + { + udp = new UdpClient(PodReplyPort) { EnableBroadcast = true }; + } + catch (Exception ex) + { + Log?.Invoke($"Provisioning: cannot listen on UDP {PodReplyPort}: {ex.Message}"); + return null; + } + mReplyListener = udp; + } + byte[] header = Encoding.UTF8.GetBytes("RPLY"); + using (Rijndael aes = Rijndael.Create()) + using (SHA1 sha1 = SHA1.Create()) + { + aes.Key = weakKey; + while (IsCurrent(generation)) + { + byte[] packet; + try + { + IPEndPoint remote = new IPEndPoint(IPAddress.Any, 0); + packet = udp.Receive(ref remote); + } + catch + { + return null; // socket closed by Stop() + } + try + { + int ivLength = aes.IV.Length; + if (packet.Length < header.Length + ivLength) + { + continue; + } + bool tagOk = true; + for (int i = 0; i < header.Length; i++) + { + if (packet[i] != header[i]) { tagOk = false; break; } + } + if (!tagOk) + { + continue; + } + byte[] iv = new byte[ivLength]; + Buffer.BlockCopy(packet, header.Length, iv, 0, ivLength); + byte[] plain; + using (ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, iv)) + using (MemoryStream ms = new MemoryStream()) + { + using (CryptoStream cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Write)) + { + cs.Write(packet, header.Length + ivLength, packet.Length - header.Length - ivLength); + cs.FlushFinalBlock(); + } + plain = ms.ToArray(); + } + int messageLength = plain.Length - sha1.HashSize / 8; + if (messageLength <= 0) + { + continue; + } + byte[] hash = sha1.ComputeHash(plain, 0, messageLength); + bool hashOk = true; + for (int i = 0; i < hash.Length; i++) + { + if (plain[messageLength + i] != hash[i]) { hashOk = false; break; } + } + if (!hashOk) + { + continue; // wrong passphrase (or noise) — keep listening + } + byte[] message = new byte[messageLength]; + Buffer.BlockCopy(plain, 0, message, 0, messageLength); + return new BasicConfigResponse(message); + } + catch + { + // undecryptable/malformed datagram — keep listening + } + } + } + return null; + } + + /// The RSA leg, mirroring the real pod (PodConfigurationClient): after + /// the OFB/CONF handshake on the passphrase key, send our RSA public key and + /// decrypt the console's session key with it. Returns null if CONF fails. + private byte[] ExchangeSessionKey(TcpClient client, byte[] weakKey) + { + if (!PodConfigurationServer.NegotiateCryptoStreams(client.GetStream(), weakKey, out Stream outStream, out Stream inStream)) + { + return null; + } + BinaryWriter writer = new BinaryWriter(outStream); + BinaryReader reader = new BinaryReader(inStream); + using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048)) + { + writer.Write(rsa.ToXmlString(includePrivateParameters: false)); + writer.Flush(); + byte[] encryptedKey = reader.ReadBytes(reader.ReadInt32()); + return rsa.Decrypt(encryptedKey, fOAEP: false); + } + } + + internal static byte[] DeriveKeyFromPassphrase(string passphrase) + { + using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passphrase, sPassphraseSalt, 1000)) + { + return pbkdf2.GetBytes(32); + } + } + + private static string GenerateRandomString(int length) + { + // RNGCryptoServiceProvider-quality randomness is unnecessary here (the real + // pod uses System.Random too), but avoid same-seed collisions across quick + // restarts by seeding from Guid entropy. + Random random = new Random(Guid.NewGuid().GetHashCode()); + char[] chars = new char[length]; + for (int i = 0; i < length; i++) + { + chars[i] = Alphabet[random.Next(Alphabet.Length)]; + } + return new string(chars); + } +} diff --git a/Console/vPOD/README.md b/Console/vPOD/README.md index 4753935..fb0ebc5 100644 --- a/Console/vPOD/README.md +++ b/Console/vPOD/README.md @@ -7,6 +7,12 @@ server on TCP 1501 — the console connects to it exactly as it would a real pod emulates the pod `ApplicationState` machine, reassembles the streamed egg, and shows everything on a live display. +It also impersonates the pod's **TeslaLauncher service** (the "Launcher / Site +Management" column), so the console's Manage Site — provisioning, Install / +Uninstall Product, launch/kill, volume, restart/shutdown — can be tested +end-to-end with no cockpit and **no console changes**. See +[Site management / virtual launcher](#site-management--virtual-launcher). + ## What it does - **Listens on TCP 1501** (configurable) and answers the console's @@ -37,7 +43,7 @@ shows everything on a live display. ## Running it ``` -vPOD.exe [-net ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H] +vPOD.exe [-net ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H] [-nomanage] ``` - `-net ` Munga control port (default **1501**). @@ -47,6 +53,78 @@ vPOD.exe [-net ] [-app rp|bt] [-lc|-mr] [-host ] [-res W H] - `-host ` responding host id reported in state responses (default 1). - `-res W H` accepted and ignored (real clients take it; kept for drop-in launch compatibility). +- `-nomanage` vPOD-only: disable the virtual launcher / site-management side + (no provisioning beacons, no TCP 53290 listener) — e.g. when a real + TeslaLauncher runs on the same machine, or for a second vPOD instance. + +## Site management / virtual launcher + +vPOD's right-hand column is a stand-in for the pod's **TeslaLauncher service**: +the OFB-encrypted, framed-JSON `ILauncherService` RPC on **TCP 53290** that the +console's Manage Site and squad/pod panel talk to (`Tesla.Contract` / +`Tesla.SecureConfig` are the same shared wire libraries both real ends use). + +### Provisioning (first run) + +The console only talks to a pod's launcher after minting a 32-byte session key +for it via the SecureConfig **Configure** flow, so an unprovisioned vPOD +behaves like a freshly-imaged pod — minus the NIC/registry changes (the +assigned network config is shown in the window but never applied): + +1. Run vPOD (no `-nomanage`). It broadcasts `RQST` beacons and displays a + **Request ID** and **Passphrase** (the real pod shows these on its screen). +2. In the console: **Manage Site** — a **"Configure <Request ID>"** button + appears at the bottom. Click it, enter the pod's name/squad, its IP + (**127.0.0.1** when vPOD runs on the console machine — accepted), any + subnet (e.g. 255.255.255.0), and the passphrase from vPOD's window. +3. The console sends the encrypted config + session key; vPOD stores the key + and starts the launcher RPC. The pod row goes healthy (`Idle [ ms]`). + +The key persists in `%LocalAppData%\vPOD\TeslaKeyStore.key` (launcher format), +so provisioning is one-time. **Reprovision** drops the key and returns to +beacon mode — pair it with deleting the pod in Manage Site (the console's +**Reconfigure…** does both ends automatically: its `ClearStore` makes vPOD +drop the key and beacon again). + +### Product deployments + +Right-click the pod row → **Install Product ▸** works exactly as against a real +pod: the console streams the package zip out-of-band on a second 53290 +connection while polling progress on the first; vPOD extracts it into its +virtual games root and reports the launcher's usual `0–50%` receive / `50–95%` +extract / `99% Complete` progression. Then: + +- Installed apps land in the column's list (and in `GetInstalledApps`, so the + Uninstall menu populates). Registrations persist in + `%LocalAppData%\vPOD\LaunchApps.json`. +- Packages extract under `%LocalAppData%\vPOD\Games\` (the virtual `C:\Games`). + A packaged `postinstall.bat` is logged and removed but **never executed**. +- **Launch/Kill** from the squad panel are simulated (fake PIDs, no real + process); Volume round-trips; **Restart/Shutdown** power-cycle the virtual + pod (dark for a few seconds on restart). + +### Ports + +| Port | Proto | Direction | Purpose | +|---|---|---|---| +| 1501 | TCP | console → vPOD | Munga game control (existing) | +| 53290 | TCP | console → vPOD | Launcher RPC (`ILauncherService`) | +| 53291 | UDP | vPOD → console (broadcast) | `RQST` provisioning beacon | +| 53292 | UDP+TCP | console → vPOD | `RPLY` config broadcast + RSA key exchange | + +Same-machine testing needs no firewall changes (loopback). Running vPOD on a +**different machine** needs inbound allows for TCP 1501/53290/53292 and UDP +53292 on the vPOD machine (the console installer already opens its own side). + +Not emulated: the console's remote Windows-service control (`ServiceController` +over SCM/SMB, used by some SitePanel service start/stop paths — dormant against +real pods too, since it queries service name `TeslaLauncherService` while the +launcher registers as `Tesla Application Launcher`), and really launching +deployed exes (a second game client would fight vPOD for its own ports). + +An end-to-end loopback test of this server against the console's real +`PodManagerConnection` client lives in the differential suite: +`Console/tests/TeslaConsole.DiffTests/VPodLauncherServerTests.cs`. ## Deploying from the console (Manage Site → Install Product) diff --git a/Console/vPOD/VPodForm.cs b/Console/vPOD/VPodForm.cs index 505107d..2ba32c3 100644 --- a/Console/vPOD/VPodForm.cs +++ b/Console/vPOD/VPodForm.cs @@ -3,6 +3,8 @@ using System.Drawing; using System.Text; using System.Windows.Forms; using Munga.Net; +using Tesla; +using Tesla.Net; namespace VPod; @@ -12,13 +14,18 @@ namespace VPod; /// with the game toggle (Red Planet / BattleTech) that changes which /// ApplicationID the pod reports. Below, the left pane is the egg viewer (the /// last egg the console streamed, one field per line) and the right pane is a -/// scrolling protocol log. +/// scrolling protocol log. The right column is the virtual launcher — the pod +/// side of the console's Site Management (provisioning, product installs, +/// launch/kill/volume/shutdown), see LauncherRpcServer / PodProvisioning. /// internal sealed class VPodForm : Form { private readonly PodArguments mOptions; private readonly MungaPodServer mServer; private readonly PodSimulator mSimulator; + private readonly VirtualLauncher mLauncher; + private readonly LauncherRpcServer mRpcServer; + private readonly PodProvisioning mProvisioning; private Label mListeningLabel; private Label mConnectionLabel; @@ -31,18 +38,39 @@ internal sealed class VPodForm : Form private Button mResetButton; private CheckBox mRestartCheckbox; private Timer mRestartTimer; + private Timer mRebootTimer; private SplitContainer mSplit; private TextBox mEggBox; private TextBox mLogBox; + // Virtual launcher / site-management column. + private Label mProvisionStatusLabel; + private Panel mPassphrasePanel; + private Label mRequestIdValueLabel; + private Label mPassphraseValueLabel; + private Label mNetConfigLabel; + private Label mRpcStatusLabel; + private Label mInstallStatusLabel; + private ProgressBar mInstallProgressBar; + private Button mReprovisionButton; + private ListView mAppsView; + + private bool mPoweredOn; + // How long the "watchdog" waits before relaunching the exited game. private const int WatchdogRestartMs = 1500; + // How long a console-requested Shutdown(restart) keeps the pod dark. + private const int RebootRestartMs = 4000; + public VPodForm(PodArguments options) { mOptions = options; mServer = new MungaPodServer(options.Port); mSimulator = new PodSimulator(mServer, options.Application, options.HostId); + mLauncher = new VirtualLauncher(); + mRpcServer = new LauncherRpcServer(mLauncher); + mProvisioning = new PodProvisioning(PodProvisioning.MacForHost(options.HostId)); BuildUi(); mServer.Log += OnLog; @@ -54,8 +82,21 @@ internal sealed class VPodForm : Form mSimulator.EggProgress += OnEggProgress; mSimulator.EndMissionExit += OnEndMissionExit; + mLauncher.Log += OnSiteLog; + mLauncher.AppsChanged += OnAppsChanged; + mLauncher.InstallProgressChanged += OnInstallProgress; + mLauncher.ShutdownRequested += OnShutdownRequested; + mLauncher.ReprovisionRequested += OnReprovisionRequested; + mRpcServer.Log += OnSiteLog; + mRpcServer.ConnectionsChanged += OnRpcConnectionsChanged; + mProvisioning.Log += OnSiteLog; + mProvisioning.ConfigReceived += OnConfigReceived; + mProvisioning.Provisioned += OnProvisioned; + mRestartTimer = new Timer { Interval = WatchdogRestartMs }; mRestartTimer.Tick += OnRestartTimerTick; + mRebootTimer = new Timer { Interval = RebootRestartMs }; + mRebootTimer.Tick += OnRebootTimerTick; Load += OnFormLoad; FormClosing += OnFormClosing; @@ -64,8 +105,8 @@ internal sealed class VPodForm : Form private void BuildUi() { Text = $"vPOD - virtual pod (port {mOptions.Port}, host {mOptions.HostId})"; - ClientSize = new Size(920, 560); - MinimumSize = new Size(760, 460); + ClientSize = new Size(1280, 560); + MinimumSize = new Size(1100, 500); Font = new Font("Segoe UI", 9f); // ---- status panel ---- @@ -212,8 +253,112 @@ internal sealed class VPodForm : Form split.Panel1.Controls.Add(eggGroup); split.Panel2.Controls.Add(logGroup); + // ---- virtual launcher / site management column ---- + GroupBox siteGroup = new GroupBox + { + Text = "Launcher / Site Management", + Dock = DockStyle.Right, + Width = 360, + Padding = new Padding(8) + }; + + Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 216 }; + + mProvisionStatusLabel = new Label + { + AutoSize = true, + Location = new Point(4, 4), + Font = new Font("Segoe UI", 9f, FontStyle.Bold), + Text = "Provisioning: —" + }; + + // Stand-in for the real pod's TeslaPasscodeDisplay: the operator reads + // these two values into the console's Configure dialog. + mPassphrasePanel = new Panel { Location = new Point(4, 28), Size = new Size(336, 62), Visible = false }; + Label requestIdCaption = new Label { AutoSize = true, Location = new Point(0, 6), Text = "Request ID:" }; + mRequestIdValueLabel = new Label + { + AutoSize = true, + Location = new Point(90, 0), + Font = new Font("Segoe UI", 15f, FontStyle.Bold), + Text = "—" + }; + Label passphraseCaption = new Label { AutoSize = true, Location = new Point(0, 38), Text = "Passphrase:" }; + mPassphraseValueLabel = new Label + { + AutoSize = true, + Location = new Point(90, 32), + Font = new Font("Segoe UI", 15f, FontStyle.Bold), + ForeColor = Color.Firebrick, + Text = "—" + }; + mPassphrasePanel.Controls.Add(requestIdCaption); + mPassphrasePanel.Controls.Add(mRequestIdValueLabel); + mPassphrasePanel.Controls.Add(passphraseCaption); + mPassphrasePanel.Controls.Add(mPassphraseValueLabel); + + mNetConfigLabel = new Label + { + Location = new Point(4, 94), + Size = new Size(336, 32), + ForeColor = Color.Gray, + Text = "" + }; + + mRpcStatusLabel = new Label + { + AutoSize = true, + Location = new Point(4, 130), + Text = "Launcher RPC: not running" + }; + + mInstallStatusLabel = new Label + { + Location = new Point(4, 154), + Size = new Size(336, 18), + Text = "No install in progress." + }; + mInstallProgressBar = new ProgressBar + { + Location = new Point(4, 176), + Size = new Size(240, 22), + Minimum = 0, + Maximum = 100 + }; + mReprovisionButton = new Button + { + Text = "Reprovision", + Location = new Point(252, 174), + Size = new Size(88, 26) + }; + mReprovisionButton.Click += ReprovisionClicked; + + siteTop.Controls.Add(mProvisionStatusLabel); + siteTop.Controls.Add(mPassphrasePanel); + siteTop.Controls.Add(mNetConfigLabel); + siteTop.Controls.Add(mRpcStatusLabel); + siteTop.Controls.Add(mInstallStatusLabel); + siteTop.Controls.Add(mInstallProgressBar); + siteTop.Controls.Add(mReprovisionButton); + + mAppsView = new ListView + { + Dock = DockStyle.Fill, + View = View.Details, + FullRowSelect = true, + HeaderStyle = ColumnHeaderStyle.Nonclickable + }; + mAppsView.Columns.Add("Installed App", 150); + mAppsView.Columns.Add("PID", 48); + mAppsView.Columns.Add("Auto", 42); + mAppsView.Columns.Add("Exe", 200); + + siteGroup.Controls.Add(mAppsView); + siteGroup.Controls.Add(siteTop); + Controls.Add(split); Controls.Add(statusGroup); + Controls.Add(siteGroup); } private void OnFormLoad(object sender, EventArgs e) @@ -225,10 +370,12 @@ internal sealed class VPodForm : Form } UpdateConnectionLabel(null); UpdateStateLabel(mSimulator.State); + RefreshAppsList(); if (StartServer()) { mSimulator.PowerOn(); SetPoweredState(on: true); + StartManagement(); } else { @@ -239,27 +386,34 @@ internal sealed class VPodForm : Form private void OnFormClosing(object sender, FormClosingEventArgs e) { mServer.Stop(); + StopManagement(); } private void PowerOnClicked(object sender, EventArgs e) { mRestartTimer.Stop(); // cancel any pending watchdog restart + mRebootTimer.Stop(); if (!mServer.IsListening && !StartServer()) { return; // couldn't bind the port; stay powered off } mSimulator.PowerOn(); SetPoweredState(on: true); + StartManagement(); } /// /// Powers the pod off by closing the TCP listener, so the console can no /// longer connect — the same condition as a pod with no game client running. + /// The launcher/provisioning side goes dark too (the whole machine is "off", + /// unlike an end-mission game exit where the launcher service survives). /// private void PowerOffClicked(object sender, EventArgs e) { mRestartTimer.Stop(); + mRebootTimer.Stop(); mServer.Stop(); + StopManagement(); SetPoweredState(on: false); } @@ -326,18 +480,114 @@ internal sealed class VPodForm : Form /// Reflects the on/off state in the buttons and, when off, the status labels. private void SetPoweredState(bool on) { + mPoweredOn = on; mPowerButton.Enabled = !on; mPowerOffButton.Enabled = on; mResetButton.Enabled = on; + mReprovisionButton.Enabled = on && !mOptions.NoManage; if (!on) { UpdateListeningLabel(false); UpdateConnectionLabel(null); mStateLabel.Text = "Powered Off"; mStateLabel.ForeColor = Color.Gray; + mProvisionStatusLabel.Text = "Provisioning: pod powered off"; + mProvisionStatusLabel.ForeColor = Color.Gray; + mPassphrasePanel.Visible = false; + mRpcStatusLabel.Text = "Launcher RPC: pod powered off"; + mRpcStatusLabel.ForeColor = Color.Gray; } } + // ---- virtual launcher / site management lifecycle ---- + + /// Brings the launcher side up for a powered-on pod: the RPC server + /// when a session key is stored, otherwise the provisioning beacons. + private void StartManagement() + { + if (mOptions.NoManage) + { + mProvisionStatusLabel.Text = "Provisioning: disabled (-nomanage)"; + mProvisionStatusLabel.ForeColor = Color.Gray; + mRpcStatusLabel.Text = "Launcher RPC: disabled (-nomanage)"; + mRpcStatusLabel.ForeColor = Color.Gray; + return; + } + byte[] key = mLauncher.LoadKey(); + if (key != null) + { + StartRpcServer(key); + } + else + { + StartProvisioning(); + } + } + + private void StopManagement() + { + mProvisioning.Stop(); + mRpcServer.Stop(); + } + + private void StartRpcServer(byte[] key) + { + try + { + mRpcServer.Start(key); + } + catch (Exception ex) + { + OnSiteLog($"FAILED to listen on TCP {LauncherRpcServer.ManagePort}: {ex.Message}"); + mRpcStatusLabel.Text = $"Launcher RPC: TCP {LauncherRpcServer.ManagePort} unavailable"; + mRpcStatusLabel.ForeColor = Color.Firebrick; + return; + } + mProvisionStatusLabel.Text = "Provisioned — key " + KeyFingerprint(key); + mProvisionStatusLabel.ForeColor = Color.ForestGreen; + mPassphrasePanel.Visible = false; + mRpcStatusLabel.Text = $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}"; + mRpcStatusLabel.ForeColor = Color.ForestGreen; + } + + private void StartProvisioning() + { + mProvisioning.Start(); + mProvisionStatusLabel.Text = "Unprovisioned — beaconing to console..."; + mProvisionStatusLabel.ForeColor = Color.DarkGoldenrod; + mRequestIdValueLabel.Text = mProvisioning.RequestId; + mPassphraseValueLabel.Text = mProvisioning.Passphrase; + mPassphrasePanel.Visible = true; + mNetConfigLabel.Text = "Use Manage Site's \"Configure\" button and enter the passphrase above."; + mRpcStatusLabel.Text = "Launcher RPC: waiting for provisioning"; + mRpcStatusLabel.ForeColor = Color.Gray; + } + + /// Drops the session key and re-enters provisioning — from the UI + /// button, or when the console's Reconfigure clears our store. + private void DropKeyAndReprovision() + { + mLauncher.DeleteKey(); + mRpcServer.Stop(); + mProvisioning.Stop(); + mNetConfigLabel.Text = ""; + if (mPoweredOn && !mOptions.NoManage) + { + StartProvisioning(); + } + } + + private void ReprovisionClicked(object sender, EventArgs e) + { + OnSiteLog("Reprovision requested from the vPOD window."); + DropKeyAndReprovision(); + } + + private static string KeyFingerprint(byte[] key) + { + return key.Length >= 4 ? $"{key[0]:X2}{key[1]:X2}{key[2]:X2}{key[3]:X2}…" : "?"; + } + private void GameToggleChanged(object sender, EventArgs e) { if (sender is RadioButton rb && !rb.Checked) @@ -417,6 +667,133 @@ internal sealed class VPodForm : Form })); } + // ---- virtual launcher event handlers (marshal to UI thread) ---- + + private void OnSiteLog(string message) + { + OnLog("[SITE] " + message); + } + + private void OnProvisioned(byte[] key) + { + if (IsDisposed) return; + BeginInvoke((Action)(() => + { + mLauncher.SaveKey(key); + mProvisioning.Stop(); + StartRpcServer(key); + })); + } + + private void OnConfigReceived(BasicConfigResponse config) + { + if (IsDisposed) return; + BeginInvoke((Action)(() => + { + mNetConfigLabel.Text = $"Assigned (display only): IP {config.Address} / {config.Mask}" + + (string.IsNullOrEmpty(config.HostName) ? "" : $"\r\nhost \"{config.HostName}\", gw {config.Gateway}, dns {config.Dns}"); + })); + } + + private void OnRpcConnectionsChanged(int count) + { + if (IsDisposed) return; + BeginInvoke((Action)(() => + { + if (!mRpcServer.IsListening) + { + return; // Stop() already updated the label + } + mRpcStatusLabel.Text = count > 0 + ? $"Launcher RPC: {count} console session(s) on TCP {LauncherRpcServer.ManagePort}" + : $"Launcher RPC: listening on TCP {LauncherRpcServer.ManagePort}"; + mRpcStatusLabel.ForeColor = Color.ForestGreen; + })); + } + + private void OnAppsChanged() + { + if (IsDisposed) return; + BeginInvoke((Action)RefreshAppsList); + } + + private void RefreshAppsList() + { + LaunchData[] installed = mLauncher.GetInstalledApps(); + LaunchedAppData[] launched = mLauncher.GetLaunchedApps(); + mAppsView.BeginUpdate(); + mAppsView.Items.Clear(); + foreach (LaunchData app in installed) + { + StringBuilder pids = new StringBuilder(); + foreach (LaunchedAppData proc in launched) + { + if (proc.LaunchKey == app.LaunchPair.LaunchKey) + { + if (pids.Length > 0) pids.Append(", "); + pids.Append(proc.ProcessId); + } + } + ListViewItem item = new ListViewItem(new[] + { + app.LaunchPair.DisplayName, + pids.ToString(), + app.AutoRestart ? "yes" : "", + app.ExeFile + }); + mAppsView.Items.Add(item); + } + mAppsView.EndUpdate(); + } + + private void OnInstallProgress(Guid callId, OutOfBandProgress progress) + { + if (IsDisposed) return; + BeginInvoke((Action)(() => + { + mInstallProgressBar.Value = Math.Max(0, Math.Min(100, progress.PercentComplete)); + mInstallStatusLabel.Text = progress.IsCompleted + ? "Install: " + progress.Status + : $"Installing: {progress.Status} ({progress.PercentComplete}%)"; + })); + } + + /// The console asked the pod to shut down or restart: go fully dark + /// (Munga + launcher), and come back after a "reboot" pause when restarting. + private void OnShutdownRequested(bool restart) + { + if (IsDisposed) return; + BeginInvoke((Action)(() => + { + mRestartTimer.Stop(); + mRebootTimer.Stop(); + mServer.Stop(); + StopManagement(); + SetPoweredState(on: false); + if (restart) + { + mStateLabel.Text = "Rebooting..."; + mStateLabel.ForeColor = Color.DarkOrange; + mRebootTimer.Start(); + } + })); + } + + private void OnRebootTimerTick(object sender, EventArgs e) + { + mRebootTimer.Stop(); + OnLog("[SITE] Pod rebooted."); + PowerOnClicked(this, EventArgs.Empty); + } + + /// The console's Reconfigure cleared our store — drop the key and go + /// back to beaconing so the "Configure" button reappears in Manage Site. + private void OnReprovisionRequested() + { + if (IsDisposed) return; + BeginInvoke((Action)DropKeyAndReprovision); + } + // ---- label helpers ---- private void UpdateListeningLabel(bool listening) diff --git a/Console/vPOD/VirtualLauncher.cs b/Console/vPOD/VirtualLauncher.cs new file mode 100644 index 0000000..7df9171 --- /dev/null +++ b/Console/vPOD/VirtualLauncher.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Tesla.Net; + +namespace VPod; + +/// +/// The state behind vPOD's virtual TeslaLauncher service: the installed-app +/// registry, the (simulated) launched-process table, the volume level, the +/// install-progress map and the session-key store. This is the pod-side model +/// that dispatches the console's +/// ILauncherService calls onto — the vPOD equivalent of the real launcher's +/// Service+Agent pair, minus any actual process launching. +/// +/// Persisted under %LocalAppData%\vPOD: +/// TeslaKeyStore.key session key, launcher format: [1-byte len][key bytes] +/// LaunchApps.json installed apps (the wire LaunchData list, PodRpc JSON) +/// Games\ where InstallProduct zips are extracted (virtual C:\Games) +/// +internal sealed class VirtualLauncher +{ + private readonly object mLock = new object(); + private readonly List mInstalledApps = new List(); + private readonly List mLaunchedApps = new List(); + private readonly Dictionary mInstallProgress = new Dictionary(); + private float mVolumeLevel = 1.0f; + private int mNextPid = 4000; + + public event Action Log; + public event Action AppsChanged; // installed or launched list changed + public event Action InstallProgressChanged; + public event Action ShutdownRequested; // restart? + public event Action ReprovisionRequested; // console cleared the store (Reconfigure) + + public string DataDirectory { get; } + public string GamesRoot => Path.Combine(DataDirectory, "Games"); + public string KeyFilePath => Path.Combine(DataDirectory, "TeslaKeyStore.key"); + private string LaunchAppsPath => Path.Combine(DataDirectory, "LaunchApps.json"); + + public VirtualLauncher() + : this(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "vPOD")) + { + } + + public VirtualLauncher(string dataDirectory) + { + DataDirectory = dataDirectory; + Directory.CreateDirectory(DataDirectory); + LoadApps(); + } + + // ---- session key store (launcher's TeslaKeyStore.key format) ---- + + /// The provisioned session key, or null when unprovisioned. + public byte[] LoadKey() + { + try + { + if (!File.Exists(KeyFilePath)) + { + return null; + } + byte[] raw = File.ReadAllBytes(KeyFilePath); + if (raw.Length < 2 || raw.Length != raw[0] + 1) + { + return null; + } + byte[] key = new byte[raw[0]]; + Buffer.BlockCopy(raw, 1, key, 0, key.Length); + return key; + } + catch + { + return null; + } + } + + public void SaveKey(byte[] key) + { + byte[] raw = new byte[key.Length + 1]; + raw[0] = (byte)key.Length; + key.CopyTo(raw, 1); + File.WriteAllBytes(KeyFilePath, raw); + } + + public void DeleteKey() + { + try { File.Delete(KeyFilePath); } catch { } + } + + // ---- ILauncherService state ---- + + public DateTime Ping(DateTime now) + { + return now; + } + + public float VolumeLevel + { + get { lock (mLock) { return mVolumeLevel; } } + set + { + lock (mLock) { mVolumeLevel = value; } + Log?.Invoke($"Volume set to {value:P0}."); + } + } + + public LaunchData[] GetInstalledApps() + { + lock (mLock) { return mInstalledApps.ToArray(); } + } + + public LaunchPair[] GetLaunchableApps() + { + lock (mLock) + { + LaunchPair[] pairs = new LaunchPair[mInstalledApps.Count]; + for (int i = 0; i < mInstalledApps.Count; i++) + { + pairs[i] = mInstalledApps[i].LaunchPair; + } + return pairs; + } + } + + public LaunchedAppData[] GetLaunchedApps() + { + lock (mLock) { return mLaunchedApps.ToArray(); } + } + + public FullUpdateData FullUpdate() + { + lock (mLock) + { + return new FullUpdateData + { + InstalledApps = mInstalledApps.ToArray(), + LaunchedApps = mLaunchedApps.ToArray(), + VolumeLevel = mVolumeLevel + }; + } + } + + /// Registers a launch entry (upsert by LaunchKey) — the per-entry call + /// the console makes after a product install, and the write behind LaunchApps.json. + public void InstallApp(LaunchData data) + { + lock (mLock) + { + int existing = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == data.LaunchPair.LaunchKey); + if (existing >= 0) + { + mInstalledApps[existing] = data; + } + else + { + mInstalledApps.Add(data); + } + SaveApps(); + } + Log?.Invoke($"InstallApp: \"{data.LaunchPair.DisplayName}\" -> {data.ExeFile} {data.Arguments}"); + AppsChanged?.Invoke(); + } + + public void UninstallApp(Guid launchKey) + { + string cleanupDir = null; + string name = null; + lock (mLock) + { + int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey); + if (index < 0) + { + return; + } + LaunchData app = mInstalledApps[index]; + name = app.LaunchPair.DisplayName; + mInstalledApps.RemoveAt(index); + mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey); + SaveApps(); + // Like the real Agent's CleanupDir: remove the product folder, but only + // when it's inside our virtual games root and no other app still uses it. + string dir = app.WorkingDirectory; + if (!string.IsNullOrEmpty(dir) + && IsUnderGamesRoot(dir) + && !mInstalledApps.Exists(a => string.Equals(a.WorkingDirectory, dir, StringComparison.OrdinalIgnoreCase))) + { + cleanupDir = dir; + } + } + if (cleanupDir != null) + { + try + { + if (Directory.Exists(cleanupDir)) + { + Directory.Delete(cleanupDir, recursive: true); + Log?.Invoke($"UninstallApp: removed product directory {cleanupDir}"); + } + } + catch (Exception ex) + { + Log?.Invoke($"UninstallApp: could not remove {cleanupDir}: {ex.Message}"); + } + } + Log?.Invoke($"UninstallApp: \"{name}\" removed."); + AppsChanged?.Invoke(); + } + + public void RemoveApp(Guid launchKey) + { + lock (mLock) + { + int removed = mInstalledApps.RemoveAll(a => a.LaunchPair.LaunchKey == launchKey); + if (removed == 0) + { + return; + } + mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey); + SaveApps(); + } + Log?.Invoke($"RemoveApp: {launchKey} unregistered."); + AppsChanged?.Invoke(); + } + + public int LaunchApp(Guid launchKey) + { + LaunchData app; + int pid; + lock (mLock) + { + int index = mInstalledApps.FindIndex(a => a.LaunchPair.LaunchKey == launchKey); + if (index < 0) + { + Log?.Invoke($"LaunchApp: unknown launch key {launchKey}."); + return 0; + } + app = mInstalledApps[index]; + pid = mNextPid++; + mLaunchedApps.Add(new LaunchedAppData { ProcessId = pid, LaunchKey = launchKey }); + } + Log?.Invoke($"LaunchApp: \"{app.LaunchPair.DisplayName}\" -> simulated PID {pid} (no real process)."); + AppsChanged?.Invoke(); + return pid; + } + + public void KillApp(Guid launchKey, int processId) + { + int removed; + lock (mLock) + { + removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey && l.ProcessId == processId); + } + if (removed > 0) + { + Log?.Invoke($"KillApp: simulated PID {processId} killed."); + AppsChanged?.Invoke(); + } + } + + public void KillAllOfType(Guid launchKey) + { + int removed; + lock (mLock) + { + removed = mLaunchedApps.RemoveAll(l => l.LaunchKey == launchKey); + } + if (removed > 0) + { + Log?.Invoke($"KillAllOfType: {removed} simulated process(es) killed."); + AppsChanged?.Invoke(); + } + } + + public void KillAllApps() + { + int removed; + lock (mLock) + { + removed = mLaunchedApps.Count; + mLaunchedApps.Clear(); + } + if (removed > 0) + { + Log?.Invoke($"KillAllApps: {removed} simulated process(es) killed."); + AppsChanged?.Invoke(); + } + } + + public void Shutdown(bool restart) + { + Log?.Invoke(restart ? "Shutdown(restart) — power-cycling the pod." : "Shutdown — powering the pod off."); + ShutdownRequested?.Invoke(restart); + } + + /// The console's Reconfigure calls this before deleting the pod from the + /// site. Wipe the store AND drop the session key: the console no longer holds it, + /// so re-entering provisioning (beacon mode) is what makes Reconfigure completable + /// without the real pod's reboot-with-DHCP step. + public void ClearStore() + { + lock (mLock) + { + mInstalledApps.Clear(); + mLaunchedApps.Clear(); + SaveApps(); + } + Log?.Invoke("ClearStore: installed apps wiped; dropping session key to re-enter provisioning."); + AppsChanged?.Invoke(); + ReprovisionRequested?.Invoke(); + } + + // ---- install progress (InitiateInstallProduct / GetOutOfBandProgress) ---- + + public Guid InitiateInstallProduct() + { + Guid callId = Guid.NewGuid(); + UpdateProgress(callId, 0, "Waiting for file..."); + return callId; + } + + public OutOfBandProgress GetOutOfBandProgress(Guid callId) + { + lock (mLock) + { + if (mInstallProgress.TryGetValue(callId, out OutOfBandProgress progress)) + { + return progress; + } + } + return new OutOfBandProgress { PercentComplete = 0, Status = "Unknown call ID", IsCompleted = true }; + } + + public void UpdateProgress(Guid callId, int percent, string status, bool isCompleted = false) + { + OutOfBandProgress progress = new OutOfBandProgress + { + PercentComplete = percent, + Status = status, + IsCompleted = isCompleted + }; + lock (mLock) + { + mInstallProgress[callId] = progress; + } + InstallProgressChanged?.Invoke(callId, progress); + } + + private bool IsUnderGamesRoot(string path) + { + try + { + string full = Path.GetFullPath(path); + string root = Path.GetFullPath(GamesRoot) + Path.DirectorySeparatorChar; + return full.StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + // ---- persistence (PodRpc's JSON options round-trip the wire types exactly) ---- + + private void LoadApps() + { + try + { + if (!File.Exists(LaunchAppsPath)) + { + return; + } + LaunchData[] apps = JsonSerializer.Deserialize(File.ReadAllText(LaunchAppsPath), PodRpc.JsonOptions); + if (apps != null) + { + mInstalledApps.AddRange(apps); + } + } + catch + { + // unreadable store: start empty, like a fresh pod + } + } + + private void SaveApps() + { + try + { + File.WriteAllText(LaunchAppsPath, JsonSerializer.Serialize(mInstalledApps.ToArray(), PodRpc.JsonOptions)); + } + catch (Exception ex) + { + Log?.Invoke("Could not persist LaunchApps.json: " + ex.Message); + } + } +} diff --git a/Console/vPOD/vPOD.csproj b/Console/vPOD/vPOD.csproj index 1f36b8e..6efa382 100644 --- a/Console/vPOD/vPOD.csproj +++ b/Console/vPOD/vPOD.csproj @@ -32,6 +32,18 @@ + + + + + + + + + + + @@ -41,4 +53,14 @@ + + + + + +