- AsioApartment gains a bounded Invoke; the close path uses it (8s cap - a healthy close is ~5ms). A driver that wedges inside Stop/Dispose can no longer hang a live driver-switch or the resume path: the caller logs the timeout and abandons the driver (old park semantics, reclaimed at process exit). Apartment test extended to pin the timeout path. - Service self-update is now NATIVE code: a detached copy of the NEW build runs --service-selfupdate (stop -> CopyProgramTo -> start, every step logged to the update log). The old PowerShell restart script silently died wherever Group Policy enforces execution policy (Bypass is ignored there), stranding the service on the old build. Verb wired through IsServiceInvocation + ServiceEntry; verb-gate test now pins all six verbs. - UpdateApplier SwapInNewFiles/RollBack made internal + pinned by a real-folder test: swap lands exactly the release's files (user files untouched), backup holds the originals, rollback restores BYTE-EXACT including deleting newly-created files - the contract that stops a failed update bricking an install. - RouterPortMapper: discovery callback now checks disposed under the gate, so an in-flight callback can't re-open the port map Dispose just removed. Gate 59/59. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
342 lines
14 KiB
C#
342 lines
14 KiB
C#
using System;
|
|
using System.Net;
|
|
using System.Threading;
|
|
using Mono.Nat;
|
|
|
|
namespace RemSound.App;
|
|
|
|
/// <summary>
|
|
/// Status of the router port mapping attempt — used to drive the inline status label in
|
|
/// the Preferences dialog.
|
|
/// </summary>
|
|
internal enum RouterMappingStatus
|
|
{
|
|
/// <summary>The feature is off (the user hasn't enabled UPnP).</summary>
|
|
Disabled,
|
|
/// <summary>Looking for a UPnP / NAT-PMP / PCP router on the LAN.</summary>
|
|
Searching,
|
|
/// <summary>Mapping opened successfully and the router is reachable.</summary>
|
|
Mapped,
|
|
/// <summary>No router with UPnP / NAT-PMP / PCP support was found. Either the router
|
|
/// doesn't support it, has it disabled, or the network blocks discovery.</summary>
|
|
NoRouterFound,
|
|
/// <summary>A router was found and the mapping was added, but the reported external
|
|
/// address is in the carrier-grade NAT (CGNAT) range — peers on the public internet
|
|
/// will not be able to reach this machine even though the local router cooperated.</summary>
|
|
CgnatDetected,
|
|
/// <summary>A router was found but the mapping attempt failed (port already mapped to
|
|
/// another device, router rejected the request, etc.). <see cref="LastError"/> has the
|
|
/// detail.</summary>
|
|
MappingFailed,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks the user's router to forward inbound UDP <see cref="AudioPort"/> traffic to this
|
|
/// machine, using UPnP / NAT-PMP / PCP via the Mono.Nat library. The point is to spare
|
|
/// home users from manual port-forwarding when they want peers on the public internet to
|
|
/// reach them. Mono.Nat picks whichever protocol the router speaks.
|
|
///
|
|
/// Off by default and gated by <c>AppConfig.UpnpEnabled</c> — RemSound never pokes the
|
|
/// router unless the user has explicitly ticked the Preferences checkbox. Failures are
|
|
/// surfaced via <see cref="StatusChanged"/> and the Preferences status label; they never
|
|
/// throw or pop a dialog (the network is too lumpy for a popup to be useful).
|
|
///
|
|
/// Lifecycle:
|
|
/// * <see cref="Start"/> kicks off discovery on a background task. When (or if) a router
|
|
/// replies, the mapping is added and <see cref="StatusChanged"/> fires with
|
|
/// <see cref="RouterMappingStatus.Mapped"/>.
|
|
/// * Renewal happens automatically — Mono.Nat extends the lease before it expires.
|
|
/// * <see cref="Refresh"/> can be called after a sleep / resume cycle to make sure the
|
|
/// router didn't drop the mapping while the machine was off; this re-runs discovery.
|
|
/// * <see cref="Stop"/> politely removes the mapping and stops discovery.
|
|
///
|
|
/// Detects CGNAT by checking whether the router's reported external address falls in
|
|
/// <c>100.64.0.0/10</c> (RFC 6598) — when it does, UPnP technically succeeded but the user
|
|
/// is still unreachable from the public internet because of an upstream ISP NAT layer.
|
|
/// We surface that as a distinct status so the user understands why peers still can't
|
|
/// connect and is pointed at Tailscale / the relay instead.
|
|
/// </summary>
|
|
internal sealed class RouterPortMapper : IDisposable
|
|
{
|
|
/// <summary>The UDP port RemSound uses for audio + heartbeat.</summary>
|
|
public const int AudioPort = 47830;
|
|
|
|
/// <summary>Lease duration on the port mapping, in seconds. The router (and Mono.Nat's
|
|
/// internal renewal) will refresh this before it expires; we set a deliberately
|
|
/// short-ish lease so a long sleep on the machine doesn't leave a stale forwarded port
|
|
/// pointing at us forever.</summary>
|
|
private const int MappingLeaseSeconds = 3600;
|
|
|
|
private readonly Action<string>? log;
|
|
private readonly object gate = new();
|
|
private INatDevice? device;
|
|
private Mapping? mapping;
|
|
private IPAddress? externalAddress;
|
|
private string lastError = "";
|
|
private RouterMappingStatus status = RouterMappingStatus.Disabled;
|
|
private bool searching;
|
|
private bool disposed;
|
|
|
|
/// <summary>Raised whenever <see cref="Status"/> changes. Always fires on a thread-pool
|
|
/// thread — the caller is responsible for marshaling onto the UI thread if it touches
|
|
/// UI state.</summary>
|
|
public event EventHandler? StatusChanged;
|
|
|
|
public RouterPortMapper(Action<string>? log = null)
|
|
{
|
|
this.log = log;
|
|
}
|
|
|
|
/// <summary>Current state of the mapping attempt. Read by the Preferences dialog to
|
|
/// keep its inline status label up to date.</summary>
|
|
public RouterMappingStatus Status
|
|
{
|
|
get { lock (gate) { return status; } }
|
|
}
|
|
|
|
/// <summary>The external (WAN-side) address and port the router reports for this
|
|
/// machine when the mapping is open. Null until <see cref="Status"/> is
|
|
/// <see cref="RouterMappingStatus.Mapped"/> or <see cref="RouterMappingStatus.CgnatDetected"/>.</summary>
|
|
public IPEndPoint? ExternalEndpoint
|
|
{
|
|
get
|
|
{
|
|
lock (gate)
|
|
{
|
|
return externalAddress is null ? null : new IPEndPoint(externalAddress, AudioPort);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Last error message captured during a failed mapping attempt — surfaced in
|
|
/// the status label so the user has a hint at what's going on.</summary>
|
|
public string LastError
|
|
{
|
|
get { lock (gate) { return lastError; } }
|
|
}
|
|
|
|
/// <summary>Start (or restart) the UPnP discovery + mapping cycle. Safe to call multiple
|
|
/// times; redundant calls are coalesced.</summary>
|
|
public void Start()
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (disposed) return;
|
|
if (searching) return;
|
|
searching = true;
|
|
status = RouterMappingStatus.Searching;
|
|
lastError = "";
|
|
}
|
|
RaiseChanged();
|
|
try
|
|
{
|
|
// Subscribe-once: Refresh() (fired on every sleep/wake) calls Start() again without a
|
|
// prior unsubscribe, and DeviceFound is a STATIC Mono.Nat event — a bare += would stack
|
|
// a new handler every resume (duplicate port-maps + this object kept alive forever).
|
|
// Remove first so there's only ever one subscription.
|
|
NatUtility.DeviceFound -= OnDeviceFound;
|
|
NatUtility.DeviceFound += OnDeviceFound;
|
|
NatUtility.StartDiscovery();
|
|
log?.Invoke("UPnP discovery started");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
lock (gate)
|
|
{
|
|
searching = false;
|
|
status = RouterMappingStatus.MappingFailed;
|
|
lastError = ex.Message;
|
|
}
|
|
log?.Invoke($"UPnP discovery could not start: {ex.GetType().Name}: {ex.Message}");
|
|
RaiseChanged();
|
|
}
|
|
|
|
// Mono.Nat doesn't fire DeviceFound at all when the network has no UPnP / NAT-PMP /
|
|
// PCP router. Without a timeout the status would sit at Searching forever, which the
|
|
// user-facing label reads as "still trying" indefinitely. Give it a reasonable window
|
|
// and then declare no-router-found if nothing has replied.
|
|
ThreadPool.QueueUserWorkItem(_ =>
|
|
{
|
|
Thread.Sleep(TimeSpan.FromSeconds(8));
|
|
bool stillSearching;
|
|
lock (gate)
|
|
{
|
|
stillSearching = searching && status == RouterMappingStatus.Searching;
|
|
}
|
|
if (!stillSearching) return;
|
|
lock (gate)
|
|
{
|
|
searching = false;
|
|
status = RouterMappingStatus.NoRouterFound;
|
|
lastError = "";
|
|
}
|
|
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
|
|
log?.Invoke("UPnP discovery timed out — no router responded");
|
|
RaiseChanged();
|
|
});
|
|
}
|
|
|
|
/// <summary>Re-run discovery and re-create the mapping. Used by the resume handler to
|
|
/// recover from routers that drop NAT entries during the user's sleep window.</summary>
|
|
public void Refresh()
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (disposed) return;
|
|
}
|
|
log?.Invoke("UPnP refresh requested");
|
|
// Drop any existing mapping; Start() will rediscover and remap.
|
|
RemoveMappingBestEffort();
|
|
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
|
|
lock (gate)
|
|
{
|
|
searching = false;
|
|
status = RouterMappingStatus.Disabled;
|
|
device = null;
|
|
mapping = null;
|
|
externalAddress = null;
|
|
}
|
|
RaiseChanged();
|
|
Start();
|
|
}
|
|
|
|
/// <summary>Politely remove the mapping and stop discovery. Safe to call from
|
|
/// <c>FormClosing</c> or app shutdown.</summary>
|
|
public void Stop()
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (disposed) return;
|
|
}
|
|
RemoveMappingBestEffort();
|
|
log?.Invoke("UPnP: stopping discovery...");
|
|
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
|
|
try { NatUtility.DeviceFound -= OnDeviceFound; } catch { /* ignore */ }
|
|
lock (gate)
|
|
{
|
|
searching = false;
|
|
status = RouterMappingStatus.Disabled;
|
|
device = null;
|
|
mapping = null;
|
|
externalAddress = null;
|
|
lastError = "";
|
|
}
|
|
log?.Invoke("UPnP stopped");
|
|
RaiseChanged();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Run the full Stop() teardown FIRST (remove the router mapping, stop discovery, unsubscribe the
|
|
// static NatUtility.DeviceFound handler) while disposed is still false — otherwise Stop()'s own
|
|
// `if (disposed) return;` guard would skip all of it, leaving the forwarded port open until its
|
|
// lease expires and the object subscribed to the process-wide event. THEN mark disposed.
|
|
try { Stop(); } catch { /* shutting down */ }
|
|
lock (gate) { disposed = true; }
|
|
}
|
|
|
|
private void OnDeviceFound(object? sender, DeviceEventArgs args)
|
|
{
|
|
// A discovery callback can already be in flight when Stop()/Dispose() unsubscribes. Without this
|
|
// guard it would re-open the port map that Dispose just removed, leaving the router forwarding to
|
|
// us until the lease expires. Narrow race, cheap check (review sweep).
|
|
lock (gate) { if (disposed) return; }
|
|
try
|
|
{
|
|
var found = args.Device;
|
|
log?.Invoke($"UPnP device found: {found.GetType().Name}");
|
|
|
|
// Add the mapping. Mono.Nat's CreatePortMap is synchronous-but-quick; doing it
|
|
// on the discovery thread is acceptable. If the same port is already mapped to
|
|
// a different internal IP, the router will reject — surface that as MappingFailed
|
|
// so the Preferences label can tell the user.
|
|
try
|
|
{
|
|
var m = new Mapping(Protocol.Udp, AudioPort, AudioPort, MappingLeaseSeconds, "RemSound audio");
|
|
found.CreatePortMap(m);
|
|
IPAddress? ext = null;
|
|
try { ext = found.GetExternalIP(); }
|
|
catch (Exception ipEx) { log?.Invoke($"UPnP GetExternalIP failed: {ipEx.GetType().Name}: {ipEx.Message}"); }
|
|
|
|
lock (gate)
|
|
{
|
|
device = found;
|
|
mapping = m;
|
|
externalAddress = ext;
|
|
searching = false;
|
|
|
|
// Detect CGNAT — RFC 6598 reserves 100.64.0.0/10 for carrier-grade NAT.
|
|
// If the router's "external" address is in that range, UPnP succeeded
|
|
// but we're still behind another tier of NAT we can't open.
|
|
if (ext is not null && IsCgnatAddress(ext))
|
|
{
|
|
status = RouterMappingStatus.CgnatDetected;
|
|
lastError = "";
|
|
log?.Invoke($"UPnP mapping added but external address {ext} is in the CGNAT range — peers will not reach this machine via UPnP alone");
|
|
}
|
|
else
|
|
{
|
|
status = RouterMappingStatus.Mapped;
|
|
lastError = "";
|
|
log?.Invoke($"UPnP mapping added: external {ext}:{AudioPort} -> internal :{AudioPort}");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
lock (gate)
|
|
{
|
|
searching = false;
|
|
status = RouterMappingStatus.MappingFailed;
|
|
lastError = ex.Message;
|
|
}
|
|
log?.Invoke($"UPnP mapping creation failed: {ex.GetType().Name}: {ex.Message}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log?.Invoke($"UPnP DeviceFound handler threw: {ex.GetType().Name}: {ex.Message}");
|
|
}
|
|
RaiseChanged();
|
|
}
|
|
|
|
private void RemoveMappingBestEffort()
|
|
{
|
|
INatDevice? d;
|
|
Mapping? m;
|
|
lock (gate)
|
|
{
|
|
d = device;
|
|
m = mapping;
|
|
}
|
|
if (d is null || m is null) return;
|
|
try
|
|
{
|
|
// Breadcrumb BEFORE the call: DeletePortMap is a synchronous request to the router and is the
|
|
// usual suspect when close hangs. The log flushes each line, so if this blocks with no reply the
|
|
// last line in the log names it (and which NAT protocol) instead of leaving us guessing.
|
|
log?.Invoke($"UPnP: removing port mapping via {d.GetType().Name} (port {AudioPort})...");
|
|
d.DeletePortMap(m);
|
|
log?.Invoke($"UPnP mapping removed (port {AudioPort})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log?.Invoke($"UPnP mapping removal failed (harmless — the router will expire it): {ex.GetType().Name}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
internal static bool IsCgnatAddress(IPAddress addr) // internal for the self-test (RFC 6598 range pinning)
|
|
{
|
|
if (addr.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return false;
|
|
var b = addr.GetAddressBytes();
|
|
// 100.64.0.0/10 — RFC 6598 shared address space for CGNAT.
|
|
return b[0] == 100 && b[1] >= 64 && b[1] <= 127;
|
|
}
|
|
|
|
private void RaiseChanged()
|
|
{
|
|
try { StatusChanged?.Invoke(this, EventArgs.Empty); }
|
|
catch { /* event handlers shouldn't escape on their own thread */ }
|
|
}
|
|
}
|