Converts the Launcher Service + Agent from net8/win-x64 self-contained to net48 framework-dependent, and makes Tesla.Contract net48-only (drops multi-targeting). Both consumers (Console + Launcher) are now a single TFM. Code changes for net48 (the only net8/netstandard2.1 APIs in use): - RandomNumberGenerator.Fill -> RandomNumberGenerator.Create().GetBytes (3x) - TcpListener.AcceptTcpClientAsync(ct) -> AcceptTcpClientAsync() + stop-on-cancel - byte[].AsSpan().SequenceEqual -> Linq SequenceEqual (no System.Memory) (2x) - PipeStream.Write(byte[]) / WriteAsync(byte[],ct) -> explicit (buf,0,len[,ct]) - Math.Clamp -> Math.Max/Min The generic host (Microsoft.Extensions.Hosting 8.x + UseWindowsService) runs on net48 unchanged. build.bat/install.bat updated for the folder-of-DLLs deploy; solution platform reverted x64 -> AnyCPU. RESULT — package size: ~3.7 MB on disk / 1.58 MB zipped, vs ~213 MB / 91 MB for the net8 self-contained build (~50-58x smaller). net48 ships in Win10/11 so no runtime prerequisite. 73 tests green; NOT re-validated on a live pod. Spike branch for evaluation — do not merge without a pod re-test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1209 lines
55 KiB
C#
1209 lines
55 KiB
C#
// =============================================================================
|
||
// TeslaLauncher — Secure Pod Configuration
|
||
// =============================================================================
|
||
// Implements the Tesla secure pod configuration protocol (reverse-engineered
|
||
// from TeslaSecureConfiguration.dll, Elsewhen Studios LLC).
|
||
//
|
||
// Protocol summary:
|
||
// 1. Assign temp IP, generate RequestId + Passphrase
|
||
// 2. Broadcast UDP RQST beacon on port 53291
|
||
// 3. Display codes on screen and COM2 plasma display
|
||
// 4. Console sends AES-encrypted network config via UDP RPLY on port 53292
|
||
// 5. Apply static IP, DNS, hostname via netsh
|
||
// 6. TCP handshake on port 53292: OFB crypto negotiation + RSA key exchange
|
||
// 7. Save 32-byte session key to TeslaKeyStore.key
|
||
// =============================================================================
|
||
|
||
using System;
|
||
using System.IO;
|
||
using System.Net;
|
||
using System.Net.Sockets;
|
||
using System.Net.NetworkInformation;
|
||
using System.Text;
|
||
using System.Security.Cryptography;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Diagnostics;
|
||
using Microsoft.Win32;
|
||
using System.Runtime.Serialization;
|
||
using System.Runtime.Serialization.Formatters.Binary;
|
||
#if WINFORMS
|
||
using System.Windows.Forms;
|
||
using System.Drawing;
|
||
using System.Runtime.InteropServices;
|
||
#endif
|
||
|
||
namespace TeslaSecureConfig
|
||
{
|
||
// ---- Wire protocol constants ----
|
||
internal static class Proto
|
||
{
|
||
public const string RQST = "RQST";
|
||
public const string RPLY = "RPLY";
|
||
public const string CONF = "CONF";
|
||
public const string DONE = "DONE";
|
||
|
||
public const int UdpBroadcastPort = 53291; // Console listens here
|
||
public const int UdpReplyPort = 53292; // Cockpit listens here for Console RPLY (UDP broadcast)
|
||
public const int UdpBeaconIntervalMs= 2000;
|
||
|
||
public const string TempIp = "172.16.0.100";
|
||
public const string TempBcast = "172.31.255.255"; // directed broadcast for 172.16.0.0/12
|
||
public const string TempMask = "255.240.0.0";
|
||
|
||
public const int PassphraseLength = 5;
|
||
public const int RequestIdLength = 3;
|
||
public const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||
|
||
public const int RsaKeySize = 2048;
|
||
public const int Pbkdf2Iterations = 1000;
|
||
public const int AesKeyBytes = 32; // 256-bit AES
|
||
|
||
// Static salt extracted from TeslaSecureConfiguration.dll FieldRVA
|
||
public static readonly byte[] PassphraseSalt = new byte[]
|
||
{
|
||
0x17, 0xab, 0x51, 0xd9, 0xec, 0xd1, 0xd4, 0x74,
|
||
0xa9, 0x09, 0x4a, 0x34, 0x27, 0xfb, 0x1f, 0xf2,
|
||
0xde, 0xc4, 0xf9, 0xf1, 0xa6, 0xd8, 0x9e, 0xda,
|
||
0x15, 0x11, 0x47, 0x65, 0x32, 0xe7, 0xe7, 0xef
|
||
};
|
||
|
||
public const string ComPort = "COM2";
|
||
public const int ComBaud = 9600;
|
||
public const string PlasmaFont = "Verdana";
|
||
|
||
public const string RegComputerName = @"SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName";
|
||
public const string RegTcpipParams = @"SYSTEM\CurrentControlSet\services\Tcpip\Parameters";
|
||
}
|
||
|
||
// ---- Network configuration returned from Console ----
|
||
public class PodNetworkConfig
|
||
{
|
||
public IPAddress Address { get; set; }
|
||
public IPAddress Mask { get; set; }
|
||
public IPAddress Gateway { get; set; }
|
||
public IPAddress Dns { get; set; }
|
||
public string HostName { get; set; }
|
||
}
|
||
|
||
#if WINFORMS
|
||
// ---- Passcode display form ----
|
||
// Request ID: sent in beacon, Console shows it to identify this cockpit.
|
||
// Passphrase: displayed HERE only. Operator reads it and types it into the
|
||
// Console. Console returns it inside the encrypted CONF to prove
|
||
// it is configuring the correct pod.
|
||
internal class PasscodeDisplayForm : Form
|
||
{
|
||
public PasscodeDisplayForm(string requestId, string passphrase)
|
||
{
|
||
SuspendLayout();
|
||
|
||
Text = "Your Configuration Passcode";
|
||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||
StartPosition = FormStartPosition.CenterScreen;
|
||
ClientSize = new Size(480, 220);
|
||
MaximizeBox = false;
|
||
MinimizeBox = false;
|
||
|
||
var boldFont = new Font("Arial Narrow", 24f, FontStyle.Bold, GraphicsUnit.Point);
|
||
var labelFont = new Font("Arial Narrow", 14f, FontStyle.Regular, GraphicsUnit.Point);
|
||
var noteFont = new Font("Arial Narrow", 10f, FontStyle.Italic, GraphicsUnit.Point);
|
||
|
||
// Request ID row
|
||
var lblRqstTitle = new Label { Text = "Request ID:", AutoSize = true,
|
||
Location = new Point(20, 24), Font = labelFont };
|
||
var lblRqstVal = new Label { Text = requestId, AutoSize = true,
|
||
Location = new Point(200, 20), Font = boldFont };
|
||
|
||
// Passphrase row
|
||
var lblPassTitle = new Label { Text = "Passphrase:", AutoSize = true,
|
||
Location = new Point(20, 100), Font = labelFont };
|
||
var lblPassVal = new Label { Text = passphrase, AutoSize = true,
|
||
Location = new Point(200, 96), Font = boldFont,
|
||
ForeColor = System.Drawing.Color.DarkRed };
|
||
|
||
// Instruction note
|
||
var lblNote = new Label
|
||
{
|
||
Text = "Console will show the Request ID above.\r\n" +
|
||
"Read the Passphrase (red) to the operator,\r\n" +
|
||
"who types it into the Console to authorise this pod.",
|
||
AutoSize = true,
|
||
Location = new Point(20, 158),
|
||
Font = noteFont,
|
||
ForeColor = System.Drawing.Color.Gray
|
||
};
|
||
|
||
Controls.AddRange(new Control[]
|
||
{ lblRqstTitle, lblRqstVal, lblPassTitle, lblPassVal, lblNote });
|
||
|
||
ResumeLayout(false);
|
||
PerformLayout();
|
||
}
|
||
}
|
||
|
||
#endif
|
||
// ---- PlasmaIO wrapper (mirrors PlasmaIO.dll PlasmaDisplay API) ----
|
||
internal sealed class PlasmaWriter : IDisposable
|
||
{
|
||
private System.IO.Ports.SerialPort _port;
|
||
private bool _disposed;
|
||
|
||
public PlasmaWriter(string comPort, int baud = 9600)
|
||
{
|
||
try
|
||
{
|
||
_port = new System.IO.Ports.SerialPort(comPort, baud,
|
||
System.IO.Ports.Parity.None, 8, System.IO.Ports.StopBits.One);
|
||
_port.Open();
|
||
ClearAll();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Plasma display is optional - log but continue
|
||
System.Diagnostics.Debug.WriteLine($"PlasmaIO init failed: {ex.Message}");
|
||
_port = null;
|
||
}
|
||
}
|
||
|
||
// ClearAll: send ESC J (clear display) as per Plasma protocol
|
||
public void ClearAll()
|
||
{
|
||
if (_port == null || !_port.IsOpen) return;
|
||
_port.BaseStream.WriteByte(0x1B); // ESC
|
||
_port.BaseStream.WriteByte(0x4A); // J (clear screen)
|
||
_port.BaseStream.Flush();
|
||
}
|
||
|
||
public void WriteLine(string format, params object[] args)
|
||
{
|
||
if (_port == null || !_port.IsOpen) return;
|
||
var text = string.Format(format, args) + "\r\n";
|
||
var bytes = Encoding.ASCII.GetBytes(text);
|
||
_port.BaseStream.Write(bytes, 0, bytes.Length);
|
||
_port.BaseStream.Flush();
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (!_disposed)
|
||
{
|
||
_disposed = true;
|
||
try { _port?.Close(); } catch { }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Crypto helpers ----
|
||
internal static class CryptoHelper
|
||
{
|
||
/// Derive AES key from passphrase using PBKDF2 with the hard-coded salt.
|
||
/// COMPATIBILITY: this MUST use the SHA1-default Rfc2898DeriveBytes(string,
|
||
/// byte[], int) overload. The Console derives the same session key with the
|
||
/// identical SHA1-default PBKDF2 (Tesla.PodConfigurationServer.GenerateKeyFromPassphrase),
|
||
/// so switching to a SHA256 overload — as SYSLIB0041 suggests on net8 — would
|
||
/// silently break the secure-config key handshake. Do not "modernize" this.
|
||
#pragma warning disable SYSLIB0041 // SHA1-default PBKDF2 is required for Console wire compatibility
|
||
public static byte[] DeriveKey(string passphrase)
|
||
{
|
||
var pbkdf2 = new Rfc2898DeriveBytes(passphrase,
|
||
Proto.PassphraseSalt, Proto.Pbkdf2Iterations);
|
||
return pbkdf2.GetBytes(Proto.AesKeyBytes);
|
||
}
|
||
#pragma warning restore SYSLIB0041
|
||
|
||
/// Encrypt data with AES-256 (CBC mode)
|
||
public static byte[] Encrypt(byte[] data, byte[] key)
|
||
{
|
||
using var aes = Aes.Create();
|
||
aes.Mode = CipherMode.CBC;
|
||
aes.Key = key;
|
||
aes.GenerateIV();
|
||
var iv = aes.IV;
|
||
using var enc = aes.CreateEncryptor();
|
||
using var ms = new MemoryStream();
|
||
using var cs = new CryptoStream(ms, enc, CryptoStreamMode.Write);
|
||
cs.Write(data, 0, data.Length);
|
||
cs.FlushFinalBlock();
|
||
var cipher = ms.ToArray();
|
||
// Prepend IV
|
||
var result = new byte[iv.Length + cipher.Length];
|
||
Buffer.BlockCopy(iv, 0, result, 0, iv.Length);
|
||
Buffer.BlockCopy(cipher, 0, result, iv.Length, cipher.Length);
|
||
return result;
|
||
}
|
||
|
||
/// Decrypt data with AES-256 (CBC mode), IV prepended
|
||
public static byte[] Decrypt(byte[] data, byte[] key)
|
||
{
|
||
using var aes = Aes.Create();
|
||
aes.Mode = CipherMode.CBC;
|
||
aes.Key = key;
|
||
var iv = new byte[aes.BlockSize / 8];
|
||
Buffer.BlockCopy(data, 0, iv, 0, iv.Length);
|
||
aes.IV = iv;
|
||
using var dec = aes.CreateDecryptor();
|
||
using var ms = new MemoryStream();
|
||
using var cs = new CryptoStream(ms, dec, CryptoStreamMode.Write);
|
||
cs.Write(data, iv.Length, data.Length - iv.Length);
|
||
cs.FlushFinalBlock();
|
||
return ms.ToArray();
|
||
}
|
||
}
|
||
|
||
// ── OFB Duplex Stream ──────────────────────────────────────────────────
|
||
//
|
||
// The original OFBCryptoStream wraps Rijndael-ECB (CipherMode=2) and implements OFB
|
||
// (Output Feedback) mode manually: keystream is generated by repeatedly encrypting the
|
||
// feedback register with the ECB block cipher, then XORing data with the keystream.
|
||
//
|
||
// For a full-duplex TCP connection, two independent keystream generators are maintained:
|
||
// Write direction (pod→console): seeded from the pod's generated IV
|
||
// Read direction (console→pod): seeded from the console's sent IV
|
||
//
|
||
// Both directions use the same PBKDF2-derived AES key.
|
||
internal sealed class OFBDuplexStream : Stream
|
||
{
|
||
private readonly NetworkStream _inner;
|
||
|
||
// Write-direction (pod→console) OFB state
|
||
private readonly Aes _writeAes;
|
||
private readonly ICryptoTransform _writeXform;
|
||
private readonly byte[] _writeFb = new byte[16]; // feedback register (starts as IV)
|
||
private readonly byte[] _writeKs = new byte[16]; // current keystream block
|
||
private int _wPos = 16; // 16 = "generate new block on next byte"
|
||
|
||
// Read-direction (console→pod) OFB state
|
||
private readonly Aes _readAes;
|
||
private readonly ICryptoTransform _readXform;
|
||
private readonly byte[] _readFb = new byte[16];
|
||
private readonly byte[] _readKs = new byte[16];
|
||
private int _rPos = 16;
|
||
|
||
/// <param name="inner">Underlying NetworkStream (not owned — caller disposes).</param>
|
||
/// <param name="key">32-byte PBKDF2 key derived from the passphrase.</param>
|
||
/// <param name="writeIv">16-byte IV for pod→console keystream (pod-generated).</param>
|
||
/// <param name="readIv">16-byte IV for console→pod keystream (console-generated).</param>
|
||
public OFBDuplexStream(NetworkStream inner, byte[] key, byte[] writeIv, byte[] readIv)
|
||
{
|
||
_inner = inner;
|
||
Buffer.BlockCopy(writeIv, 0, _writeFb, 0, 16);
|
||
Buffer.BlockCopy(readIv, 0, _readFb, 0, 16);
|
||
|
||
_writeAes = Aes.Create();
|
||
_writeAes.Mode = CipherMode.ECB;
|
||
_writeAes.Padding = PaddingMode.None;
|
||
_writeAes.Key = key;
|
||
_writeXform = _writeAes.CreateEncryptor();
|
||
|
||
_readAes = Aes.Create();
|
||
_readAes.Mode = CipherMode.ECB;
|
||
_readAes.Padding = PaddingMode.None;
|
||
_readAes.Key = key;
|
||
_readXform = _readAes.CreateEncryptor();
|
||
}
|
||
|
||
private byte NextWriteByte()
|
||
{
|
||
if (_wPos == 16)
|
||
{
|
||
_writeXform.TransformBlock(_writeFb, 0, 16, _writeKs, 0);
|
||
Buffer.BlockCopy(_writeKs, 0, _writeFb, 0, 16);
|
||
_wPos = 0;
|
||
}
|
||
return _writeKs[_wPos++];
|
||
}
|
||
|
||
private byte NextReadByte()
|
||
{
|
||
if (_rPos == 16)
|
||
{
|
||
_readXform.TransformBlock(_readFb, 0, 16, _readKs, 0);
|
||
Buffer.BlockCopy(_readKs, 0, _readFb, 0, 16);
|
||
_rPos = 0;
|
||
}
|
||
return _readKs[_rPos++];
|
||
}
|
||
|
||
public override int Read(byte[] buffer, int offset, int count)
|
||
{
|
||
int n = _inner.Read(buffer, offset, count);
|
||
for (int i = 0; i < n; i++)
|
||
buffer[offset + i] ^= NextReadByte();
|
||
return n;
|
||
}
|
||
|
||
public override void Write(byte[] buffer, int offset, int count)
|
||
{
|
||
var enc = new byte[count];
|
||
for (int i = 0; i < count; i++)
|
||
enc[i] = (byte)(buffer[offset + i] ^ NextWriteByte());
|
||
_inner.Write(enc, 0, count);
|
||
}
|
||
|
||
public override void Flush() => _inner.Flush();
|
||
|
||
public override bool CanRead => true;
|
||
public override bool CanWrite => true;
|
||
public override bool CanSeek => false;
|
||
|
||
public override long Length => throw new NotSupportedException();
|
||
public override long Position
|
||
{
|
||
get => throw new NotSupportedException();
|
||
set => throw new NotSupportedException();
|
||
}
|
||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||
public override void SetLength(long value) => throw new NotSupportedException();
|
||
|
||
// Delegate timeout properties to the inner NetworkStream.
|
||
public override bool CanTimeout => true;
|
||
public override int ReadTimeout { get => _inner.ReadTimeout; set => _inner.ReadTimeout = value; }
|
||
public override int WriteTimeout { get => _inner.WriteTimeout; set => _inner.WriteTimeout = value; }
|
||
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
if (disposing)
|
||
{
|
||
_writeXform?.Dispose();
|
||
_readXform?.Dispose();
|
||
_writeAes?.Dispose();
|
||
_readAes?.Dispose();
|
||
// _inner is not owned — do not dispose
|
||
}
|
||
base.Dispose(disposing);
|
||
}
|
||
}
|
||
|
||
// ---- Main secure configuration orchestrator ----
|
||
public sealed class PodSecureConfigurator : IDisposable
|
||
{
|
||
private int _adapterIndex; // IPv4 interface index — used with netsh index=N
|
||
private string _adapterId; // Adapter GUID — used for registry static-IP persistence
|
||
private string _adapterName; // Display name e.g. "Ethernet" — used for ipconfig /release
|
||
private readonly Action<string> _log;
|
||
private IPAddress _consoleAddress; // Console sender IP from RPLY — used to re-resolve adapter
|
||
|
||
private string _requestId;
|
||
private string _passphrase;
|
||
|
||
/// <summary>
|
||
/// After the RSA key exchange, the 32-byte session key is stored here.
|
||
/// The Service saves it to TeslaKeyStore.key and uses it for NegotiateCryptoStreams
|
||
/// on the management port (53290) for all subsequent Console connections.
|
||
/// </summary>
|
||
public byte[] SessionKey { get; private set; }
|
||
|
||
private System.Net.Sockets.Socket _replySocket;
|
||
private Thread _beaconThread;
|
||
private Thread _replyThread;
|
||
private volatile bool _running;
|
||
private ManualResetEventSlim _configReceived = new ManualResetEventSlim(false);
|
||
private PodNetworkConfig _receivedConfig;
|
||
|
||
#if WINFORMS
|
||
private PasscodeDisplayForm _displayForm;
|
||
#endif
|
||
private PlasmaWriter _plasma;
|
||
|
||
public PodSecureConfigurator(string adapterName = null, Action<string> logger = null) // adapterName = display name hint or null for auto-detect
|
||
{
|
||
if (adapterName != null)
|
||
{
|
||
_adapterIndex = FindAdapterIndex(adapterName);
|
||
_adapterId = FindAdapterId(adapterName);
|
||
_adapterName = adapterName;
|
||
}
|
||
else
|
||
{
|
||
(_adapterIndex, _adapterId, _adapterName) = FindFirstEthernetAdapter();
|
||
}
|
||
_log = logger ?? (s => Debug.WriteLine(s));
|
||
}
|
||
|
||
// --- Entry point ---
|
||
public PodNetworkConfig Configure(int timeoutMs = 300_000)
|
||
{
|
||
Log("Beginning configuration.");
|
||
|
||
// Delete any stale configuring.json from a previous run so
|
||
// the Agent doesn't display old codes before we generate new ones.
|
||
try
|
||
{
|
||
var staleFile = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||
"TeslaLauncher", "configuring.json");
|
||
if (File.Exists(staleFile)) File.Delete(staleFile);
|
||
}
|
||
catch { }
|
||
|
||
// Step 1: Generate identifiers
|
||
_requestId = GenerateRandomString(Proto.RequestIdLength);
|
||
_passphrase = GenerateRandomString(Proto.PassphraseLength);
|
||
Log($"Waiting for configuration. Request ID: {_requestId} Passphrase: {_passphrase} (passphrase is NOT transmitted — operator reads it off screen)");
|
||
|
||
// Write RequestId/Passphrase to a shared file so the Agent
|
||
// (running in the user session) can display them on screen.
|
||
try
|
||
{
|
||
var cfgDir = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||
"TeslaLauncher");
|
||
Directory.CreateDirectory(cfgDir);
|
||
File.WriteAllText(Path.Combine(cfgDir, "configuring.json"),
|
||
$"{{\"RequestId\":\"{_requestId}\",\"Passphrase\":\"{_passphrase}\"}}");
|
||
}
|
||
catch { /* best-effort — plasma/log still have the values */ }
|
||
|
||
// Step 2: Assign temporary IP
|
||
Log($"Configuring temporary IP address on \"{_adapterName}\".");
|
||
if (!ConfigureTempIp(_adapterName))
|
||
{
|
||
Log("Could not initalize temporary IP address.");
|
||
return null;
|
||
}
|
||
Log("Temp IP configured.");
|
||
|
||
// Step 3: Show display (WinForms + Plasma)
|
||
ShowPasscodeDisplay();
|
||
|
||
// Step 4: Start UDP beacon.
|
||
// Bind to the temp IP so broadcasts go out through the correct
|
||
// Ethernet adapter. netsh returns before the kernel has finished
|
||
// assigning the address, so retry for up to 5 seconds.
|
||
_running = true;
|
||
|
||
// No longer waiting for the temp IP to propagate before broadcasting.
|
||
// BeaconLoop uses per-interface sockets bound to each adapter's actual current
|
||
// IP so Windows routes each broadcast out the correct physical NIC.
|
||
// The temp IP assignment still runs (source=static disables DHCP for TCP),
|
||
// but the beacon works immediately from whatever address the adapter has.
|
||
Log("Starting beacon.");
|
||
|
||
_beaconThread = new Thread(BeaconLoop) { Name = "BeaconThread", IsBackground = true };
|
||
_beaconThread.Start();
|
||
|
||
// Step 5: Start UDP listener on port 53292 for RPLY from Console.
|
||
// The Console broadcasts "RPLY" + AES-encrypted CONF as a 68-byte UDP datagram
|
||
// to 255.255.255.255:53292. We receive it, derive the AES key from our locally-
|
||
// generated passphrase (operator typed it into the Console), decrypt and apply.
|
||
try
|
||
{
|
||
_replySocket = new System.Net.Sockets.Socket(
|
||
System.Net.Sockets.AddressFamily.InterNetwork,
|
||
System.Net.Sockets.SocketType.Dgram,
|
||
System.Net.Sockets.ProtocolType.Udp);
|
||
_replySocket.SetSocketOption(
|
||
System.Net.Sockets.SocketOptionLevel.Socket,
|
||
System.Net.Sockets.SocketOptionName.ReuseAddress, true);
|
||
_replySocket.EnableBroadcast = true;
|
||
_replySocket.Bind(new IPEndPoint(IPAddress.Any, Proto.UdpReplyPort));
|
||
}
|
||
catch (SocketException ex)
|
||
{
|
||
Log($"Could not bind UDP reply socket on port {Proto.UdpReplyPort}: {ex.Message}");
|
||
_running = false;
|
||
return null;
|
||
}
|
||
_replyThread = new Thread(UdpReplyLoop) { Name = "UdpReplyThread", IsBackground = true };
|
||
_replyThread.Start();
|
||
|
||
Log("Broadcasting... waiting for packets.");
|
||
|
||
// Wait for config
|
||
bool received = _configReceived.Wait(timeoutMs);
|
||
_running = false;
|
||
|
||
// Hide display
|
||
#if WINFORMS
|
||
try { _displayForm?.Invoke(new Action(() => _displayForm.Close())); } catch { }
|
||
#endif
|
||
_plasma?.Dispose();
|
||
|
||
if (!received || _receivedConfig == null)
|
||
{
|
||
Log("Timed out waiting for configuration.");
|
||
return null;
|
||
}
|
||
|
||
Log("Packets received.");
|
||
|
||
// Step 6: Open TCP listener on 0.0.0.0:53292 BEFORE changing the IP.
|
||
//
|
||
// After sending RPLY the Console immediately ARPs for the NEW IP it put
|
||
// in the payload (not the beacon source IP). It then connects TCP to that
|
||
// new IP:53292 to confirm the cockpit applied the config.
|
||
//
|
||
// Binding the TCP listener to 0.0.0.0 first means it is already waiting
|
||
// when the IP changes. Once netsh applies the new address and the Console's
|
||
// ARP gets a reply, it connects TCP — and the listener accepts it.
|
||
Log("Confirming configuration to Console.");
|
||
try { _replySocket?.Close(); _replySocket = null; } catch { }
|
||
|
||
// Re-resolve the adapter using the TARGET IP from the RPLY config.
|
||
//
|
||
// The Console may route its RPLY broadcast through a secondary NIC that is
|
||
// NOT on the pod's network (e.g. a 192.168.1.x NIC instead of the 10.0.x
|
||
// NIC that actually connects to the pods). Using the Console's sender IP
|
||
// (_consoleAddress) for adapter selection therefore fails whenever the Console
|
||
// has multiple NICs.
|
||
//
|
||
// The target IP in the RPLY config is always on the pod's network — the
|
||
// Console operator assigns it from the same IP range. We find the local NIC
|
||
// whose current IP shares the same subnet as the target: that is the correct
|
||
// NIC to reconfigure.
|
||
if (_receivedConfig != null)
|
||
ResolveAdapterForTargetIp(_receivedConfig.Address, _receivedConfig.Mask);
|
||
|
||
var tcpConf = new System.Net.Sockets.TcpListener(IPAddress.Any, Proto.UdpReplyPort);
|
||
tcpConf.Start();
|
||
|
||
// Step 7: Apply network configuration (IP changes here).
|
||
// The listener is already bound so it will accept the Console's connection
|
||
// once the new IP is live and the ARP resolves.
|
||
Log($"Attempting to configure final IP.");
|
||
if (!ApplyNetworkConfig(_adapterId, _adapterName, _receivedConfig))
|
||
{
|
||
Log("Could not initalize final IP address.");
|
||
tcpConf.Stop();
|
||
return null;
|
||
}
|
||
|
||
Log("Setting host name.");
|
||
SetHostName(_receivedConfig.HostName);
|
||
|
||
// Step 8: Accept Console connection and send DONE.
|
||
SendTcpConfirmation(tcpConf);
|
||
|
||
Log("...Configured");
|
||
|
||
// Clean up the shared file so the Agent doesn't show stale data
|
||
try
|
||
{
|
||
var cfgFile = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||
"TeslaLauncher", "configuring.json");
|
||
if (File.Exists(cfgFile)) File.Delete(cfgFile);
|
||
}
|
||
catch { }
|
||
|
||
return _receivedConfig;
|
||
}
|
||
|
||
// --- UDP beacon loop ---
|
||
// On Windows, a socket bound to 0.0.0.0 sending to 255.255.255.255
|
||
// is routed through whichever interface has the lowest metric —
|
||
// which is often a virtual or loopback adapter, silently discarding
|
||
// the packet before it reaches the physical wire.
|
||
//
|
||
// Fix: enumerate every live Ethernet interface, bind a separate socket
|
||
// to each one's actual current IP, and send to that subnet's directed
|
||
// broadcast address. This forces each datagram out the correct NIC.
|
||
private void BeaconLoop()
|
||
{
|
||
byte[] payload = BuildBeaconPayload();
|
||
|
||
// On boot, the service may start before the NIC driver has fully initialised.
|
||
// Wait up to 60 s for at least one UP Ethernet interface with a real, *bindable* IPv4
|
||
// address. Checking OperationalStatus+UnicastAddresses is not sufficient — after
|
||
// "netsh interface ip set address ... static", the new IP can appear in the interface
|
||
// list for several seconds before the kernel routing table commits it, causing socket
|
||
// Bind() to fail with WSAEADDRNOTAVAIL. We probe-bind a throwaway UDP socket to
|
||
// confirm the address is actually usable before leaving the wait loop.
|
||
int waitSecs = 0;
|
||
while (_running)
|
||
{
|
||
bool ready = false;
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||
foreach (var ua in nic.GetIPProperties().UnicastAddresses)
|
||
{
|
||
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
||
if (IPAddress.IsLoopback(ua.Address)) continue;
|
||
// Verify the address is actually bindable, not just listed
|
||
try
|
||
{
|
||
using var probe = new System.Net.Sockets.Socket(
|
||
System.Net.Sockets.AddressFamily.InterNetwork,
|
||
System.Net.Sockets.SocketType.Dgram,
|
||
System.Net.Sockets.ProtocolType.Udp);
|
||
probe.Bind(new IPEndPoint(ua.Address, 0));
|
||
ready = true;
|
||
}
|
||
catch { /* not committed by kernel yet — keep waiting */ }
|
||
if (ready) break;
|
||
}
|
||
if (ready) break;
|
||
}
|
||
if (ready) break;
|
||
if (waitSecs == 0) Log("Waiting for network interface to obtain a bindable IP address...");
|
||
waitSecs++;
|
||
if (waitSecs >= 60) { Log("Warning: no interface had a bindable IP after 60 s — broadcasting anyway."); break; }
|
||
Thread.Sleep(1000);
|
||
}
|
||
|
||
while (_running)
|
||
{
|
||
SendBeaconOnAllInterfaces(payload);
|
||
Thread.Sleep(Proto.UdpBeaconIntervalMs);
|
||
}
|
||
}
|
||
|
||
private byte[] BuildBeaconPayload()
|
||
{
|
||
// RQST beacon — 13 bytes:
|
||
// [0:4] "RQST"
|
||
// [4:10] 6-byte raw MAC of the physical Ethernet adapter
|
||
// [10:13] RequestId (3 ASCII chars)
|
||
byte[] macBytes = GetAdapterMacBytes();
|
||
using var ms = new MemoryStream(13);
|
||
using var w = new BinaryWriter(ms, Encoding.ASCII);
|
||
w.Write(Encoding.ASCII.GetBytes(Proto.RQST));
|
||
w.Write(macBytes);
|
||
w.Write(Encoding.ASCII.GetBytes(_requestId.PadRight(Proto.RequestIdLength)));
|
||
return ms.ToArray();
|
||
}
|
||
|
||
private void SendBeaconOnAllInterfaces(byte[] payload)
|
||
{
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||
|
||
var ipProps = nic.GetIPProperties();
|
||
foreach (var ua in ipProps.UnicastAddresses)
|
||
{
|
||
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
||
if (IPAddress.IsLoopback(ua.Address)) continue;
|
||
|
||
// Send to the limited broadcast address (255.255.255.255).
|
||
//
|
||
// The Console can be on any IP subnet — in production it is on
|
||
// 10.0.0.x while the pod temp IP is 172.16.0.x. A directed
|
||
// subnet broadcast (e.g. 172.31.255.255) never crosses the subnet
|
||
// boundary so the Console would not receive it.
|
||
// Bind to this NIC's IP so Windows routes via the physical adapter
|
||
try
|
||
{
|
||
using var sock = new System.Net.Sockets.Socket(
|
||
System.Net.Sockets.AddressFamily.InterNetwork,
|
||
System.Net.Sockets.SocketType.Dgram,
|
||
System.Net.Sockets.ProtocolType.Udp);
|
||
sock.EnableBroadcast = true;
|
||
sock.Bind(new IPEndPoint(ua.Address, 0));
|
||
sock.SendTo(payload, new IPEndPoint(IPAddress.Broadcast, Proto.UdpBroadcastPort));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log($"Beacon send error on {ua.Address}: {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the 6-byte raw MAC of the adapter holding the temp IP.
|
||
/// Falls back to the first operational Ethernet adapter.
|
||
/// </summary>
|
||
private static byte[] GetAdapterMacBytes()
|
||
{
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
|
||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||
|
||
foreach (var ua in nic.GetIPProperties().UnicastAddresses)
|
||
{
|
||
if (ua.Address.ToString() == Proto.TempIp)
|
||
return nic.GetPhysicalAddress().GetAddressBytes();
|
||
}
|
||
}
|
||
|
||
// Fallback: first operational Ethernet adapter
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
|
||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||
|
||
var mac = nic.GetPhysicalAddress().GetAddressBytes();
|
||
if (mac.Length == 6) return mac;
|
||
}
|
||
|
||
return new byte[6]; // all-zero fallback
|
||
}
|
||
|
||
// --- UDP RPLY listener ---
|
||
// Listens for AES-encrypted RPLY packets from the Console on port 53292.
|
||
// Key = PBKDF2(passphrase, salt, 1000, 32). Format: "RPLY"(4) + IV(16) + ciphertext(48).
|
||
private void UdpReplyLoop()
|
||
{
|
||
Log("Waiting for console to connect.");
|
||
var buf = new byte[4096];
|
||
_replySocket.ReceiveTimeout = 2000;
|
||
|
||
while (_running)
|
||
{
|
||
try
|
||
{
|
||
EndPoint remoteEp = new IPEndPoint(IPAddress.Any, 0);
|
||
int len = _replySocket.ReceiveFrom(buf, ref remoteEp);
|
||
if (len < 4) continue;
|
||
|
||
string tag = Encoding.ASCII.GetString(buf, 0, 4);
|
||
if (tag != Proto.RPLY) continue;
|
||
|
||
// Payload after "RPLY" tag is the AES-encrypted CONF block
|
||
int cipherLen = len - 4;
|
||
byte[] cipherText = new byte[cipherLen];
|
||
Buffer.BlockCopy(buf, 4, cipherText, 0, cipherLen);
|
||
|
||
// Derive AES key from our passphrase (operator typed this into Console)
|
||
byte[] aesKey = CryptoHelper.DeriveKey(_passphrase);
|
||
byte[] confData;
|
||
try { confData = CryptoHelper.Decrypt(cipherText, aesKey); }
|
||
catch { Log("RPLY decrypt failed — AES error, likely wrong passphrase key."); continue; }
|
||
|
||
var config = ParseConf(confData);
|
||
if (config == null) { Log("RPLY parsed but config invalid — skipping."); continue; }
|
||
|
||
Log($"CONF accepted: IP {config.Address}, Mask {config.Mask}, " +
|
||
$"GW {config.Gateway}, DNS {config.Dns}, Host {config.HostName}");
|
||
|
||
_receivedConfig = config;
|
||
if (remoteEp is IPEndPoint rip && !IPAddress.Any.Equals(rip.Address))
|
||
_consoleAddress = rip.Address;
|
||
_configReceived.Set();
|
||
return;
|
||
}
|
||
catch (SocketException) { /* timeout — loop */ }
|
||
catch (ObjectDisposedException) { break; }
|
||
catch (Exception ex) { Log($"RPLY receive error: {ex.Message}"); }
|
||
}
|
||
}
|
||
|
||
// --- TCP session: OFB negotiation + RSA key exchange ---
|
||
// Uses Socket.Poll() because ReceiveTimeout doesn't affect AcceptTcpClient in .NET 6.
|
||
private void SendTcpConfirmation(System.Net.Sockets.TcpListener tcpConf)
|
||
{
|
||
try
|
||
{
|
||
const int TimeoutMs = 60_000; // 60 s — Console retries at ~0.5 s intervals
|
||
|
||
Log("TCP port 53292 open — waiting for Console to connect...");
|
||
|
||
bool ready = tcpConf.Server.Poll(TimeoutMs * 1000, System.Net.Sockets.SelectMode.SelectRead);
|
||
if (!ready) { Log("TCP confirmation timed out."); return; }
|
||
|
||
System.Net.Sockets.TcpClient client;
|
||
try { client = tcpConf.AcceptTcpClient(); }
|
||
catch (SocketException) { Log("TCP accept failed."); return; }
|
||
|
||
using (client)
|
||
{
|
||
var netStream = client.GetStream();
|
||
netStream.WriteTimeout = 10_000;
|
||
netStream.ReadTimeout = 30_000;
|
||
|
||
Log("Console connection received. Negotiating...");
|
||
|
||
// ── 1. IV exchange ──────────────────────────────────────
|
||
var consoleIv = new byte[16];
|
||
TcpReadExact(netStream, consoleIv);
|
||
|
||
var podIv = new byte[16];
|
||
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(podIv);
|
||
netStream.Write(podIv, 0, 16);
|
||
netStream.Flush();
|
||
|
||
// ── 2. OFB session setup ────────────────────────────────
|
||
// Each direction keyed from the OTHER side's IV
|
||
var aesKey = CryptoHelper.DeriveKey(_passphrase);
|
||
|
||
using var ofb = new OFBDuplexStream(netStream, aesKey,
|
||
writeIv: consoleIv, // pod→console direction
|
||
readIv: podIv); // console→pod direction
|
||
ofb.WriteTimeout = 10_000;
|
||
ofb.ReadTimeout = 30_000;
|
||
|
||
// ── 3. CONF handshake ───────────────────────────────────
|
||
// Both sides write "CONF" over OFB and verify the reply
|
||
// to confirm matching PBKDF2 keys.
|
||
var confMsg = Encoding.UTF8.GetBytes("CONF");
|
||
ofb.Write(confMsg, 0, confMsg.Length);
|
||
ofb.Flush();
|
||
|
||
var confReply = new byte[4];
|
||
TcpReadExact(ofb, confReply);
|
||
if (!confReply.SequenceEqual(confMsg))
|
||
{
|
||
Log($"CONF mismatch: got [{BitConverter.ToString(confReply)}], expected CONF — key/IV mismatch.");
|
||
return;
|
||
}
|
||
Log("Secure connection to console negotiated.");
|
||
|
||
// ── 4. RSA key exchange ─────────────────────────────────
|
||
using var rsa = RSA.Create(Proto.RsaKeySize);
|
||
|
||
Log("Sending console final key.");
|
||
using (var bw = new BinaryWriter(ofb, Encoding.UTF8, leaveOpen: true))
|
||
{
|
||
bw.Write(rsa.ToXmlString(false)); // public key only
|
||
bw.Flush();
|
||
}
|
||
|
||
Log("Receiving console key.");
|
||
byte[] sessionKey;
|
||
using (var br = new BinaryReader(ofb, Encoding.UTF8, leaveOpen: true))
|
||
{
|
||
int encLen = br.ReadInt32();
|
||
byte[] enc = br.ReadBytes(encLen);
|
||
sessionKey = rsa.Decrypt(enc, RSAEncryptionPadding.Pkcs1);
|
||
}
|
||
Log($"Console key received ({sessionKey.Length} bytes).");
|
||
|
||
// Store session key — used for OFB on the management port (53290)
|
||
SessionKey = sessionKey;
|
||
|
||
// Persist in original KeyStore format: [1-byte length] + [key bytes]
|
||
var keyDir = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||
"TeslaLauncher");
|
||
Directory.CreateDirectory(keyDir);
|
||
var keyPath = Path.Combine(keyDir, "TeslaKeyStore.key");
|
||
using (var fs = File.Open(keyPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||
{
|
||
fs.WriteByte((byte)sessionKey.Length);
|
||
fs.Write(sessionKey, 0, sessionKey.Length);
|
||
}
|
||
Log($"Session key saved ({sessionKey.Length} bytes).");
|
||
|
||
Log("Console confirmed — configuration complete.");
|
||
}
|
||
}
|
||
catch (Exception ex) { Log($"TCP confirmation error (non-fatal): {ex.Message}"); }
|
||
finally { try { tcpConf?.Stop(); } catch { } }
|
||
}
|
||
|
||
/// <summary>Reads exactly buf.Length bytes from stream; throws IOException on EOF.</summary>
|
||
private static void TcpReadExact(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("TCP connection closed before all bytes received.");
|
||
off += n;
|
||
}
|
||
}
|
||
|
||
// Parse decrypted CONF payload: IP(4) + Mask(4) + GW(4) + DNS(4) + Hostname(ASCII)
|
||
private PodNetworkConfig ParseConf(byte[] data)
|
||
{
|
||
try
|
||
{
|
||
if (data.Length < 16) return null;
|
||
|
||
var addr = new IPAddress(new byte[] { data[0], data[1], data[2], data[3] });
|
||
var mask = new IPAddress(new byte[] { data[4], data[5], data[6], data[7] });
|
||
var gw = new IPAddress(new byte[] { data[8], data[9], data[10], data[11] });
|
||
var dns = new IPAddress(new byte[] { data[12], data[13], data[14], data[15] });
|
||
|
||
// Sanity-check: first octet of IP must be 1–223 (unicast range).
|
||
// If decryption produced garbage (wrong passphrase), this will fail silently —
|
||
// the cockpit just keeps broadcasting and waiting for the correct RPLY.
|
||
if (data[0] == 0 || data[0] >= 224)
|
||
{
|
||
Log($"RPLY: decryption produced non-unicast IP first-octet ({data[0]}) — ignoring (wrong passphrase in Console?).");
|
||
return null;
|
||
}
|
||
|
||
// Hostname is the printable ASCII content of the remaining bytes,
|
||
// terminated at the first non-printable character.
|
||
var hostName = string.Empty;
|
||
for (int i = 16; i < data.Length; i++)
|
||
{
|
||
byte b = data[i];
|
||
if (b < 0x20 || b > 0x7e) break;
|
||
hostName += (char)b;
|
||
}
|
||
|
||
Log($"CONF decrypted: IP={addr} Mask={mask} GW={gw} DNS={dns} Host={hostName}");
|
||
return new PodNetworkConfig { Address=addr, Mask=mask, Gateway=gw, Dns=dns, HostName=hostName };
|
||
}
|
||
catch (Exception ex) { Log($"ParseConf error: {ex.Message}"); return null; }
|
||
}
|
||
|
||
// --- Show passcode display ---
|
||
private void ShowPasscodeDisplay()
|
||
{
|
||
_plasma = new PlasmaWriter(Proto.ComPort, Proto.ComBaud);
|
||
_plasma.ClearAll();
|
||
_plasma.WriteLine("Request ID: {0}", _requestId);
|
||
_plasma.WriteLine("Passphrase: {0}", _passphrase);
|
||
|
||
#if WINFORMS
|
||
// WinForms dialog — only shown when running as the Agent (user session)
|
||
var t = new Thread(() =>
|
||
{
|
||
Application.EnableVisualStyles();
|
||
Application.SetCompatibleTextRenderingDefault(false);
|
||
_displayForm = new PasscodeDisplayForm(_requestId, _passphrase);
|
||
_displayForm.ShowDialog();
|
||
})
|
||
{ IsBackground = true };
|
||
t.SetApartmentState(ApartmentState.STA);
|
||
t.Start();
|
||
#endif
|
||
}
|
||
|
||
// --- Temp IP via netsh ---
|
||
//
|
||
// Uses the old-style "interface ip set address" command (not "interface ipv4")
|
||
// because the old form directly calls SetAdapterIPAddress() and immediately
|
||
// updates the running TCP/IP stack. The "interface ipv4" variant only updates
|
||
// the persistent config and requires a network adapter restart to take effect.
|
||
//
|
||
// No gateway for the temp IP — it is only needed to receive the RPLY broadcast
|
||
// on the same subnet; no routing is required at this stage.
|
||
private static bool ConfigureTempIp(string adapterName)
|
||
{
|
||
return RunNetsh(
|
||
$"interface ip set address \"{adapterName}\" " +
|
||
$"static {Proto.TempIp} {Proto.TempMask}");
|
||
}
|
||
|
||
// --- Final IP via netsh + registry ---
|
||
//
|
||
// Uses the old-style "interface ip set address" command to immediately update
|
||
// both the running stack and the persistent config. The registry writes provide
|
||
// belt-and-suspenders persistence in case the DHCP client service ever re-asserts
|
||
// on the next boot.
|
||
private static bool ApplyNetworkConfig(string adapterId, string adapterName, PodNetworkConfig cfg)
|
||
{
|
||
// Set static IP + mask + gateway. The trailing "1" is the gateway metric.
|
||
bool ok = RunNetsh(
|
||
$"interface ip set address \"{adapterName}\" " +
|
||
$"static {cfg.Address} {cfg.Mask} {cfg.Gateway} 1");
|
||
if (!ok) return false;
|
||
|
||
// Set primary DNS. "register=primary" ensures the host registers its DNS record.
|
||
ok = RunNetsh(
|
||
$"interface ip set dns \"{adapterName}\" " +
|
||
$"static {cfg.Dns} primary");
|
||
if (!ok) return false;
|
||
|
||
// Belt-and-suspenders: write directly to the Tcpip registry key so the
|
||
// static config is guaranteed to survive a reboot.
|
||
PersistStaticIpRegistry(adapterId, cfg);
|
||
return true;
|
||
}
|
||
|
||
private static void PersistStaticIpRegistry(string adapterId, PodNetworkConfig cfg)
|
||
{
|
||
string keyPath = $@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{adapterId}";
|
||
try
|
||
{
|
||
using var key = Registry.LocalMachine.OpenSubKey(keyPath, writable: true);
|
||
if (key == null) { Log2($"Registry key not found: {keyPath}"); return; }
|
||
|
||
key.SetValue("EnableDHCP", 0, RegistryValueKind.DWord);
|
||
key.SetValue("IPAddress", new[] { cfg.Address.ToString() }, RegistryValueKind.MultiString);
|
||
key.SetValue("SubnetMask", new[] { cfg.Mask.ToString() }, RegistryValueKind.MultiString);
|
||
key.SetValue("DefaultGateway", new[] { cfg.Gateway.ToString() }, RegistryValueKind.MultiString);
|
||
key.SetValue("GatewayMetric", new[] { "0" }, RegistryValueKind.MultiString);
|
||
key.SetValue("NameServer", cfg.Dns.ToString(), RegistryValueKind.String);
|
||
|
||
// Remove cached DHCP lease data that could re-enable DHCP on boot
|
||
foreach (var val in new[] {
|
||
"DhcpIPAddress", "DhcpSubnetMask", "DhcpDefaultGateway",
|
||
"DhcpNameServer", "DhcpServer", "Lease",
|
||
"LeaseObtainedTime", "LeaseTerminatesTime" })
|
||
{
|
||
try { key.DeleteValue(val); } catch { }
|
||
}
|
||
Log2("Static IP configuration persisted to registry.");
|
||
}
|
||
catch (Exception ex) { Log2($"Registry static-IP persist failed: {ex.Message}"); }
|
||
}
|
||
|
||
private static bool RunNetsh(string args)
|
||
{
|
||
var psi = new ProcessStartInfo("netsh", args)
|
||
{
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true
|
||
};
|
||
var p = Process.Start(psi);
|
||
p.WaitForExit();
|
||
return p.ExitCode == 0;
|
||
}
|
||
|
||
// --- Set hostname in registry ---
|
||
private static void SetHostName(string hostName)
|
||
{
|
||
try
|
||
{
|
||
using var key1 = Registry.LocalMachine.OpenSubKey(Proto.RegComputerName, true);
|
||
key1?.SetValue("ComputerName", hostName, RegistryValueKind.String);
|
||
|
||
using var key2 = Registry.LocalMachine.OpenSubKey(Proto.RegTcpipParams, true);
|
||
key2?.SetValue("NV Hostname", hostName, RegistryValueKind.String);
|
||
|
||
Log2($"Host set to: {hostName}");
|
||
}
|
||
catch (Exception ex) { Log2($"Could not set host name: {ex.Message}"); }
|
||
}
|
||
|
||
private static void Log2(string s) => Debug.WriteLine(s);
|
||
|
||
// --- Virtual adapter filter ---
|
||
// Hyper-V, VirtualBox, VMware and similar virtualization layers expose
|
||
// Ethernet-type adapters that are DHCP-enabled by default. Without this
|
||
// filter, FindFirstEthernetAdapter() and IsMachineConfigured() both pick
|
||
// up the virtual adapter (often the lowest-index one) instead of the
|
||
// physical NIC, causing netsh to configure the wrong interface.
|
||
private static bool IsVirtualAdapter(NetworkInterface nic)
|
||
{
|
||
var desc = nic.Description ?? "";
|
||
var name = nic.Name ?? "";
|
||
// Common substrings found in virtual/software adapter descriptions.
|
||
// Checked case-insensitively to cover all Windows localizations.
|
||
string[] virtualMarkers = {
|
||
"Virtual", "Hyper-V", "VMware", "VirtualBox",
|
||
"Loopback", "Tunnel", "Miniport", "Wi-Fi Direct",
|
||
"Bluetooth", "WAN Miniport", "Microsoft Kernel Debug"
|
||
};
|
||
foreach (var m in virtualMarkers)
|
||
if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||
name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
return true;
|
||
// Physical NICs always have a 6-byte MAC. Virtual adapters sometimes
|
||
// use locally-administered addresses (bit 1 of first octet set).
|
||
var mac = nic.GetPhysicalAddress().GetAddressBytes();
|
||
if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally administered
|
||
return false;
|
||
}
|
||
|
||
// --- Re-resolve adapter using the target IP from the RPLY config ---
|
||
// Using the Console's sender IP is unreliable when the Console has multiple NICs:
|
||
// Windows may route the RPLY broadcast through a NIC on a completely different
|
||
// subnet (e.g. 192.168.1.x instead of the 10.0.x NIC that faces the pods).
|
||
// The TARGET IP assigned in the RPLY payload is always on the pod's network —
|
||
// the Console operator picks it from the same range. So we find the local NIC
|
||
// whose current address is on the same subnet as the target: that is the correct
|
||
// NIC to reconfigure, regardless of which Console NIC happened to send the RPLY.
|
||
private void ResolveAdapterForTargetIp(IPAddress targetIp, IPAddress targetMask)
|
||
{
|
||
byte[] tBytes = targetIp.GetAddressBytes();
|
||
byte[] mBytes = targetMask?.GetAddressBytes();
|
||
if (mBytes == null || mBytes.Length != 4) mBytes = new byte[] { 255, 255, 255, 0 };
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
|
||
if (IsVirtualAdapter(nic)) continue;
|
||
foreach (var ua in nic.GetIPProperties().UnicastAddresses)
|
||
{
|
||
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
||
if (IPAddress.IsLoopback(ua.Address)) continue;
|
||
byte[] aBytes = ua.Address.GetAddressBytes();
|
||
bool same = true;
|
||
for (int i = 0; i < 4; i++)
|
||
if ((aBytes[i] & mBytes[i]) != (tBytes[i] & mBytes[i])) { same = false; break; }
|
||
if (!same) continue;
|
||
try
|
||
{
|
||
int idx = nic.GetIPProperties().GetIPv4Properties().Index;
|
||
Log($"Adapter resolved to [{idx}] {nic.Name} (same subnet as target {targetIp})");
|
||
_adapterIndex = idx;
|
||
_adapterId = nic.Id;
|
||
_adapterName = nic.Name;
|
||
return;
|
||
}
|
||
catch (NetworkInformationException) { }
|
||
}
|
||
}
|
||
Log($"Warning: target IP {targetIp} not on any local subnet — keeping [{_adapterIndex}] {_adapterName}");
|
||
}
|
||
|
||
// Returns (IPv4 interface index, adapter GUID).
|
||
// Index is used with `netsh interface ipv4 ... interface=N` (avoids name-quoting issues).
|
||
// GUID is used for direct registry writes to persist static IP across reboots.
|
||
private static (int index, string id, string name) FindFirstEthernetAdapter()
|
||
{
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||
if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
|
||
if (IsVirtualAdapter(nic)) continue;
|
||
// Skip interfaces with no IPv4 unicast address (NIC Up but no IP yet)
|
||
bool hasIp = false;
|
||
foreach (var ua in nic.GetIPProperties().UnicastAddresses)
|
||
if (ua.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
|
||
&& !IPAddress.IsLoopback(ua.Address))
|
||
{ hasIp = true; break; }
|
||
if (!hasIp) continue;
|
||
try
|
||
{
|
||
int idx = nic.GetIPProperties().GetIPv4Properties().Index;
|
||
Log2($"Selected physical adapter: [{idx}] {nic.Name} / {nic.Description}");
|
||
return (idx, nic.Id, nic.Name);
|
||
}
|
||
catch (NetworkInformationException) { }
|
||
}
|
||
throw new InvalidOperationException("Could not find a physical Ethernet adapter with an IPv4 address.");
|
||
}
|
||
|
||
private static string FindAdapterId(string displayName)
|
||
{
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (string.Equals(nic.Name, displayName, StringComparison.OrdinalIgnoreCase) ||
|
||
string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase))
|
||
return nic.Id;
|
||
}
|
||
return FindFirstEthernetAdapter().id; // .name ignored in fallback
|
||
}
|
||
|
||
// Find index for a named adapter (fallback when caller passes a display name)
|
||
private static int FindAdapterIndex(string displayName)
|
||
{
|
||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||
{
|
||
if (string.Equals(nic.Name, displayName, StringComparison.OrdinalIgnoreCase) ||
|
||
string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
try { return nic.GetIPProperties().GetIPv4Properties().Index; }
|
||
catch (NetworkInformationException) { }
|
||
}
|
||
}
|
||
// Fall back to auto-detect if name not matched
|
||
return FindFirstEthernetAdapter().index; // .name ignored in fallback
|
||
}
|
||
|
||
// --- Random string generator ---
|
||
// Alphabet length is 32 (2^5), byte range is 256 (2^8) — no modulo bias.
|
||
private static string GenerateRandomString(int length)
|
||
{
|
||
var buf = new byte[length];
|
||
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(buf);
|
||
var sb = new System.Text.StringBuilder(length);
|
||
for (int i = 0; i < length; i++)
|
||
sb.Append(Proto.Alphabet[buf[i] % Proto.Alphabet.Length]);
|
||
return sb.ToString();
|
||
}
|
||
|
||
private void Log(string msg) => _log?.Invoke(msg);
|
||
|
||
public void Dispose()
|
||
{
|
||
_running = false;
|
||
try { _replySocket?.Close(); } catch { }
|
||
_plasma?.Dispose();
|
||
}
|
||
}
|
||
}
|