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>
1104 lines
48 KiB
C#
1104 lines
48 KiB
C#
// =============================================================================
|
||
// 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 0–50%
|
||
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 50–95%
|
||
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();
|
||
}
|
||
}
|
||
}
|