Files
TeslaSuite/vPOD/LauncherRpcServer.cs
T
CydandClaude Opus 5 33f734da2d Console: internet-session roster from TeslaLobby; vPOD per-address bind
The eight internet pod rows are furniture the operator builds once in
Manage Site — the slot-to-IP map is frozen — but which of those slots a
human actually claimed changes every session. New SessionRoster reads the
roster TeslaLobby writes at the state=launching flip, and both game panes
grow a session strip above Mission Properties: a banner (session key, game,
slots claimed, waiting, written-at), Apply Session, and the Reset Pods that
until now existed only as a right-click on a Go button that is disabled
exactly when the reset is wanted.

Two properties shape all of it. Only claimed slots appear in the file, so
absence is the unclaimed signal and every failure path — truncated, stale,
unreadable, refused — degrades to "no roster", which is byte-for-byte
today's arcade behaviour; museums run this software and a bad JSON file
must never stop a mission that would otherwise run by hand. And enabled
implies claimed, not the reverse: the operator may always sit a pilot out,
never add one, because an enabled row nobody claimed puts a dead IP in the
egg and the pods then wait on a peer that will never boot. Roster issues
join the pane's existing issue text, so the Go button is still the gate.

The roster is one file per launch generation and deliberately not a live
view: pod peer tables are boot-static, so a player whose lobby crashed is
still in every pod's table and still playable, and live tracking would
evict that working pod mid-session. Poll runs at ~1Hz off the existing
network timer with its own deadline, and the strips are built at runtime —
InitializeComponent is decompiled 1995 designer output that the
differential tests compare literally.

Go/Load also re-checks CheckAllValues at the click instead of trusting the
last status tick: a pod that died in that gap went straight into the
mission, and the post-Load barrier in NetworkScan then waited forever for a
WaitingForLaunch that never arrives.

vPOD gains -bind <ip>. Both listeners defaulted to IPAddress.Any, so a
second vPOD on the machine lost the port and the console could only ever
see one fake pod; binding each instance to its own address runs a whole
eight-pod session side by side with no cockpits. Bind all of them or none —
Windows lets IPAddress.Any take a port that specific addresses already
hold, and the unbound instance then answers for every slot nobody claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:14:38 -05:00

