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+ }
0 commit comments