Files
nucleic-windows/NucleicBroker/Wslc/WslcInternal.cs
T

385 lines
18 KiB
C#

#if USE_WSLC
using System.Runtime.InteropServices;
namespace NucleicBroker.Wslc;
/// <summary>
/// D13's internal-COM arm, **Tier 1: recover** (docs/WINDOWS_PORT.md §13.2).
///
/// The compat SDK cannot re-adopt a running session — its `Session` constructor is lazy and
/// `Start()` refuses an existing name with `ERROR_ALREADY_EXISTS`. So a broker that restarts
/// while its session is up cannot reach the sandbox again, and today that costs the user a manual
/// `wsl --shutdown`. This class fixes exactly that: it opens the orphaned session through the
/// **service-internal** COM interface, reports what was running, terminates it, and lets the
/// facade create a fresh one through the ordinary compat path.
///
/// It deliberately does NOT try to keep those containers alive — that is Tier 2, and it is
/// blocked (§13.2): `Session.FromAbi()` throws on a service-side pointer, because the WinRT layer
/// the C# projection wraps lives client-side in `wslcsdk.dll`. Everything here is confirmed on
/// hardware; nothing here depends on that unresolved question.
///
/// **Four things that are not obvious and each cost a debugging round:**
///
/// 1. **There is no CLSID for `IWSLCSessionManager`.** `wslc.idl` declares interfaces and no
/// activatable class. The entry point is the *compat* coclass — `WSLCCompatSessionManager`
/// also implements the internal interface. One object, two faces.
/// 2. **The proxy must grant IMPERSONATE.** Per-user calls like `OpenSessionByName` fail
/// `0x80070542` (`ERROR_BAD_IMPERSONATION_LEVEL`) under .NET's default `IDENTIFY` — a security
/// error that reads exactly like "not found". `GetVersion`/`ListSessions` don't impersonate,
/// so they succeed and make it look like a per-method gap. <see cref="InitializeSecurity"/>
/// handles this process-wide; the per-proxy blanket here is belt and braces.
/// 3. **Vtable slots are fixed by declaration ORDER, not signature.** Only the methods actually
/// called need accurate signatures, which is what makes reaching `ListContainers` (method #19,
/// behind four methods taking a by-value `WSLCHandle` union) tractable at all.
/// 4. **The ABI is explicitly unstable.** `wslc.idl` says breaking changes are fine because
/// Microsoft ships both ends. We are not both ends, so every entry point here is probed and
/// every failure degrades to "no recovery" rather than propagating.
/// </summary>
internal sealed class WslcInternal : IDisposable
{
private IWSLCSessionManager? manager;
private IntPtr managerPtr;
/// <summary>What a recovery found and did, for the host log and the `session.down` story.</summary>
internal sealed record Recovery(IReadOnlyList<string> Containers, bool Terminated);
/// <summary>
/// Bind the internal interface, or return null. Called once at facade construction so the
/// result can be reported in the `capabilities` hello (§2.3) rather than discovered when a
/// user's broker restarts.
/// </summary>
internal static WslcInternal? TryBind()
{
var clsid = ClsidWslcCompatSessionManager;
var iid = IidWslcSessionManager;
// Ask for the internal interface directly. The compat coclass implements both, and going
// straight for it means a machine where this arm is unavailable fails here rather than
// half-way through a recovery.
var hr = CoCreateInstance(in clsid, IntPtr.Zero, ClsctxAll, in iid, out var ptr);
if (hr < 0)
{
// REGDB_E_CLASSNOTREG here means WSL simply isn't installed — the §8 onboarding
// state, not a defect. Saying "recovery unavailable" without that distinction reads
// as a broker fault on a machine that has not been set up yet.
Console.Error.WriteLine((uint)hr == RegdbEClassNotReg
? "wslc: WSL is not installed — internal COM absent, as expected before onboarding"
: $"wslc: internal COM did not bind (0x{hr:X8}) — a broker restart will not "
+ "auto-recover a running session; D13 Tier 1 is unavailable on this machine");
return null;
}
RaiseImpersonation(ptr);
try
{
return new WslcInternal
{
managerPtr = ptr,
manager = (IWSLCSessionManager)Marshal.GetObjectForIUnknown(ptr),
};
}
catch (Exception e)
{
Marshal.Release(ptr);
Console.Error.WriteLine($"wslc: internal COM bound but unusable: {e.Message}");
return null;
}
}
/// <summary>
/// Open the orphaned session named <paramref name="name"/>, note what was running in it, and
/// terminate it. Returns null when there is nothing to recover — which is the ordinary case
/// and not an error.
///
/// Terminating rather than adopting is the deliberate Tier 1 choice: the containers are lost,
/// but they are lost today too, and `ContainerManager.reconcile` already copes with a sandbox
/// that came back empty. What it buys is that the *next* `Start()` succeeds.
/// </summary>
internal Recovery? RecoverSession(string name)
{
if (manager is null) return null;
int hr;
IntPtr sessionPtr;
try
{
hr = manager.OpenSessionByName(name, out sessionPtr);
}
catch (Exception e)
{
Console.Error.WriteLine($"wslc: OpenSessionByName('{name}') threw: {e.Message}");
return null;
}
if (hr < 0)
{
Console.Error.WriteLine($"wslc: no recoverable session '{name}' (0x{hr:X8})"
+ ((uint)hr == ErrorBadImpersonationLevel
? " — IMPERSONATE was not granted; CoInitializeSecurity must run before the "
+ "first COM call in the process"
: ""));
return null;
}
// The session proxy is a separate object from the manager, so it needs its own blanket.
RaiseImpersonation(sessionPtr);
try
{
var session = (IWSLCSession)Marshal.GetObjectForIUnknown(sessionPtr);
var containers = ListContainers(session);
var terminated = Terminate(session);
Console.Error.WriteLine(
$"wslc: recovered orphaned session '{name}' — {containers.Count} container(s) "
+ $"[{string.Join(", ", containers)}], terminated={terminated}");
return new Recovery(containers, terminated);
}
catch (Exception e)
{
Console.Error.WriteLine($"wslc: recovery of '{name}' failed: {e.Message}");
return null;
}
finally
{
Marshal.Release(sessionPtr);
}
}
/// <summary>
/// The container roster of a session, by name. Enumeration the compat SDK has no call for at
/// all — <c>Session</c> exposes no listing and <c>Container</c> carries no <c>Name</c>.
/// </summary>
private static IReadOnlyList<string> ListContainers(IWSLCSession session)
{
// Flags=All, or the listing is running-containers-only and a stopped container silently
// vanishes from the recovery report.
var options = new WslcListContainersOptions
{
Flags = WslcListContainersFlagsAll,
Limit = 0,
Filters = IntPtr.Zero,
FiltersCount = 0,
};
var optionsPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf<WslcListContainersOptions>());
var containers = IntPtr.Zero;
var ports = IntPtr.Zero;
try
{
Marshal.StructureToPtr(options, optionsPtr, fDeleteOld: false);
var hr = session.ListContainers(optionsPtr, out containers, out var count,
out ports, out _);
if (hr < 0)
{
Console.Error.WriteLine($"wslc: ListContainers failed (0x{hr:X8})");
return [];
}
var size = Marshal.SizeOf<WslcContainerEntry>();
var names = new List<string>((int)count);
for (var i = 0; i < count; i++)
{
var entry = Marshal.PtrToStructure<WslcContainerEntry>(containers + i * size);
names.Add(string.IsNullOrEmpty(entry.Name) ? entry.Id : entry.Name);
}
return names;
}
finally
{
Marshal.FreeCoTaskMem(optionsPtr);
// Both out-arrays are callee-allocated; nobody else frees them.
if (containers != IntPtr.Zero) Marshal.FreeCoTaskMem(containers);
if (ports != IntPtr.Zero) Marshal.FreeCoTaskMem(ports);
}
}
private static bool Terminate(IWSLCSession session)
{
try
{
var hr = session.Terminate();
if (hr >= 0) return true;
Console.Error.WriteLine($"wslc: session Terminate failed (0x{hr:X8})");
return false;
}
catch (Exception e)
{
Console.Error.WriteLine($"wslc: session Terminate threw: {e.Message}");
return false;
}
}
public void Dispose()
{
manager = null;
if (managerPtr != IntPtr.Zero)
{
Marshal.Release(managerPtr);
managerPtr = IntPtr.Zero;
}
}
// MARK: - COM security
/// <summary>
/// Grant servers the right to impersonate this process, for **every** proxy it will hold.
///
/// Must run before the first COM call in the process or it fails `RPC_E_TOO_LATE` — and the
/// compat SDK makes COM calls of its own, so this has to precede any `WslcService`/`Session`
/// use, not merely precede the internal arm. Failing is not fatal: only the per-user internal
/// calls need it, so the sandbox still runs and recovery is what degrades.
/// </summary>
internal static void InitializeSecurity()
{
var hr = CoInitializeSecurity(
IntPtr.Zero, -1, IntPtr.Zero, IntPtr.Zero,
RpcCAuthnLevelDefault, RpcCImpLevelImpersonate, IntPtr.Zero, EoacNone, IntPtr.Zero);
// RPC_E_TOO_LATE means something already initialised security — worth saying, because it
// silently removes session recovery and nothing else will mention it.
if (hr < 0)
Console.Error.WriteLine(
$"wslc: CoInitializeSecurity failed (0x{hr:X8})"
+ ((uint)hr == RpcETooLate
? " — RPC_E_TOO_LATE: a COM call ran first. Session recovery will fail "
+ "0x80070542."
: ""));
}
private static void RaiseImpersonation(IntPtr proxy) =>
// Per-proxy, and harmless if CoInitializeSecurity already covered it. Kept because the
// process-wide call is order-dependent and this one is not.
CoSetProxyBlanket(
proxy, RpcCAuthnDefault, RpcCAuthzDefault, ColeDefaultPrincipal,
RpcCAuthnLevelDefault, RpcCImpLevelImpersonate, ColeDefaultAuthinfo, EoacNone);
// MARK: - Interop
/// `WSLCCompatSessionManager` from WSLCCompat.idl. Not a typo that this is the *compat*
/// class: `wslc.idl` declares no coclass, and this one answers a QI for the internal
/// interface (confirmed on hardware, §13.2).
private static readonly Guid ClsidWslcCompatSessionManager =
new("a9b7a1b9-0671-405c-95f1-e0612cb4ce8f");
private static readonly Guid IidWslcSessionManager = new("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760");
private const uint ClsctxAll = 0x17;
private const uint RpcCAuthnDefault = 0xFFFFFFFF;
private const uint RpcCAuthzDefault = 0xFFFFFFFF;
private const uint RpcCAuthnLevelDefault = 0;
private const uint RpcCImpLevelImpersonate = 3;
private const uint EoacNone = 0;
private const uint RpcETooLate = 0x80010119;
private const uint RegdbEClassNotReg = 0x80040154;
private const uint ErrorBadImpersonationLevel = 0x80070542;
private const uint WslcListContainersFlagsAll = 1;
private static readonly IntPtr ColeDefaultPrincipal = new(-1);
private static readonly IntPtr ColeDefaultAuthinfo = new(-1);
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(
in Guid clsid, IntPtr outer, uint clsContext, in Guid iid, out IntPtr instance);
[DllImport("ole32.dll")]
private static extern int CoSetProxyBlanket(
IntPtr proxy, uint authnService, uint authzService, IntPtr serverPrincipalName,
uint authnLevel, uint impersonationLevel, IntPtr authInfo, uint capabilities);
[DllImport("ole32.dll")]
private static extern int CoInitializeSecurity(
IntPtr securityDescriptor, int authSvcCount, IntPtr authSvc, IntPtr reserved1,
uint authnLevel, uint impersonationLevel, IntPtr authList, uint capabilities,
IntPtr reserved3);
[StructLayout(LayoutKind.Sequential)]
private struct WslcListContainersOptions
{
public uint Flags;
public int Limit;
public IntPtr Filters;
public uint FiltersCount;
}
/// <summary>
/// `WSLCContainerEntry` from wslc.idl. The three char arrays are **inline fixed buffers**,
/// not pointers — `ByValTStr`/`Ansi`, with the sizes straight from the IDL's `+ 1` constants
/// (255+1, 255+1, 64+1). Getting a size wrong here does not fail loudly; it silently shifts
/// every later field. The equivalent layout was validated on hardware via
/// `ListSessions`, whose entry struct has the same shape (§13.2).
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
private struct WslcContainerEntry
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string Name;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string Image;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 65)] public string Id;
public ulong StateChangedAt;
public ulong CreatedAt;
public uint State;
}
[ComImport, Guid("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IWSLCSessionManager
{
[PreserveSig] int GetVersion(out WslcVersion version);
[PreserveSig] int CreateSession(IntPtr settings, uint flags, IntPtr warningCallback, out IntPtr session);
[PreserveSig] int EnterSession(
[MarshalAs(UnmanagedType.LPWStr)] string displayName,
[MarshalAs(UnmanagedType.LPWStr)] string storagePath,
IntPtr warningCallback, out IntPtr session);
[PreserveSig] int ListSessions(out IntPtr sessions, out uint count);
[PreserveSig] int OpenSession(uint id, out IntPtr session);
[PreserveSig] int OpenSessionByName(
[MarshalAs(UnmanagedType.LPWStr)] string displayName, out IntPtr session);
}
[StructLayout(LayoutKind.Sequential)]
internal struct WslcVersion
{
public uint Major;
public uint Minor;
public uint Revision;
}
/// <summary>
/// `IWSLCSession`, declared only as far as <c>Terminate</c> (method #23).
///
/// **Every method ahead of the ones we call must still be declared**, because a COM vtable is
/// addressed by slot — but only the called ones need accurate signatures, since a method that
/// is never invoked is never marshalled. That is what makes this tractable: four of the
/// placeholders (`LoadImage`, `ImportImage`, `SaveImage`, `SaveImages`) take `WSLCHandle` — a
/// tagged union — **by value**, which would be genuinely awkward to marshal and does not have
/// to be. Parameter *counts* are kept faithful to the IDL purely as documentation.
///
/// Do not reorder. Do not delete an unused entry. Either silently shifts every slot below it.
/// </summary>
[ComImport, Guid("EF0661E4-6364-40EA-B433-E2FDF11F3519"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IWSLCSession
{
[PreserveSig] int GetId(out uint id); // 1
[PreserveSig] int GetDisplayName(out IntPtr displayName); // 2
[PreserveSig] int GetState(out uint state); // 3
[PreserveSig] int GetTerminationEvent(out IntPtr eventHandle); // 4
[PreserveSig] int GetTerminationReason(out uint reason, out IntPtr details); // 5
[PreserveSig] int PullImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 6
[PreserveSig] int BuildImage(IntPtr a, IntPtr b, IntPtr c); // 7
[PreserveSig] int LoadImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 8 (WSLCHandle by value)
[PreserveSig] int ImportImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d, IntPtr e); // 9 (WSLCHandle by value)
[PreserveSig] int SaveImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 10 (WSLCHandle by value)
[PreserveSig] int SaveImages(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 11 (WSLCHandle by value)
[PreserveSig] int ListImages(IntPtr a, IntPtr b, IntPtr c); // 12
[PreserveSig] int DeleteImage(IntPtr a, IntPtr b, IntPtr c); // 13
[PreserveSig] int TagImage(IntPtr a); // 14
[PreserveSig] int InspectImage(IntPtr a, IntPtr b); // 15
[PreserveSig] int PruneImages(IntPtr a, IntPtr b, IntPtr c, IntPtr d, IntPtr e); // 16
[PreserveSig] int CreateContainer(IntPtr a, IntPtr b, IntPtr c); // 17
[PreserveSig] int OpenContainer(IntPtr a, IntPtr b); // 18
[PreserveSig] int ListContainers( // 19
IntPtr options, out IntPtr containers, out uint count,
out IntPtr ports, out uint portsCount);
[PreserveSig] int PruneContainers(IntPtr a, IntPtr b, IntPtr c); // 20
[PreserveSig] int CreateRootNamespaceProcess(
IntPtr a, IntPtr b, IntPtr c, IntPtr d, IntPtr e, IntPtr f); // 21
[PreserveSig] int FormatVirtualDisk(IntPtr a); // 22
[PreserveSig] int Terminate(); // 23
}
}
#endif