fix(control-surface): reserve loopback URL ACL at install time

The control surface uses HttpListener (http.sys), which denies prefix
registration to standard users -- on every non-Administrator install the
REST/WebSocket surface died at startup with Access is denied (5), taking
the /ui phone panel and Companion integration with it. The MSI runs
elevated, so it now reserves http://127.0.0.1:9755/ on install and deletes
the reservation on uninstall.

Deliberately NOT the strong wildcard: verified empirically that an idle
http://+:9755/ RESERVATION makes http.sys answer 503 for every request to
the loopback-bound listener without the request ever reaching the app.
The in-app failure guidance suggested exactly that reservation; corrected
to recommend the bound prefix and warn about the stale-wildcard trap.

Verified on a clean install: urlacl present, GET / and /ui and
/participants all 200 as a standard user; reservation removed on
uninstall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-07-06 21:46:12 -04:00
parent e1b3339cc2
commit b9e2180030
4 changed files with 82 additions and 8 deletions

View file

@ -27,6 +27,13 @@ Beta pre-release for the framerate and reliability work since 1.1.0.
- **MSI ProductVersion now tracks the build version** — it was hardcoded to - **MSI ProductVersion now tracks the build version** — it was hardcoded to
1.0.0.0, so in-place upgrades (MajorUpgrade) never saw a newer version. 1.0.0.0, so in-place upgrades (MajorUpgrade) never saw a newer version.
Upgrading from an earlier install may require a manual uninstall once. Upgrading from an earlier install may require a manual uninstall once.
- **Control surface works out of the box for standard users** — the installer
now reserves the http.sys URL namespace for `http://127.0.0.1:9755/`
(removed again on uninstall). Previously the REST/WebSocket control surface
failed with "Access is denied" on every non-Administrator launch. Note for
LAN-reachable mode: it still needs its own `http://+:9755/` reservation
(the app logs the exact command), and that reservation must be deleted when
reverting to loopback-only — while idle it makes http.sys 503 every request.
- **CAB embedded in the MSI** — the installer is a single self-contained - **CAB embedded in the MSI** — the installer is a single self-contained
file again; distributing only the MSI no longer fails with error 1302. file again; distributing only the MSI no longer fails with error 1302.

View file

@ -39,6 +39,8 @@
--> -->
<ItemGroup> <ItemGroup>
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" /> <PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
<!-- WixQuietExec for the URL ACL custom actions (control surface, port 9755). -->
<PackageReference Include="WixToolset.Util.wixext" Version="5.0.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -224,5 +224,62 @@
</Component> </Component>
</ComponentGroup> </ComponentGroup>
<!--
URL ACL reservation for the control surface (REST/WebSocket, default
port 9755). The server uses HttpListener (http.sys), and http.sys
denies prefix registration to non-elevated processes unless the URL
namespace is reserved — without this, the control surface fails with
"Access is denied (5)" on every standard-user launch and the operator
has to run the app elevated or run netsh by hand. The MSI already runs
elevated, so reserve the namespace here.
IMPORTANT — reserve ONLY the explicit loopback prefix, never
http://+:9755/. Verified empirically 2026-07-06: an idle strong-
wildcard RESERVATION poisons http.sys routing for the app's IP-bound
registration — every request 503s inside http.sys without ever
reaching the listener. The LAN-reachable mode (which binds
http://+:9755/) remains an opt-in that needs its own reservation;
the app logs the exact netsh command when that bind fails, and the
operator must delete that + reservation again if they revert to
loopback-only.
SDDL D:(A;;GX;;;WD) = allow GENERIC_EXECUTE (listen) to Everyone by
SID, which survives non-English locales where "Everyone" isn't a
valid account name. Operators who change the port in settings still
need the app's documented netsh one-liner for the new port.
Return="ignore": an already-existing reservation (repair/reinstall)
exits non-zero and must not fail the install.
-->
<SetProperty Id="AddControlSurfaceUrlAcl"
Value="&quot;[System64Folder]netsh.exe&quot; http add urlacl url=http://127.0.0.1:9755/ sddl=D:(A;;GX;;;WD)"
Sequence="execute"
Before="AddControlSurfaceUrlAcl" />
<CustomAction Id="AddControlSurfaceUrlAcl"
BinaryRef="Wix4UtilCA_X64"
DllEntry="WixQuietExec64"
Execute="deferred"
Impersonate="no"
Return="ignore" />
<SetProperty Id="RemoveControlSurfaceUrlAcl"
Value="&quot;[System64Folder]netsh.exe&quot; http delete urlacl url=http://127.0.0.1:9755/"
Sequence="execute"
Before="RemoveControlSurfaceUrlAcl" />
<CustomAction Id="RemoveControlSurfaceUrlAcl"
BinaryRef="Wix4UtilCA_X64"
DllEntry="WixQuietExec64"
Execute="deferred"
Impersonate="no"
Return="ignore" />
<InstallExecuteSequence>
<Custom Action="AddControlSurfaceUrlAcl"
After="InstallFiles"
Condition="NOT Installed OR REINSTALL" />
<Custom Action="RemoveControlSurfaceUrlAcl"
After="InstallInitialize"
Condition="REMOVE=&quot;ALL&quot;" />
</InstallExecuteSequence>
</Package> </Package>
</Wix> </Wix>

View file

@ -97,11 +97,17 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
/// "headless show machine + thin client controller" setups. When false /// "headless show machine + thin client controller" setups. When false
/// (default), binds to <c>127.0.0.1</c> only. /// (default), binds to <c>127.0.0.1</c> only.
/// ///
/// LAN binding requires either running Dragon-ISO as Administrator OR a /// Binding requires either running Dragon-ISO as Administrator OR a
/// one-time URL ACL reservation at the OS level: /// one-time URL ACL reservation at the OS level for the prefix actually
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code> /// being bound (the installer reserves the loopback default,
/// If neither is in place the listener throws AccessDeniedException /// <c>http://127.0.0.1:9755/</c>):
/// which we catch and surface as a logger warning. /// <code>netsh http add urlacl url=http://127.0.0.1:9755/ sddl=D:(A;;GX;;;WD)</code>
/// or <c>url=http://+:9755/</c> for LAN mode. CAUTION: an idle
/// <c>http://+:port/</c> reservation makes http.sys 503 every request to
/// the loopback-bound listener — only add it while LAN mode is in use,
/// and delete it when reverting to loopback-only. If no reservation is
/// in place the listener throws AccessDeniedException which we catch and
/// surface as a logger warning.
/// </summary> /// </summary>
public void Start(int port, bool bindToLan = false) public void Start(int port, bool bindToLan = false)
{ {
@ -123,9 +129,11 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
{ {
_logger?.LogWarning(ex, _logger?.LogWarning(ex,
"Could not start control surface on {Prefix}. " + "Could not start control surface on {Prefix}. " +
"If binding to LAN, run as Administrator once OR run: " + "Run as Administrator once OR reserve the URL namespace: " +
"netsh http add urlacl url=http://+:{Port}/ user=Everyone", "netsh http add urlacl url={UrlAclUrl} sddl=D:(A;;GX;;;WD) " +
prefix, port); "(if switching back from LAN mode, also delete the stale " +
"http://+:{Port}/ reservation — while idle it 503s the loopback listener)",
prefix, prefix, port);
_listener = null; _listener = null;
return; return;
} }