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; } }