Skip to content

Commit 1951e2f

Browse files
authored
Added mDNS advertising for simple LAN access to UI (#738)
* Change application URL to allow external access * Added mDNS advertising to allow for simple wowbot.local naming * Move batch file back to localhost * Move to a better maintained fork of Makaretu.Dns.Multicast * Moved to built in advertising system instead of manual. New library has better built in methods. * Rotated to cached IPs after trying a few different libraries and methods. Added MDNS_HOSTNAME env var to allow for custom naming * Update documentation to show custom mDNS name usage * Switched to system.threading.lock pattern * Using host lifetime to get kestral settings for port instead of env var * Added comment explaining event usage for kestral info * added USE_MDNS env var to enable mDNS
1 parent ddb7e3e commit 1951e2f

File tree

7 files changed

+356
-5
lines changed

7 files changed

+356
-5
lines changed

BlazorServer/BlazorServer.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
</ItemGroup>
2525

2626
<ItemGroup>
27+
<PackageReference Include="Makaretu.Dns.Multicast.New" />
2728
<PackageReference Include="Serilog.AspNetCore" />
2829
<PackageReference Include="Serilog.Enrichers.Environment" />
2930
<PackageReference Include="Serilog.Enrichers.Process" />
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
using Makaretu.Dns;
2+
3+
using Microsoft.Extensions.Hosting;
4+
using Microsoft.Extensions.Logging;
5+
6+
using Microsoft.AspNetCore.Hosting.Server;
7+
using Microsoft.AspNetCore.Hosting.Server.Features;
8+
9+
using System;
10+
using System.Linq;
11+
using System.Net;
12+
using System.Net.NetworkInformation;
13+
using System.Net.Sockets;
14+
using System.Threading;
15+
using System.Threading.Tasks;
16+
17+
namespace BlazorServer;
18+
19+
/// <summary>
20+
/// Background service that advertises the Blazor Server via mDNS,
21+
/// making it accessible at http://wowbot.local or whatever is set at MDNS_HOSTNAME
22+
/// </summary>
23+
public sealed class MdnsAdvertisingService : IHostedService, IDisposable
24+
{
25+
private readonly ILogger<MdnsAdvertisingService> _logger;
26+
private int _port;
27+
private readonly string _hostname;
28+
private readonly IServer _server;
29+
private readonly IHostApplicationLifetime _lifetime;
30+
private CancellationTokenRegistration _startedRegistration;
31+
private MulticastService? _multicastService;
32+
private ServiceDiscovery? _serviceDiscovery;
33+
private ServiceProfile? _serviceProfile;
34+
private bool _isAdvertising;
35+
36+
// Cached IP addresses - refreshed when network interfaces change
37+
private IPAddress[] _cachedAddresses = [];
38+
private readonly Lock _addressLock = new();
39+
40+
public MdnsAdvertisingService(
41+
ILogger<MdnsAdvertisingService> logger,
42+
IServer server,
43+
IHostApplicationLifetime lifetime)
44+
{
45+
_logger = logger;
46+
_server = server;
47+
_lifetime = lifetime;
48+
_hostname = Environment.GetEnvironmentVariable("MDNS_HOSTNAME") ?? "wowbot";
49+
_port = 5000;
50+
51+
// Subscribe to network address changes
52+
NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
53+
}
54+
55+
private void OnNetworkAddressChanged(object? sender, EventArgs e)
56+
{
57+
_logger.LogDebug("mDNS: Network address changed, refreshing IP cache");
58+
RefreshIPAddressCache();
59+
60+
// Re-announce with new addresses
61+
if (_isAdvertising)
62+
{
63+
AnnounceHostname();
64+
}
65+
}
66+
67+
private void RefreshIPAddressCache()
68+
{
69+
var addresses = new System.Collections.Generic.List<IPAddress>();
70+
71+
foreach (var netInterface in NetworkInterface.GetAllNetworkInterfaces())
72+
{
73+
if (netInterface.OperationalStatus != OperationalStatus.Up)
74+
continue;
75+
76+
if (netInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback)
77+
continue;
78+
79+
var ipProps = netInterface.GetIPProperties();
80+
foreach (var addr in ipProps.UnicastAddresses)
81+
{
82+
if (addr.Address.AddressFamily == AddressFamily.InterNetwork ||
83+
addr.Address.AddressFamily == AddressFamily.InterNetworkV6)
84+
{
85+
addresses.Add(addr.Address);
86+
}
87+
}
88+
}
89+
90+
using (_addressLock.EnterScope())
91+
{
92+
_cachedAddresses = addresses.ToArray();
93+
}
94+
}
95+
96+
private IPAddress[] GetCachedAddresses()
97+
{
98+
using (_addressLock.EnterScope())
99+
{
100+
return _cachedAddresses;
101+
}
102+
}
103+
104+
public Task StartAsync(CancellationToken cancellationToken)
105+
{
106+
try
107+
{
108+
// Initialize IP address cache
109+
RefreshIPAddressCache();
110+
111+
// Using Application started event to get the port from Kestral
112+
_startedRegistration = _lifetime.ApplicationStarted.Register(() =>
113+
{
114+
var addressFeature = _server.Features.Get<IServerAddressesFeature>();
115+
if (addressFeature != null)
116+
{
117+
foreach (var address in addressFeature.Addresses)
118+
{
119+
if (Uri.TryCreate(address, UriKind.Absolute, out var uri))
120+
{
121+
_port = uri.Port;
122+
_logger.LogDebug("mDNS: Detected server port {Port} from {Address}", _port, address);
123+
break;
124+
}
125+
}
126+
}
127+
128+
_multicastService = new MulticastService();
129+
_serviceDiscovery = new ServiceDiscovery(_multicastService);
130+
131+
// Respond to direct hostname queries (e.g., ping wowbot.local)
132+
_multicastService.QueryReceived += OnQueryReceived;
133+
134+
// Create service profile
135+
_serviceProfile = new ServiceProfile(
136+
instanceName: _hostname,
137+
serviceName: "_http._tcp",
138+
port: (ushort)_port);
139+
140+
// Add TXT records
141+
_serviceProfile.AddProperty("path", "/");
142+
_serviceProfile.AddProperty("server", "BlazorServer");
143+
144+
// Start the multicast service
145+
_multicastService.Start();
146+
_logger.LogInformation("mDNS: Multicast service started");
147+
148+
// Advertise and announce the service
149+
_serviceDiscovery.Advertise(_serviceProfile);
150+
_serviceDiscovery.Announce(_serviceProfile);
151+
_isAdvertising = true;
152+
153+
// Also announce our hostname A record
154+
AnnounceHostname();
155+
156+
if(_logger.IsEnabled(LogLevel.Information))
157+
{
158+
_logger.LogInformation(
159+
"mDNS: Service advertised at http://{Hostname}.local:{Port}",
160+
_hostname, _port);
161+
}
162+
});
163+
}
164+
catch (Exception ex)
165+
{
166+
_logger.LogError(ex, "mDNS: Failed to start advertising");
167+
}
168+
169+
return Task.CompletedTask;
170+
}
171+
172+
private void OnQueryReceived(object? sender, MessageEventArgs e)
173+
{
174+
var domainName = $"{_hostname}.local";
175+
176+
foreach (var question in e.Message.Questions)
177+
{
178+
// Check if someone is asking for our hostname
179+
if (question.Name.ToString().Equals(domainName, StringComparison.OrdinalIgnoreCase))
180+
{
181+
if (_logger.IsEnabled(LogLevel.Debug))
182+
{
183+
_logger.LogDebug("mDNS: Received query for {Name} (Type: {Type})", question.Name, question.Type);
184+
}
185+
186+
var response = e.Message.CreateResponse();
187+
var addresses = GetCachedAddresses();
188+
189+
foreach (var ip in addresses)
190+
{
191+
if (question.Type == DnsType.A && ip.AddressFamily == AddressFamily.InterNetwork)
192+
{
193+
response.Answers.Add(new ARecord
194+
{
195+
Name = domainName,
196+
Address = ip,
197+
TTL = TimeSpan.FromMinutes(2)
198+
});
199+
if (_logger.IsEnabled(LogLevel.Debug))
200+
{
201+
_logger.LogDebug("mDNS: Responding with A record: {Ip}", ip);
202+
}
203+
}
204+
else if (question.Type == DnsType.AAAA && ip.AddressFamily == AddressFamily.InterNetworkV6)
205+
{
206+
response.Answers.Add(new AAAARecord
207+
{
208+
Name = domainName,
209+
Address = ip,
210+
TTL = TimeSpan.FromMinutes(2)
211+
});
212+
if (_logger.IsEnabled(LogLevel.Debug))
213+
{
214+
_logger.LogDebug("mDNS: Responding with AAAA record: {Ip}", ip);
215+
}
216+
}
217+
else if (question.Type == DnsType.ANY)
218+
{
219+
if (ip.AddressFamily == AddressFamily.InterNetwork)
220+
{
221+
response.Answers.Add(new ARecord
222+
{
223+
Name = domainName,
224+
Address = ip,
225+
TTL = TimeSpan.FromMinutes(2)
226+
});
227+
}
228+
else if (ip.AddressFamily == AddressFamily.InterNetworkV6 && !ip.IsIPv6LinkLocal)
229+
{
230+
response.Answers.Add(new AAAARecord
231+
{
232+
Name = domainName,
233+
Address = ip,
234+
TTL = TimeSpan.FromMinutes(2)
235+
});
236+
}
237+
}
238+
}
239+
240+
if (response.Answers.Count > 0)
241+
{
242+
_multicastService?.SendAnswer(response);
243+
}
244+
}
245+
}
246+
}
247+
248+
private void AnnounceHostname()
249+
{
250+
if (_multicastService == null) return;
251+
252+
var domainName = $"{_hostname}.local";
253+
var response = new Message();
254+
response.QR = true; // This is a response
255+
response.AA = true; // Authoritative answer
256+
257+
foreach (var ip in GetCachedAddresses())
258+
{
259+
if (ip.AddressFamily == AddressFamily.InterNetwork)
260+
{
261+
response.Answers.Add(new ARecord
262+
{
263+
Name = domainName,
264+
Address = ip,
265+
TTL = TimeSpan.FromMinutes(2)
266+
});
267+
if (_logger.IsEnabled(LogLevel.Debug))
268+
{
269+
_logger.LogDebug("mDNS: Announcing A record: {Hostname} -> {Ip}", domainName, ip);
270+
}
271+
}
272+
else if (ip.AddressFamily == AddressFamily.InterNetworkV6 && !ip.IsIPv6LinkLocal)
273+
{
274+
response.Answers.Add(new AAAARecord
275+
{
276+
Name = domainName,
277+
Address = ip,
278+
TTL = TimeSpan.FromMinutes(2)
279+
});
280+
if (_logger.IsEnabled(LogLevel.Debug))
281+
{
282+
_logger.LogDebug("mDNS: Announcing AAAA record: {Hostname} -> {Ip}", domainName, ip);
283+
}
284+
}
285+
}
286+
287+
if (response.Answers.Count > 0)
288+
{
289+
_multicastService.SendAnswer(response);
290+
}
291+
}
292+
293+
public Task StopAsync(CancellationToken cancellationToken)
294+
{
295+
_logger.LogInformation("mDNS: Stopping advertising");
296+
297+
// Unsubscribe from network changes
298+
NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
299+
300+
try
301+
{
302+
if (_isAdvertising && _serviceProfile != null && _serviceDiscovery != null)
303+
{
304+
_serviceDiscovery.Unadvertise(_serviceProfile);
305+
_isAdvertising = false;
306+
}
307+
}
308+
catch (Exception ex)
309+
{
310+
_logger.LogDebug(ex, "mDNS: Error during unadvertise");
311+
}
312+
313+
try
314+
{
315+
_multicastService?.Stop();
316+
}
317+
catch (Exception ex)
318+
{
319+
_logger.LogDebug(ex, "mDNS: Error stopping multicast service");
320+
}
321+
322+
return Task.CompletedTask;
323+
}
324+
325+
public void Dispose()
326+
{
327+
_serviceDiscovery?.Dispose();
328+
_multicastService?.Dispose();
329+
}
330+
}

BlazorServer/Program.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ private static void ConfigureServices(IConfiguration configuration, IServiceColl
143143
o.ShutdownTimeout = TimeSpan.FromSeconds(1);
144144
});
145145

