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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user