Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions BlazorServer/BlazorServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Makaretu.Dns.Multicast.New" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Enrichers.Process" />
Expand Down
319 changes: 319 additions & 0 deletions BlazorServer/MdnsAdvertisingService.cs
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();
Copy link
Owner

@Xian55 Xian55 Jan 27, 2026

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.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented as requested 49c876c


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");
Copy link
Owner

@Xian55 Xian55 Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't account for launchSettings.json or appsettings.json Kestrel config.


Recommendation

 using Microsoft.AspNetCore.Hosting.Server;
 using Microsoft.AspNetCore.Hosting.Server.Features;
public sealed class MdnsAdvertisingService : IHostedService, IDisposable
{
    private readonly ILogger<MdnsAdvertisingService> _logger;
    private readonly IServer _server;
    private readonly IHostApplicationLifetime _lifetime;
    private readonly string _hostname;

    private int _port = 5000; // fallback
    private CancellationTokenRegistration _startedRegistration;

    public MdnsAdvertisingService(
        ILogger<MdnsAdvertisingService> logger,
        IServer server,
        IHostApplicationLifetime lifetime)
    {
        _logger = logger;
        _server = server;
        _lifetime = lifetime;
        _hostname = Environment.GetEnvironmentVariable("MDNS_HOSTNAME") ?? "wowbot";
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Server addresses aren't available until after Kestrel binds
        // Wait for ApplicationStarted event
        _startedRegistration = _lifetime.ApplicationStarted.Register(() =>
        {
            var addressFeature = _server.Features.Get<IServerAddressesFeature>();
            if (addressFeature != null)
            {
                foreach (var address in addressFeature.Addresses)
                {
                    if (Uri.TryCreate(address, UriKind.Absolute, out var uri))
                    {
                        _port = uri.Port;
                        _logger.LogDebug("mDNS: Detected server port {Port} from {Address}", _port, address);
                        break;
                    }
                }
            }

            // Now start advertising with the correct port
            StartAdvertising();
        });

        return Task.CompletedTask;
    }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented as requested in 57b0511

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();
}
}
3 changes: 3 additions & 0 deletions BlazorServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Copy link
Owner

@Xian55 Xian55 Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice when the feature is not used, then the service its won't be registered in the Dependency Injection container.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added USE_MDNS env var check, if it is not null it will load the service
d69eac7


services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
Expand Down
4 changes: 2 additions & 2 deletions BlazorServer/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5000"
"applicationUrl": "http://*:5000"
}
}
}
}
3 changes: 2 additions & 1 deletion BlazorServer/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Game.WowProcessInput": "Warning",
"Core.ConfigurableInput": "Warning"
"Core.ConfigurableInput": "Warning",
"BlazorServer.MdnsAdvertisingService": "Information"
}
}
},
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Net.Http.Json" Version="9.0.9" />
<PackageVersion Include="GameOverlay.Net" Version="4.3.1" />
<PackageVersion Include="Makaretu.Dns.Multicast.New" Version="0.38.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2339,9 +2339,15 @@ The available modes are:

## Other devices

The user interface is shown in a browser on port **5000** [http://localhost:5000](http://localhost:5000). This allows you to view it from another device on your lan.
The user interface is shown in a browser on port **5000** [http://wowbot.local:5000](http://wowbot.local:5000). This allows you to view it from another device on your lan.

To access you PC port **5000** from another device, you will need to open up port **5000** in your firewall.
If you are running multiple bots on the same network, you can use a custom name per bot, in powershell run:

```$env:MDNS_HOSTNAME = 'yourcustomname'```

Now the UI will be available at [http://yourcustomname.local:5000](http://yourcustomname.local:5000).

If you do not accept the "Allow" prompt when you first start the BlazorServer app you will need to open up port **5000** in your firewall.

Control Panel\System and Security\Windows Defender Firewall - Advanced Settings

Expand Down