-
-
Notifications
You must be signed in to change notification settings - Fork 175
Added mDNS advertising for simple LAN access to UI #738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
0213f05
a43b8ab
ddbd087
f28d844
0e2cc3e
3dfd5d6
b3c698c
49c876c
57b0511
6063db0
d69eac7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,319 @@ | ||
| using Makaretu.Dns; | ||
|
|
||
| using Microsoft.Extensions.Hosting; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| using System; | ||
| using System.Linq; | ||
| using System.Net; | ||
| using System.Net.NetworkInformation; | ||
| using System.Net.Sockets; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| namespace BlazorServer; | ||
|
|
||
| /// <summary> | ||
| /// Background service that advertises the Blazor Server via mDNS, | ||
| /// making it accessible at http://wowbot.local or whatever is set at MDNS_HOSTNAME | ||
| /// </summary> | ||
| public sealed class MdnsAdvertisingService : IHostedService, IDisposable | ||
| { | ||
| private readonly ILogger<MdnsAdvertisingService> _logger; | ||
| private readonly int _port; | ||
| private readonly string _hostname; | ||
|
|
||
| private MulticastService? _multicastService; | ||
| private ServiceDiscovery? _serviceDiscovery; | ||
| private ServiceProfile? _serviceProfile; | ||
| private bool _isAdvertising; | ||
|
|
||
| // Cached IP addresses - refreshed when network interfaces change | ||
| private IPAddress[] _cachedAddresses = []; | ||
| private readonly object _addressLock = new(); | ||
|
|
||
| public MdnsAdvertisingService(ILogger<MdnsAdvertisingService> logger) | ||
| { | ||
| _logger = logger; | ||
| _hostname = Environment.GetEnvironmentVariable("MDNS_HOSTNAME") ?? "wowbot"; | ||
| _port = GetServerPort(); | ||
|
|
||
| // Subscribe to network address changes | ||
| NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; | ||
| } | ||
|
|
||
| private void OnNetworkAddressChanged(object? sender, EventArgs e) | ||
| { | ||
| _logger.LogDebug("mDNS: Network address changed, refreshing IP cache"); | ||
| RefreshIPAddressCache(); | ||
|
|
||
| // Re-announce with new addresses | ||
| if (_isAdvertising) | ||
| { | ||
| AnnounceHostname(); | ||
| } | ||
| } | ||
|
|
||
| private void RefreshIPAddressCache() | ||
| { | ||
| var addresses = new System.Collections.Generic.List<IPAddress>(); | ||
|
|
||
| foreach (var netInterface in NetworkInterface.GetAllNetworkInterfaces()) | ||
| { | ||
| if (netInterface.OperationalStatus != OperationalStatus.Up) | ||
| continue; | ||
|
|
||
| if (netInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) | ||
| continue; | ||
|
|
||
| var ipProps = netInterface.GetIPProperties(); | ||
| foreach (var addr in ipProps.UnicastAddresses) | ||
| { | ||
| if (addr.Address.AddressFamily == AddressFamily.InterNetwork || | ||
| addr.Address.AddressFamily == AddressFamily.InterNetworkV6) | ||
| { | ||
| addresses.Add(addr.Address); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| lock (_addressLock) | ||
| { | ||
| _cachedAddresses = addresses.ToArray(); | ||
| } | ||
| } | ||
|
|
||
| private IPAddress[] GetCachedAddresses() | ||
| { | ||
| lock (_addressLock) | ||
| { | ||
| return _cachedAddresses; | ||
| } | ||
| } | ||
|
|
||
| public Task StartAsync(CancellationToken cancellationToken) | ||
| { | ||
| try | ||
| { | ||
| // Initialize IP address cache | ||
| RefreshIPAddressCache(); | ||
|
|
||
| _multicastService = new MulticastService(); | ||
| _serviceDiscovery = new ServiceDiscovery(_multicastService); | ||
|
|
||
| // Respond to direct hostname queries (e.g., ping wowbot.local) | ||
| _multicastService.QueryReceived += OnQueryReceived; | ||
|
|
||
| // Create service profile | ||
| _serviceProfile = new ServiceProfile( | ||
| instanceName: _hostname, | ||
| serviceName: "_http._tcp", | ||
| port: (ushort)_port); | ||
|
|
||
| // Add TXT records | ||
| _serviceProfile.AddProperty("path", "/"); | ||
| _serviceProfile.AddProperty("server", "BlazorServer"); | ||
|
|
||
| // Start the multicast service | ||
| _multicastService.Start(); | ||
| _logger.LogInformation("mDNS: Multicast service started"); | ||
|
|
||
| // Advertise and announce the service | ||
| _serviceDiscovery.Advertise(_serviceProfile); | ||
| _serviceDiscovery.Announce(_serviceProfile); | ||
| _isAdvertising = true; | ||
|
|
||
| // Also announce our hostname A record | ||
| AnnounceHostname(); | ||
|
|
||
| if(_logger.IsEnabled(LogLevel.Information)) | ||
| { | ||
| _logger.LogInformation( | ||
| "mDNS: Service advertised at http://{Hostname}.local:{Port}", | ||
| _hostname, _port); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "mDNS: Failed to start advertising"); | ||
| } | ||
|
|
||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| private void OnQueryReceived(object? sender, MessageEventArgs e) | ||
| { | ||
| var domainName = $"{_hostname}.local"; | ||
|
|
||
| foreach (var question in e.Message.Questions) | ||
| { | ||
| // Check if someone is asking for our hostname | ||
| if (question.Name.ToString().Equals(domainName, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| _logger.LogDebug("mDNS: Received query for {Name} (Type: {Type})", question.Name, question.Type); | ||
| } | ||
|
|
||
| var response = e.Message.CreateResponse(); | ||
| var addresses = GetCachedAddresses(); | ||
|
|
||
| foreach (var ip in addresses) | ||
| { | ||
| if (question.Type == DnsType.A && ip.AddressFamily == AddressFamily.InterNetwork) | ||
| { | ||
| response.Answers.Add(new ARecord | ||
| { | ||
| Name = domainName, | ||
| Address = ip, | ||
| TTL = TimeSpan.FromMinutes(2) | ||
| }); | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| _logger.LogDebug("mDNS: Responding with A record: {Ip}", ip); | ||
| } | ||
| } | ||
| else if (question.Type == DnsType.AAAA && ip.AddressFamily == AddressFamily.InterNetworkV6) | ||
| { | ||
| response.Answers.Add(new AAAARecord | ||
| { | ||
| Name = domainName, | ||
| Address = ip, | ||
| TTL = TimeSpan.FromMinutes(2) | ||
| }); | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| _logger.LogDebug("mDNS: Responding with AAAA record: {Ip}", ip); | ||
| } | ||
| } | ||
| else if (question.Type == DnsType.ANY) | ||
| { | ||
| if (ip.AddressFamily == AddressFamily.InterNetwork) | ||
| { | ||
| response.Answers.Add(new ARecord | ||
| { | ||
| Name = domainName, | ||
| Address = ip, | ||
| TTL = TimeSpan.FromMinutes(2) | ||
| }); | ||
| } | ||
| else if (ip.AddressFamily == AddressFamily.InterNetworkV6 && !ip.IsIPv6LinkLocal) | ||
| { | ||
| response.Answers.Add(new AAAARecord | ||
| { | ||
| Name = domainName, | ||
| Address = ip, | ||
| TTL = TimeSpan.FromMinutes(2) | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (response.Answers.Count > 0) | ||
| { | ||
| _multicastService?.SendAnswer(response); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void AnnounceHostname() | ||
| { | ||
| if (_multicastService == null) return; | ||
|
|
||
| var domainName = $"{_hostname}.local"; | ||
| var response = new Message(); | ||
| response.QR = true; // This is a response | ||
| response.AA = true; // Authoritative answer | ||
|
|
||
| foreach (var ip in GetCachedAddresses()) | ||
| { | ||
| if (ip.AddressFamily == AddressFamily.InterNetwork) | ||
| { | ||
| response.Answers.Add(new ARecord | ||
| { | ||
| Name = domainName, | ||
| Address = ip, | ||
| TTL = TimeSpan.FromMinutes(2) | ||
| }); | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| _logger.LogDebug("mDNS: Announcing A record: {Hostname} -> {Ip}", domainName, ip); | ||
| } | ||
| } | ||
| else if (ip.AddressFamily == AddressFamily.InterNetworkV6 && !ip.IsIPv6LinkLocal) | ||
| { | ||
| response.Answers.Add(new AAAARecord | ||
| { | ||
| Name = domainName, | ||
| Address = ip, | ||
| TTL = TimeSpan.FromMinutes(2) | ||
| }); | ||
| if (_logger.IsEnabled(LogLevel.Debug)) | ||
| { | ||
| _logger.LogDebug("mDNS: Announcing AAAA record: {Hostname} -> {Ip}", domainName, ip); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (response.Answers.Count > 0) | ||
| { | ||
| _multicastService.SendAnswer(response); | ||
| } | ||
| } | ||
|
|
||
| public Task StopAsync(CancellationToken cancellationToken) | ||
| { | ||
| _logger.LogInformation("mDNS: Stopping advertising"); | ||
|
|
||
| // Unsubscribe from network changes | ||
| NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; | ||
|
|
||
| try | ||
| { | ||
| if (_isAdvertising && _serviceProfile != null && _serviceDiscovery != null) | ||
| { | ||
| _serviceDiscovery.Unadvertise(_serviceProfile); | ||
| _isAdvertising = false; | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogDebug(ex, "mDNS: Error during unadvertise"); | ||
| } | ||
|
|
||
| try | ||
| { | ||
| _multicastService?.Stop(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogDebug(ex, "mDNS: Error stopping multicast service"); | ||
| } | ||
|
|
||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| private static int GetServerPort() | ||
| { | ||
| var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); | ||
|
||
| if (!string.IsNullOrEmpty(urls)) | ||
| { | ||
| foreach (var url in urls.Split(';')) | ||
| { | ||
| if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) | ||
| { | ||
| return uri.Port; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return 5000; | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| _serviceDiscovery?.Dispose(); | ||
| _multicastService?.Dispose(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,6 +143,9 @@ private static void ConfigureServices(IConfiguration configuration, IServiceColl | |
| o.ShutdownTimeout = TimeSpan.FromSeconds(1); | ||
| }); | ||
|
|
||
| // Register mDNS advertising service for http://wowbot.local access | ||
| services.AddHostedService<MdnsAdvertisingService>(); | ||
|
||
|
|
||
| services.AddControllers().AddJsonOptions(options => | ||
| { | ||
| options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since .NET 9 introduced System.Threading.Lock it's the recommended approach now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Implemented as requested 49c876c