vPOD: virtual launcher — test Site Management / product deploys without a cockpit

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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-09 20:45:33 -05:00
co-authored by Claude Fable 5
parent fd0c8c5196
commit 1daaf4af78
10 changed files with 2024 additions and 4 deletions
@@ -29,6 +29,8 @@
<ItemGroup>
<Reference Include="System.Drawing" />
<Reference Include="System.Xml" />
<!-- Zip building for the VPodLauncherServerTests InstallProduct round-trip -->
<Reference Include="System.IO.Compression" />
</ItemGroup>
<ItemGroup>
@@ -53,6 +55,9 @@
<!-- The source-built secure-config (emits TeslaSecureConfiguration.dll), for
SecureConfigCompatTests' byte-identity checks vs the original vendored DLL. -->
<ProjectReference Include="..\..\..\SecureConfig\Tesla.SecureConfig.csproj" />
<!-- vPOD's virtual launcher (server side of the pod RPC), exercised end-to-end
against the real PodManagerConnection client in VPodLauncherServerTests. -->
<ProjectReference Include="..\..\vPOD\vPOD.csproj" />
</ItemGroup>
</Project>
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<IOException>(
() => 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;
}
}
}
@@ -0,0 +1,83 @@
using System;
using System.Net;
using System.Threading;
using Tesla;
using VPod;
using Xunit;
namespace TeslaConsole.DiffTests
{
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
}
}
+402
View File
@@ -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;
/// <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" />; unlike the real service,
/// postinstall.bat from a package is logged but never executed.
/// </summary>
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<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)
{
mLauncher = launcher;
mPort = port;
}
/// <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(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<JsonElement> args = request.Args ?? new List<JsonElement>();
// 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;
}
}
}
/// <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, IReadOnlyList<JsonElement> 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<LaunchData>(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;
}
}
/// <summary>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).</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);
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;
}
}
+9
View File
@@ -14,6 +14,10 @@ namespace VPod;
/// <c>-app rp|bt</c> which ApplicationID to report (RP by default; also
/// switchable live in the UI)
/// <c>-host &lt;id&gt;</c> the responding host id reported in state responses
///
/// vPOD-only (not a real game-client option):
/// <c>-nomanage</c> disable the virtual launcher / site-management side
/// (no provisioning beacons, no TCP 53290 listener)
/// </summary>
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;
+378
View File
@@ -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;
/// <summary>
/// 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 &lt;RequestId&gt;" 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.
/// </summary>
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<string> Log;
public event Action<BasicConfigResponse> ConfigReceived; // display-only network config
public event Action<byte[]> Provisioned; // the 32-byte session key
public string RequestId { get; private set; }
public string Passphrase { get; private set; }
public bool IsRunning => mRunning;
/// <summary>The pod's stable fake MAC: locally-administered "VPOD" + host id, so
/// the console recognizes the same virtual pod across reprovisions (Site.FindPod).</summary>
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);
}
}
/// <summary>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.</summary>
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;
}
}
/// <summary>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)).</summary>
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;
}
/// <summary>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.</summary>
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);
}
}
+79 -1
View File
@@ -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 <port>] [-app rp|bt] [-lc|-mr] [-host <id>] [-res W H]
vPOD.exe [-net <port>] [-app rp|bt] [-lc|-mr] [-host <id>] [-res W H] [-nomanage]
```
- `-net <port>` Munga control port (default **1501**).
@@ -47,6 +53,78 @@ vPOD.exe [-net <port>] [-app rp|bt] [-lc|-mr] [-host <id>] [-res W H]
- `-host <id>` 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 &lt;Request ID&gt;"** 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 [<n> 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 `050%` receive / `5095%`
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)
+380 -3
View File
@@ -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.
/// </summary>
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();
}
/// <summary>
/// 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).
/// </summary>
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
/// <summary>Reflects the on/off state in the buttons and, when off, the status labels.</summary>
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 ----
/// <summary>Brings the launcher side up for a powered-on pod: the RPC server
/// when a session key is stored, otherwise the provisioning beacons.</summary>
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;
}
/// <summary>Drops the session key and re-enters provisioning — from the UI
/// button, or when the console's Reconfigure clears our store.</summary>
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}%)";
}));
}
/// <summary>The console asked the pod to shut down or restart: go fully dark
/// (Munga + launcher), and come back after a "reboot" pause when restarting.</summary>
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);
}
/// <summary>The console's Reconfigure cleared our store — drop the key and go
/// back to beaconing so the "Configure" button reappears in Manage Site.</summary>
private void OnReprovisionRequested()
{
if (IsDisposed) return;
BeginInvoke((Action)DropKeyAndReprovision);
}
// ---- label helpers ----
private void UpdateListeningLabel(bool listening)
+398
View File
@@ -0,0 +1,398 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Tesla.Net;
namespace VPod;
/// <summary>
/// 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 <see cref="LauncherRpcServer" /> 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)
/// </summary>
internal sealed class VirtualLauncher
{
private readonly object mLock = new object();
private readonly List<LaunchData> mInstalledApps = new List<LaunchData>();
private readonly List<LaunchedAppData> mLaunchedApps = new List<LaunchedAppData>();
private readonly Dictionary<Guid, OutOfBandProgress> mInstallProgress = new Dictionary<Guid, OutOfBandProgress>();
private float mVolumeLevel = 1.0f;
private int mNextPid = 4000;
public event Action<string> Log;
public event Action AppsChanged; // installed or launched list changed
public event Action<Guid, OutOfBandProgress> InstallProgressChanged;
public event Action<bool> 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) ----
/// <summary>The provisioned session key, or null when unprovisioned.</summary>
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
};
}
}
/// <summary>Registers a launch entry (upsert by LaunchKey) — the per-entry call
/// the console makes after a product install, and the write behind LaunchApps.json.</summary>
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);
}
/// <summary>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.</summary>
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<LaunchData[]>(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);
}
}
}
+22
View File
@@ -32,6 +32,18 @@
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<!-- The differential suite drives the real PodManagerConnection client against
vPOD's LauncherRpcServer in-process (VPodLauncherServerTests). -->
<InternalsVisibleTo Include="TeslaConsole.DiffTests" />
</ItemGroup>
<ItemGroup>
<!-- Framework assemblies for extracting InstallProduct zips (virtual launcher) -->
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
</ItemGroup>
<ItemGroup>
<!-- The Munga wire types (messages, header, enums). Copied next to vPOD.exe so
the deployable package is self-contained. -->
@@ -41,4 +53,14 @@
</Reference>
</ItemGroup>
<ItemGroup>
<!-- The shared console<->launcher wire libraries, so vPOD can also stand in
for the pod's TeslaLauncher service (Site Management / Install Product):
PodRpc framing + ILauncherService wire types, and the SecureConfig
provisioning protocol + OFB crypto-stream handshake. vPOD implements the
SERVER side of the existing contract only - no new RPCs. -->
<ProjectReference Include="..\..\Contract\Tesla.Contract.csproj" />
<ProjectReference Include="..\..\SecureConfig\Tesla.SecureConfig.csproj" />
</ItemGroup>
</Project>