Files
TeslaSuite/Launcher/TeslaLauncherService.cs
T
CydandClaude Opus 4.8 548550b312 Initial commit: TeslaSuite monorepo (TeslaConsole + TeslaLauncher)
Co-locate the two cockpit-pod projects into a single repository:

- Console/  : TeslaConsole, the net48 WinForms operator console (decompiled
              reconstruction) plus its differential + catalog test suite.
- Launcher/ : TeslaLauncher, the net6 pod-side Service + Agent rewrite.

Adds a combined TeslaSuite.sln, root README documenting the shared wire
contract (and its current duplication, the main follow-up), and a root
.gitignore. Histories were not preserved per request; this is a fresh start
from the current working state of both projects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:43:28 -05:00

1104 lines
48 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// TeslaLauncher — Windows Service (Session 0)
// =============================================================================
// Replaces TeslaLauncherService.exe (Elsewhen Studios LLC, .NET Framework 2.0).
// Runs in Session 0, auto-starts at boot before user login.
// Listens on TCP 53290 for OFB-encrypted BinaryFormatter RPC from TeslaConsole.
// Forwards commands to TeslaLauncherAgent (user session) via Named Pipe.
//
// On first boot (DHCP detected), runs SecureConfig to receive network
// configuration from the Console and establish the session key.
// =============================================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.IO.Pipes;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeslaSecureConfig;
using Tesla.Launcher.Shared;
using Tesla.Net;
#pragma warning disable SYSLIB0011 // BinaryFormatter is required for Console wire compatibility
namespace Tesla.Launcher.Service
{
public class TeslaLauncherService : BackgroundService
{
// ---------------------------------------------------------------
// CONFIGURATION
// ---------------------------------------------------------------
/// <summary>
/// TCP port the Console connects to for RPC commands.
/// Original: ManagePort = 53290 (confirmed from decompiled TeslaLauncherService.exe).
/// Each connection is wrapped in OFB encryption via NegotiateCryptoStreams
/// using the session key from SecureConfig (TeslaKeyStore.key).
/// </summary>
private const int CONSOLE_PORT = 53290;
/// <summary>
/// Named pipe connecting this service to TeslaLauncherAgent.exe.
/// Must match PIPE_NAME in TeslaLauncherAgent.cs.
/// </summary>
private const string PIPE_NAME = "TeslaLauncherIPC";
/// <summary>
/// Seconds to wait for the Agent to connect on the Named Pipe.
/// If the Agent isn't running (user not yet logged in), we return an error.
/// </summary>
private const int AGENT_CONNECT_TIMEOUT_MS = 3000;
// ---------------------------------------------------------------
/// <summary>Path to session key file (original KeyStore format).</summary>
private static readonly string KEY_FILE = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"TeslaLauncher", "TeslaKeyStore.key");
private readonly ILogger<TeslaLauncherService> _logger;
private TcpListener _listener;
private readonly List<Task> _clientTasks = new();
private readonly object _taskLock = new();
private byte[] _sessionKey;
// Install product — file transfer progress tracking.
// InitiateInstallProduct creates a Guid; the Console then streams the
// game archive on the SAME OFB connection. GetOutOfBandProgress polls
// this dictionary from a separate connection.
private readonly Dictionary<Guid, OutOfBandProgress> _installProgress = new();
private readonly object _installLock = new();
public TeslaLauncherService(ILogger<TeslaLauncherService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Tesla Launcher Service starting on port {Port}", CONSOLE_PORT);
// Enable BinaryFormatter at runtime. Must also be set in .csproj.
// Both switches are required on .NET 6+.
AppContext.SetSwitch(
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization", true);
// ── Boot diagnostic — always written so every reboot is traceable ──
// Logs adapter state to podconf.log before IsMachineConfigured() decides,
// visible even if SecureConfig does not run.
var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
void StartupLog(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
StartupLog("=== Service starting ===");
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Ethernet) continue;
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
try
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
StartupLog($" NIC [{nic.Name}] DHCP={ipv4.IsDhcpEnabled} Virtual={IsVirtualAdapter(nic)}");
}
catch { StartupLog($" NIC [{nic.Name}] (no IPv4 props)"); }
}
// ── First-boot secure configuration ──────────────────────────────
// If the Ethernet adapter is still on DHCP the cockpit has not yet
// been configured. Run PodSecureConfigurator here in Session 0:
// • broadcasts the RQST beacon (MAC + RequestId)
// • displays passphrase on COM2 / PlasmaIO
// • receives UDP RPLY with AES-encrypted network configuration
// • applies netsh IP settings and hostname
// • completes TCP handshake with Console + RSA key exchange
// • saves 32-byte session key to TeslaKeyStore.key
// After SecureConfig, fall through to the normal command listener —
// the original Service does the same (ExecutionMode after Configure).
if (!IsMachineConfigured())
{
StartupLog("IsMachineConfigured=false → running SecureConfig");
_logger.LogInformation("Machine is unconfigured (DHCP). Running SecureConfiguration.");
await Task.Run(() => RunSecureConfiguration(stoppingToken), stoppingToken);
// Load key that was saved during SecureConfig
_sessionKey = LoadSessionKey();
if (_sessionKey == null)
{
_logger.LogWarning("SecureConfig completed but no session key was saved. Cannot start command listener.");
return;
}
StartupLog("SecureConfig complete → starting command listener on port 53290");
}
else
{
StartupLog("IsMachineConfigured=true → loading session key");
_sessionKey = LoadSessionKey();
if (_sessionKey == null)
{
_logger.LogWarning("Machine is configured but TeslaKeyStore.key not found. Reboot with DHCP to re-run SecureConfig.");
StartupLog("ERROR: TeslaKeyStore.key not found — cannot start command listener");
return;
}
StartupLog($"Session key loaded ({_sessionKey.Length} bytes) → starting normal service");
}
_logger.LogInformation("Starting command listener on port {Port}.", CONSOLE_PORT);
try
{
_listener = new TcpListener(IPAddress.Any, CONSOLE_PORT);
_listener.Start();
_logger.LogInformation("Listening. Waiting for Console connections...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var client = await _listener.AcceptTcpClientAsync(stoppingToken);
_logger.LogInformation(
"Console connected from {EP}", client.Client.RemoteEndPoint);
// Handle each Console connection on its own thread.
// BinaryFormatter.Deserialize is synchronous, so we
// offload to a thread-pool thread via Task.Run.
var task = Task.Run(
() => HandleConsoleClient(client, stoppingToken),
stoppingToken);
lock (_taskLock)
{
_clientTasks.Add(task);
_clientTasks.RemoveAll(t => t.IsCompleted);
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Error accepting Console connection");
}
}
}
finally
{
_listener?.Stop();
_logger.LogInformation("Tesla Launcher Service stopped.");
}
}
// ── Console Connection Handler ───────────────────────────────────
private async Task HandleConsoleClient(TcpClient tcpClient, CancellationToken ct)
{
using var client = tcpClient;
using var netStream = client.GetStream();
netStream.WriteTimeout = 10_000;
netStream.ReadTimeout = 30_000;
// ── OFB crypto negotiation ────────────────────────────────────
// Both sides exchange 16-byte IVs, then verify "CONF" handshake.
// Read Console's IV
var consoleIv = new byte[16];
try { ReadExactSync(netStream, consoleIv); }
catch (Exception ex)
{
_logger.LogWarning("Failed reading Console IV: {Msg}", ex.Message);
return;
}
// Generate and send our IV
var podIv = new byte[16];
RandomNumberGenerator.Fill(podIv);
netStream.Write(podIv, 0, 16);
netStream.Flush();
// Create OFB duplex stream — lives for the entire session
using var ofb = new OFBDuplexStream(netStream, _sessionKey,
writeIv: consoleIv, // pod→console (other's IV)
readIv: podIv); // console→pod (own IV)
// CONF handshake — verify both sides have the same session key
var confMsg = Encoding.UTF8.GetBytes("CONF");
ofb.Write(confMsg, 0, confMsg.Length);
ofb.Flush();
var confReply = new byte[4];
try { ReadExactSync(ofb, confReply); }
catch (Exception ex)
{
_logger.LogWarning("CONF read failed: {Msg}", ex.Message);
return;
}
if (!confReply.AsSpan().SequenceEqual(confMsg))
{
_logger.LogWarning("CONF mismatch — session key mismatch, dropping connection.");
return;
}
Stream rpcStream = ofb;
var formatter = CreateFormatter();
_logger.LogInformation("Console session started.");
try
{
while (!ct.IsCancellationRequested && client.Connected)
{
InvokeCommand command;
try
{
MethodInfoProxy.LastResolvedName = null;
var obj = formatter.Deserialize(rpcStream);
command = (InvokeCommand)obj;
}
catch (IOException)
{
_logger.LogInformation("Console disconnected (EOF).");
break;
}
catch (SerializationException ex)
when (ex.Message.Contains("End of Stream"))
{
_logger.LogInformation("Console disconnected (end of stream).");
break;
}
catch (SerializationException ex)
when (ex.InnerException is IOException)
{
// Socket timeout or reset — Console went idle or disconnected
_logger.LogInformation("Console disconnected (I/O: {Msg}).",
ex.InnerException.Message);
break;
}
catch (Exception ex)
{
_logger.LogWarning("Deserialization error ({Type}): {Msg}",
ex.GetType().Name, ex.Message);
File.AppendAllText(
Path.Combine(AppContext.BaseDirectory, "podconf.log"),
$"[{DateTime.Now:HH:mm:ss}] DESER ERROR: {ex}{Environment.NewLine}");
break;
}
// Fall back to ThreadStatic name if IObjectReference fixup
// didn't update the boxed struct field.
var funcName = command.Function?.Name
?? MethodInfoProxy.LastResolvedName ?? "???";
_logger.LogInformation(
"Command: {Func}({Args})", funcName,
command.Parameters != null
? string.Join(", ", command.Parameters) : "");
var start = DateTime.UtcNow;
var result = new InvokeResult();
var diagLog = Path.Combine(AppContext.BaseDirectory, "podconf.log");
try
{
result.Result = await DispatchCommandAsync(
funcName, command.Parameters, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing {Func}", funcName);
result.Exception = new Exception(ex.Message);
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] CMD {funcName} ERROR: {ex.Message}{Environment.NewLine}");
}
result.CallDuration = DateTime.UtcNow - start;
if (result.Exception == null)
{
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] CMD {funcName} OK ({result.CallDuration.TotalMilliseconds:F0}ms)" +
$"{(result.Result != null ? $" {result.Result}" : "")}{Environment.NewLine}");
}
try
{
formatter.Serialize(rpcStream, result);
rpcStream.Flush();
}
catch (IOException)
{
_logger.LogInformation("Console disconnected while writing response.");
break;
}
catch (Exception serEx)
{
// Result type not BinaryFormatter-serializable — send error instead
_logger.LogWarning("Serialize failed for {Func}: {Msg}",
funcName, serEx.Message);
var fallback = new InvokeResult
{
Exception = new Exception($"Pod error: {serEx.Message}"),
CallDuration = result.CallDuration
};
try
{
formatter.Serialize(rpcStream, fallback);
rpcStream.Flush();
}
catch { break; }
}
// ── File receive after InitiateInstallProduct ────────────
// The Console streams the game archive on this same OFB
// connection immediately after receiving the Guid response:
// 8-byte Int64 file size + raw file bytes
// Then closes the connection (FIN).
if (funcName == "InitiateInstallProduct"
&& result.Exception == null
&& result.Result is Guid installGuid)
{
ReceiveInstallFile(rpcStream, installGuid, diagLog);
break; // connection done after file transfer
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error in Console handler");
var diagLog = Path.Combine(AppContext.BaseDirectory, "podconf.log");
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] HANDLER ERROR: {ex.GetType().Name}: {ex.Message}{Environment.NewLine}" +
$"[{DateTime.Now:HH:mm:ss}] Inner: {ex.InnerException?.Message}{Environment.NewLine}");
}
_logger.LogInformation("Console session ended.");
}
// ── Command Dispatch ─────────────────────────────────────────────
private async Task<object> DispatchCommandAsync(
string functionName, object[] parameters, CancellationToken ct)
{
switch (functionName)
{
case "Ping":
// Echo back the DateTime parameter (Console measures round-trip)
return parameters?.Length > 0 ? parameters[0] : DateTime.Now;
case "GetLaunchableApps":
{
var json = await ForwardToAgentJsonAsync(functionName, parameters, ct);
var wire = FlatArrayToWire(json);
var pairs = new LaunchPair[wire.Length];
for (int i = 0; i < wire.Length; i++)
pairs[i] = wire[i].LaunchPair;
return pairs;
}
case "GetInstalledApps":
{
var json = await ForwardToAgentJsonAsync(functionName, parameters, ct);
return FlatArrayToWire(json);
}
case "GetLaunchedApps":
{
var json = await ForwardToAgentJsonAsync(functionName, parameters, ct);
return FlatLaunchedArrayToWire(json);
}
case "FullUpdate":
{
var json = await ForwardToAgentJsonAsync(functionName, parameters, ct);
if (json == null) return new FullUpdateData();
var flat = JsonSerializer.Deserialize<FlatFullUpdateData>(json);
if (flat == null) return new FullUpdateData();
var result2 = new FullUpdateData
{
InstalledApps = flat.InstalledApps != null
? Array.ConvertAll(flat.InstalledApps, FlatToWire)
: Array.Empty<LaunchData>(),
LaunchedApps = flat.LaunchedApps != null
? Array.ConvertAll(flat.LaunchedApps, FlatLaunchedToWire)
: Array.Empty<LaunchedAppData>(),
VolumeLevel = flat.VolumeLevel
};
return result2;
}
case "GetOutOfBandProgress":
{
// Handled directly in the Service — progress is tracked
// during file receive on the InitiateInstallProduct connection.
var diagPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
if (parameters?.Length > 0)
{
var p0 = parameters[0];
var p0Type = p0?.GetType().FullName ?? "null";
Guid progressId;
bool guidMatch = false;
if (p0 is Guid g)
{
progressId = g;
guidMatch = true;
}
else if (p0 is string s && Guid.TryParse(s, out var gs))
{
progressId = gs;
guidMatch = true;
}
else
{
progressId = Guid.Empty;
}
if (guidMatch)
{
lock (_installLock)
{
if (_installProgress.TryGetValue(progressId, out var prog))
{
// Log first poll and when status changes
_logger.LogDebug(
"GetOutOfBandProgress({Id}): {Pct}% {Status} done={Done}",
progressId, prog.PercentComplete, prog.Status, prog.IsCompleted);
return prog;
}
}
File.AppendAllText(diagPath,
$"[{DateTime.Now:HH:mm:ss}] OOBP: Guid {progressId} NOT FOUND in _installProgress (type={p0Type}, keys={string.Join(",", GetProgressKeys())}){Environment.NewLine}");
}
else
{
File.AppendAllText(diagPath,
$"[{DateTime.Now:HH:mm:ss}] OOBP: param[0] type={p0Type} value={p0} — NOT a Guid{Environment.NewLine}");
}
}
else
{
File.AppendAllText(diagPath,
$"[{DateTime.Now:HH:mm:ss}] OOBP: NO parameters{Environment.NewLine}");
}
return new OutOfBandProgress
{
PercentComplete = 0,
Status = "Unknown call ID",
IsCompleted = true
};
}
case "LaunchApp":
{
var json = await ForwardToAgentJsonAsync(functionName, parameters, ct);
if (json == null) return 0;
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ProcessId", out var pid))
return pid.GetInt32();
return 0;
}
case "get_VolumeLevel":
{
var json = await ForwardToAgentJsonAsync(functionName, parameters, ct);
return json != null ? JsonSerializer.Deserialize<float>(json) : 1.0f;
}
case "InitiateInstallProduct":
{
var callId = Guid.NewGuid();
lock (_installLock)
{
_installProgress[callId] = new OutOfBandProgress
{
PercentComplete = 0,
Status = "Waiting for file...",
IsCompleted = false
};
}
return callId;
}
case "KillApp":
case "KillAllOfType":
case "KillAllApps":
case "Shutdown":
case "ClearStore":
case "RemoveApp":
case "UninstallApp":
case "InstallApp":
{
// Flatten nested LaunchPair for Agent's flat JSON format
if (parameters?.Length > 0 && parameters[0] is LaunchData ld)
{
var flat = new
{
LaunchKey = ld.LaunchPair.LaunchKey.ToString(),
DisplayName = ld.LaunchPair.DisplayName,
WorkingDirectory = ld.WorkingDirectory,
ExeFile = ld.ExeFile,
Arguments = ld.Arguments,
AutoRestart = ld.AutoRestart
};
parameters = new object[] { flat };
}
await ForwardToAgentJsonAsync(functionName, parameters, ct);
return null;
}
case "set_VolumeLevel":
await ForwardToAgentJsonAsync(functionName, parameters, ct);
return null;
default:
_logger.LogWarning("Unknown command: {Func}", functionName);
await ForwardToAgentJsonAsync(functionName, parameters, ct);
return null;
}
}
// ── Install Product File Receive ─────────────────────────────────
private const string GAMES_DIR = @"C:\Games";
private void ReceiveInstallFile(Stream stream, Guid callId, string logPath)
{
void Log(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
string tempZip = null;
try
{
// ── Phase 1: Receive file ────────────────────────────────
var sizeBuf = new byte[8];
ReadExactSync(stream, sizeBuf);
long fileSize = BitConverter.ToInt64(sizeBuf, 0);
Log($"INSTALL {callId:N}: receiving {fileSize:N0} bytes...");
UpdateProgress(callId, 0, "Receiving file...");
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"TeslaLauncher");
Directory.CreateDirectory(dataDir);
tempZip = Path.Combine(dataDir, $"install_{callId:N}.zip");
using (var fs = File.Create(tempZip))
{
var buffer = new byte[65536];
long received = 0;
while (received < fileSize)
{
var toRead = (int)Math.Min(buffer.Length, fileSize - received);
var n = stream.Read(buffer, 0, toRead);
if (n == 0) throw new IOException("Connection closed during file transfer.");
fs.Write(buffer, 0, n);
received += n;
// Report receive progress as 050%
int pct = (int)(received * 50 / fileSize);
UpdateProgress(callId, pct, "Receiving file...");
}
}
Log($"INSTALL {callId:N}: file received ({fileSize:N0} bytes)");
// ── Phase 2: Extract to C:\Games\ ────────────────────────
UpdateProgress(callId, 50, "Extracting...");
Directory.CreateDirectory(GAMES_DIR);
using (var zip = ZipFile.OpenRead(tempZip))
{
int total = zip.Entries.Count;
int done = 0;
foreach (var entry in zip.Entries)
{
var destPath = Path.GetFullPath(
Path.Combine(GAMES_DIR, entry.FullName));
// Zip-slip protection
if (!destPath.StartsWith(GAMES_DIR, StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrEmpty(entry.Name))
{
// Directory entry
Directory.CreateDirectory(destPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
entry.ExtractToFile(destPath, overwrite: true);
}
done++;
// Report extract progress as 5095%
int pct = 50 + (done * 45 / Math.Max(total, 1));
UpdateProgress(callId, pct, "Extracting...");
}
}
Log($"INSTALL {callId:N}: extracted to {GAMES_DIR}");
// ── Phase 3: Run postinstall.bat if present ──────────────
var postInstall = Path.Combine(GAMES_DIR, "postinstall.bat");
if (File.Exists(postInstall))
{
UpdateProgress(callId, 96, "Running postinstall...");
Log($"INSTALL {callId:N}: running postinstall.bat");
try
{
using var proc = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo
{
FileName = postInstall,
WorkingDirectory = GAMES_DIR,
UseShellExecute = false,
CreateNoWindow = true
});
proc?.WaitForExit(60_000); // 60 s timeout
}
catch (Exception ex)
{
Log($"INSTALL {callId:N}: postinstall.bat error: {ex.Message}");
}
try { File.Delete(postInstall); } catch { }
Log($"INSTALL {callId:N}: postinstall.bat completed and removed");
}
// ── Done ─────────────────────────────────────────────────
// Console checks PercentComplete == 99 to break out of its
// 3-iteration install loop (InstallProductWorker in PodInfo).
// Using 100 causes the Console to retry all 3 times.
UpdateProgress(callId, 99, "Complete", isCompleted: true);
Log($"INSTALL {callId:N}: install complete");
}
catch (Exception ex)
{
UpdateProgress(callId, 0, $"Failed: {ex.Message}", isCompleted: true);
Log($"INSTALL {callId:N}: FAILED: {ex.Message}");
_logger.LogError(ex, "Install {Id} failed", callId);
}
finally
{
// Clean up temp zip
try { if (tempZip != null) File.Delete(tempZip); } catch { }
}
}
private string[] GetProgressKeys()
{
lock (_installLock)
{
return Array.ConvertAll(
new List<Guid>(_installProgress.Keys).ToArray(),
g => g.ToString());
}
}
// ── Flat ↔ Wire Conversion ────────────────────────────────────────
private class FlatLaunchData
{
public string LaunchKey { get; set; }
public string DisplayName { get; set; }
public string WorkingDirectory { get; set; }
public string ExeFile { get; set; }
public string Arguments { get; set; }
public bool AutoRestart { get; set; }
}
private class FlatLaunchedAppData
{
public string LaunchKey { get; set; }
public int ProcessId { get; set; }
}
private class FlatFullUpdateData
{
public FlatLaunchData[] InstalledApps { get; set; }
public FlatLaunchedAppData[] LaunchedApps { get; set; }
public float VolumeLevel { get; set; }
}
private static LaunchData FlatToWire(FlatLaunchData f)
{
return new LaunchData
{
LaunchPair = new LaunchPair
{
LaunchKey = Guid.TryParse(f.LaunchKey, out var g) ? g : Guid.Empty,
DisplayName = f.DisplayName
},
WorkingDirectory = f.WorkingDirectory,
ExeFile = f.ExeFile,
Arguments = f.Arguments,
AutoRestart = f.AutoRestart
};
}
private static LaunchedAppData FlatLaunchedToWire(FlatLaunchedAppData f)
{
return new LaunchedAppData
{
LaunchKey = Guid.TryParse(f.LaunchKey, out var g) ? g : Guid.Empty,
ProcessId = f.ProcessId
};
}
private static LaunchedAppData[] FlatLaunchedArrayToWire(string json)
{
if (json == null) return Array.Empty<LaunchedAppData>();
var flat = JsonSerializer.Deserialize<FlatLaunchedAppData[]>(json);
if (flat == null) return Array.Empty<LaunchedAppData>();
return Array.ConvertAll(flat, FlatLaunchedToWire);
}
private static LaunchData[] FlatArrayToWire(string json)
{
if (json == null) return Array.Empty<LaunchData>();
var flat = JsonSerializer.Deserialize<FlatLaunchData[]>(json);
if (flat == null) return Array.Empty<LaunchData>();
var result = new LaunchData[flat.Length];
for (int i = 0; i < flat.Length; i++)
result[i] = FlatToWire(flat[i]);
return result;
}
private void UpdateProgress(Guid callId, int pct, string status, bool isCompleted = false)
{
lock (_installLock)
{
_installProgress[callId] = new OutOfBandProgress
{
PercentComplete = pct,
Status = status,
IsCompleted = isCompleted
};
}
}
// ── Secure Configuration ─────────────────────────────────────────
private static bool IsVirtualAdapter(System.Net.NetworkInformation.NetworkInterface nic)
{
var desc = nic.Description ?? ""; var name = nic.Name ?? "";
string[] markers = { "Virtual", "Hyper-V", "VMware", "VirtualBox",
"Loopback", "Tunnel", "Miniport", "Wi-Fi Direct", "Bluetooth" };
foreach (var m in markers)
if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0) return true;
var mac = nic.GetPhysicalAddress().GetAddressBytes();
if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true;
return false;
}
private static bool IsMachineConfigured()
{
foreach (var nic in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.NetworkInterfaceType != System.Net.NetworkInformation.NetworkInterfaceType.Ethernet) continue;
if (nic.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
if (IsVirtualAdapter(nic)) continue;
try
{
var ipv4 = nic.GetIPProperties().GetIPv4Properties();
if (!ipv4.IsDhcpEnabled) return true; // static = configured
}
catch (System.Net.NetworkInformation.NetworkInformationException) { }
}
return false;
}
private void RunSecureConfiguration(CancellationToken ct)
{
var logPath = Path.Combine(AppContext.BaseDirectory, "podconf.log");
void Log(string msg) => File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
try
{
using var configurator = new PodSecureConfigurator(
adapterName: null,
logger: msg => { Log(msg); _logger.LogInformation(msg); });
// 10-minute timeout — rebroadcasts automatically every 2 s
var config = configurator.Configure(timeoutMs: 600_000);
if (config == null)
{
_logger.LogWarning(
"SecureConfiguration timed out. Machine will need a reboot to retry.");
return;
}
_logger.LogInformation("SecureConfiguration complete.");
}
catch (Exception ex)
{
_logger.LogError(ex, "SecureConfiguration failed");
}
}
// ── Session Key ──────────────────────────────────────────────────
private static byte[] LoadSessionKey()
{
if (!File.Exists(KEY_FILE)) return null;
using var fs = File.Open(KEY_FILE, FileMode.Open, FileAccess.Read, FileShare.Read);
var len = fs.ReadByte();
if (len <= 0) return null;
var key = new byte[len];
int off = 0;
while (off < key.Length)
{
int n = fs.Read(key, off, key.Length - off);
if (n == 0) break;
off += n;
}
return off == key.Length ? key : null;
}
/// <summary>Reads exactly buf.Length bytes from stream; throws IOException on EOF.</summary>
private static void ReadExactSync(Stream stream, byte[] buf)
{
int off = 0;
while (off < buf.Length)
{
int n = stream.Read(buf, off, buf.Length - off);
if (n == 0) throw new IOException("Connection closed before all bytes received.");
off += n;
}
}
// ── BinaryFormatter Setup ────────────────────────────────────────
private static BinaryFormatter CreateFormatter()
{
return new BinaryFormatter
{
Binder = new TeslaSerializationBinder()
};
}
// ── Named Pipe IPC ───────────────────────────────────────────────
private async Task<string> ForwardToAgentJsonAsync(
string functionName, object[] parameters, CancellationToken ct)
{
string launchKey = null;
if (parameters?.Length > 0 && parameters[0] is Guid guid)
launchKey = guid.ToString();
var msg = new IpcMessage
{
Command = functionName.ToUpperInvariant(),
LaunchKey = launchKey,
PayloadJson = parameters?.Length > 0
? JsonSerializer.Serialize(parameters) : null
};
using var pipe = new NamedPipeClientStream(
".", PIPE_NAME, PipeDirection.InOut, PipeOptions.Asynchronous);
try
{
await pipe.ConnectAsync(AGENT_CONNECT_TIMEOUT_MS, ct);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Agent pipe timeout — is TeslaLauncherAgent.exe running?");
throw new Exception(
"Cockpit agent is not available. " +
"Ensure TeslaLauncherAgent.exe is running in the user session.");
}
// Send IpcMessage
var reqBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg));
await pipe.WriteAsync(BitConverter.GetBytes(reqBytes.Length), ct);
await pipe.WriteAsync(reqBytes, ct);
await pipe.FlushAsync(ct);
// Read IpcResponse
var lenBuf = new byte[4];
await ReadExactAsync(pipe, lenBuf, ct);
var resBuf = new byte[BitConverter.ToInt32(lenBuf, 0)];
await ReadExactAsync(pipe, resBuf, ct);
var response = JsonSerializer.Deserialize<IpcResponse>(
Encoding.UTF8.GetString(resBuf));
if (response == null)
throw new Exception("Null response from Agent.");
if (!response.Success)
throw new Exception(response.Message ?? "Agent returned failure.");
if (response.Data is System.Text.Json.JsonElement je)
return je.GetRawText();
return response.Data?.ToString();
}
private static async Task ReadExactAsync(Stream stream, byte[] buf, CancellationToken ct)
{
var off = 0;
while (off < buf.Length)
{
var n = await stream.ReadAsync(buf, off, buf.Length - off, ct);
if (n == 0) throw new IOException("Pipe closed before all bytes were read.");
off += n;
}
}
}
// ── Serialization Binder ─────────────────────────────────────────────
// BindToType: resolves Console-side type names to local Tesla.Net types.
// BindToName: writes "TeslaConsoleLaunchLib" as assembly for outgoing types.
internal sealed class TeslaSerializationBinder : SerializationBinder
{
private static readonly Assembly TeslaAssembly =
typeof(ILauncherService).Assembly;
public override Type BindToType(string assemblyName, string typeName)
{
// Intercept MemberInfoSerializationHolder — handles cross-runtime
// MethodBase deserialization (.NET Framework 2.0 → .NET 6).
if (typeName == "System.Reflection.MemberInfoSerializationHolder")
return typeof(MethodInfoProxy);
// Try to resolve Tesla.Net types from our assembly
var type = TeslaAssembly.GetType(typeName);
if (type != null) return type;
// Return null → default resolution (triggers AssemblyResolve if needed)
return null;
}
public override void BindToName(
Type serializedType, out string assemblyName, out string typeName)
{
if (serializedType.Namespace == "Tesla.Net")
{
// Console expects types from TeslaConsoleLaunchLib
assemblyName = "TeslaConsoleLaunchLib, Version=4.11.4.0, Culture=neutral, PublicKeyToken=null";
typeName = serializedType.FullName;
}
else
{
assemblyName = null;
typeName = null;
}
}
}
// ── MethodInfo Proxy ─────────────────────────────────────────────────
// Replaces MemberInfoSerializationHolder during deserialization.
// Resolves serialized MethodBase references to ILauncherService methods.
[Serializable]
internal sealed class MethodInfoProxy : ISerializable, IObjectReference
{
/// <summary>
/// Thread-static capture of the last deserialized method name.
/// IObjectReference fixup may fail to update fields inside boxed
/// value types (InvokeCommand is a struct), leaving Function null.
/// The handler reads this field as a fallback for the method name.
/// </summary>
[ThreadStatic]
internal static string LastResolvedName;
private readonly string _memberName;
private MethodInfoProxy(SerializationInfo info, StreamingContext ctx)
{
_memberName = info.GetString("Name");
LastResolvedName = _memberName;
}
public object GetRealObject(StreamingContext context)
{
// Look up the method on our ILauncherService interface.
// This handles both regular methods (Ping, LaunchApp, etc.)
// and property accessors (get_VolumeLevel, set_VolumeLevel).
var type = typeof(ILauncherService);
var method = type.GetMethod(_memberName,
BindingFlags.Public | BindingFlags.Instance);
if (method != null) return method;
// Property accessors: get_X / set_X
if (_memberName.StartsWith("get_") || _memberName.StartsWith("set_"))
{
var propName = _memberName.Substring(4);
var prop = type.GetProperty(propName,
BindingFlags.Public | BindingFlags.Instance);
if (prop != null)
{
var accessor = _memberName.StartsWith("get_")
? prop.GetMethod : prop.SetMethod;
if (accessor != null) return accessor;
}
}
throw new SerializationException(
$"Cannot resolve method '{_memberName}' on ILauncherService");
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
throw new NotSupportedException("MethodInfoProxy is read-only.");
}
}
// ── Entry Point ──────────────────────────────────────────────────────
public class Program
{
public static void Main(string[] args)
{
AppContext.SetSwitch(
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization", true);
// Redirect TeslaConsoleLaunchLib assembly references to our assembly
AppDomain.CurrentDomain.AssemblyResolve += (sender, resolveArgs) =>
{
var name = new AssemblyName(resolveArgs.Name);
if (string.Equals(name.Name, "TeslaConsoleLaunchLib",
StringComparison.OrdinalIgnoreCase))
return typeof(ILauncherService).Assembly;
return null;
};
Host.CreateDefaultBuilder(args)
.UseWindowsService(options =>
{
options.ServiceName = "Tesla Application Launcher";
})
.ConfigureServices(services =>
{
services.AddHostedService<TeslaLauncherService>();
})
.ConfigureLogging(logging =>
{
logging.AddEventLog(settings =>
{
settings.SourceName = "Tesla Application Launcher";
});
})
.Build()
.Run();
}
}
}