146+
// Register mDNS advertising service for http://wowbot.local access
147+
if(Environment.GetEnvironmentVariable("USE_MDNS") != null)
148+
{
149+
services.AddHostedService<MdnsAdvertisingService>();
150+
}
151+
146152
services.AddControllers().AddJsonOptions(options =>
147153
{
148154
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;

BlazorServer/Properties/launchSettings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"environmentVariables": {
2323
"ASPNETCORE_ENVIRONMENT": "Development"
2424
},
25-
"applicationUrl": "http://localhost:5000"
25+
"applicationUrl": "http://*:5000"
2626
}
2727
}
28-
}
28+
}

BlazorServer/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"Microsoft": "Warning",
77
"Microsoft.Hosting.Lifetime": "Information",
88
"Game.WowProcessInput": "Warning",
9-
"Core.ConfigurableInput": "Warning"
9+
"Core.ConfigurableInput": "Warning",
10+
"BlazorServer.MdnsAdvertisingService": "Information"
1011
}
1112
}
1213
},

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
2626
<PackageVersion Include="System.Net.Http.Json" Version="9.0.9" />
2727
<PackageVersion Include="GameOverlay.Net" Version="4.3.1" />
28+
<PackageVersion Include="Makaretu.Dns.Multicast.New" Version="0.38.0" />
2829
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
2930
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
3031
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />

0 commit comments

Comments
 (0)