433 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using Newtonsoft.Json.Linq;
using Tesla;
using Tesla.Launcher;
using Tesla.Net;
namespace VPod;
/// <summary>
/// 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 <see cref="VirtualLauncher" />; packaged product scripts
/// (postinstall.bat here, pre-uninstall.bat in UninstallApp) are logged and
/// removed unrun unless <see cref="VirtualLauncher.RunPackageScripts" /> is set
/// from the vPOD window, in which case they run like on the real pod.
/// </summary>
internal sealed class LauncherRpcServer
{
public const int ManagePort = 53290;
private readonly VirtualLauncher mLauncher;
private readonly int mPort;
private readonly IPAddress mBind; // null = every interface (the default)
private byte[] mSessionKey;
private TcpListener mListener;
private Thread mAcceptThread;
private volatile bool mRunning;
private readonly object mClientsLock = new object();
private readonly List<TcpClient> mClients = new List<TcpClient>();
public event Action<string> Log;
public event Action<int> ConnectionsChanged; // number of active console sessions
public bool IsListening => mRunning;
public LauncherRpcServer(VirtualLauncher launcher, int port = ManagePort, IPAddress bind = null)
{
mLauncher = launcher;
mPort = port;
mBind = bind; // null keeps the historical every-interface behaviour
}
/// <summary>Starts listening with the given provisioned session key. Throws if
/// the port cannot be bound (e.g. a real TeslaLauncher on the same machine).</summary>
public void Start(byte[] sessionKey)
{
if (mRunning)
{
return;
}
mSessionKey = sessionKey;
mListener = new TcpListener(mBind ?? IPAddress.Any, mPort);
mListener.Start();
mRunning = true;
mAcceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "vPOD-launcher-accept" };
mAcceptThread.Start();
Log?.Invoke(mBind == null
? $"Launcher RPC listening on TCP {mPort}."
: $"Launcher RPC listening on TCP {mPort} ({mBind} only).");
}
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 ?? "???";
List<JToken> args = request.Args ?? new List<JToken>();
// 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;
}
}
}
// RPC args surface as Newtonsoft JTokens (the Contract is net40;
// System.Text.Json has no net40 target).
private static bool IsNull(JToken arg) => arg == null || arg.Type == JTokenType.Null;
private static T Arg<T>(JToken arg) => arg.ToObject<T>(PodRpc.JsonOptions);
/// <summary>Maps the console's method names (dispatch-by-name, including the
/// get_/set_ property accessors) onto the VirtualLauncher. Mirrors the real
/// service's DispatchCommandAsync.</summary>
private object Dispatch(string method, List<JToken> args)
{
switch (method)
{
case "Ping":
return args.Count > 0 && !IsNull(args[0])
? mLauncher.Ping(Arg<DateTime>(args[0]))
: 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(Arg<Guid>(args[0]));
case "InitiateInstallProduct":
return mLauncher.InitiateInstallProduct();
case "InstallApp":
mLauncher.InstallApp(Arg<LaunchData>(args[0]));
return null;
case "UninstallApp":
mLauncher.UninstallApp(Arg<Guid>(args[0]));
return null;
case "RemoveApp":
mLauncher.RemoveApp(Arg<Guid>(args[0]));
return null;
case "LaunchApp":
return mLauncher.LaunchApp(Arg<Guid>(args[0]));
case "KillApp":
mLauncher.KillApp(Arg<Guid>(args[0]), Arg<int>(args[1]));
return null;
case "KillAllOfType":
mLauncher.KillAllOfType(Arg<Guid>(args[0]));
return null;
case "KillAllApps":
mLauncher.KillAllApps();
return null;
case "Shutdown":
mLauncher.Shutdown(Arg<bool>(args[0]));
return null;
case "ClearStore":
mLauncher.ClearStore();
return null;
case "get_VolumeLevel":
return mLauncher.VolumeLevel;
case "set_VolumeLevel":
mLauncher.VolumeLevel = Arg<float>(args[0]);
return null;
default:
Log?.Invoke($"Unknown command \"{method}\" — answering null.");
return null;
}
}
/// <summary>Receives the out-of-band product zip and extracts it into the
/// games root (the real C:\Games, like the launcher; tests override it),
/// reporting progress exactly like the real service:
/// 0-50% receive, 50-95% extract, 99% "Complete" (IsCompleted).</summary>
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);
// The Launcher's own extractor (zip-slip protection included): net40
// has no ZipFile/ZipArchive, and sharing it keeps vPOD's extraction
// byte-identical to the real pod service.
MiniZip.ExtractToDirectory(tempZip, gamesRoot, (done, total) =>
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 only does so when the operator opts in via RunPackageScripts;
// otherwise the script is logged and removed unrun (default), since it
// runs package code on the host machine.
string postInstall = Path.Combine(gamesRoot, "postinstall.bat");
if (File.Exists(postInstall))
{
if (mLauncher.RunPackageScripts)
{
mLauncher.UpdateProgress(callId, 96, "Running postinstall...");
Log?.Invoke($"Install {callId:N}: running postinstall.bat...");
try
{
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = "/c \"" + postInstall + "\"",
WorkingDirectory = gamesRoot,
UseShellExecute = false,
CreateNoWindow = true
};
using (Process proc = Process.Start(psi))
{
if (proc.WaitForExit(300000))
{
Log?.Invoke($"Install {callId:N}: postinstall.bat exited with code {proc.ExitCode}.");
}
else
{
Log?.Invoke($"Install {callId:N}: postinstall.bat still running after 5 min — leaving it, continuing.");
}
}
}
catch (Exception ex)
{
Log?.Invoke($"Install {callId:N}: postinstall.bat failed to run: {ex.Message}");
}
}
else
{
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;
}
}