579 lines
19 KiB
C#
579 lines
19 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
|
|
namespace Inspectron.Epson;
|
|
|
|
/// <summary>
|
|
/// Bonjour/mDNS (Multicast DNS) Service Discovery
|
|
/// Implements RFC 6762 (mDNS) and RFC 6763 (DNS-SD)
|
|
/// Port 5353, Multicast 224.0.0.251 (IPv4) / FF02::FB (IPv6)
|
|
/// </summary>
|
|
public class BonjourDiscovery
|
|
{
|
|
private const int MDNS_PORT = 5353;
|
|
private const string MDNS_MULTICAST_IPV4 = "224.0.0.251";
|
|
//private const string MDNS_MULTICAST_IPV4 = "255.255.255.255";
|
|
private const string MDNS_MULTICAST_IPV6 = "FF02::FB";
|
|
private const int DEFAULT_TIMEOUT_MS = 10000;
|
|
|
|
private static ushort _nextTransactionId = 1;
|
|
|
|
/// <summary>
|
|
/// Well-known service types for Bonjour/mDNS
|
|
/// </summary>
|
|
public static class ServiceTypes
|
|
{
|
|
public const string PRINTER_IPP = "_ipp._tcp.local.";
|
|
public const string PRINTER_IPP_TLS = "_ipps._tcp.local.";
|
|
public const string PRINTER_LPD = "_printer._tcp.local.";
|
|
public const string PRINTER_PDL_DATASTREAM = "_pdl-datastream._tcp.local.";
|
|
public const string SCANNER = "_scanner._tcp.local.";
|
|
public const string HTTP = "_http._tcp.local.";
|
|
public const string HTTPS = "_https._tcp.local.";
|
|
public const string SSH = "_ssh._tcp.local.";
|
|
public const string FTP = "_ftp._tcp.local.";
|
|
public const string SFTP = "_sftp-ssh._tcp.local.";
|
|
public const string SMB = "_smb._tcp.local.";
|
|
public const string AFP = "_afpovertcp._tcp.local.";
|
|
public const string NFS = "_nfs._tcp.local.";
|
|
public const string AIRPLAY = "_airplay._tcp.local.";
|
|
public const string AIRPRINT = "_ipp._tcp.local.";
|
|
public const string RAOP = "_raop._tcp.local.";
|
|
public const string HOMEKIT = "_hap._tcp.local.";
|
|
public const string CHROMECAST = "_googlecast._tcp.local.";
|
|
public const string SPOTIFY_CONNECT = "_spotify-connect._tcp.local.";
|
|
public const string MQTT = "_mqtt._tcp.local.";
|
|
public const string WORKSTATION = "_workstation._tcp.local.";
|
|
public const string SERVICES_DNS_SD = "_services._dns-sd._udp.local.";
|
|
}
|
|
|
|
// _dosvc._tcp.local - discovery of all devices
|
|
|
|
/// <summary>
|
|
/// Discovers services of specified type via mDNS/Bonjour
|
|
/// </summary>
|
|
public static List<BonjourService> DiscoverServices(
|
|
string serviceType = "_ipp._tcp.local.",
|
|
int timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
bool useIPv6 = false)
|
|
{
|
|
var discoveredServices = new List<BonjourService>();
|
|
var serviceKeys = new HashSet<string>();
|
|
|
|
using (var udpClient = new UdpClient())
|
|
{
|
|
try
|
|
{
|
|
// Configure socket
|
|
udpClient.Client.SetSocketOption(
|
|
SocketOptionLevel.Socket,
|
|
SocketOptionName.ReuseAddress,
|
|
true);
|
|
|
|
if (useIPv6)
|
|
{
|
|
udpClient.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, MDNS_PORT));
|
|
var multicastAddress = IPAddress.Parse(MDNS_MULTICAST_IPV6);
|
|
udpClient.JoinMulticastGroup(multicastAddress);
|
|
}
|
|
else
|
|
{
|
|
udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, MDNS_PORT));
|
|
var multicastAddress = IPAddress.Parse(MDNS_MULTICAST_IPV4);
|
|
udpClient.JoinMulticastGroup(multicastAddress);
|
|
}
|
|
|
|
udpClient.Client.ReceiveTimeout = timeoutMs;
|
|
|
|
// Build and send mDNS query
|
|
var transactionId = _nextTransactionId++;
|
|
byte[] query = BuildMdnsQuery(serviceType, transactionId);
|
|
|
|
var multicastEndpoint = new IPEndPoint(
|
|
useIPv6 ? IPAddress.Parse(MDNS_MULTICAST_IPV6) : IPAddress.Parse(MDNS_MULTICAST_IPV4),
|
|
MDNS_PORT);
|
|
|
|
Console.WriteLine($"[mDNS] Querying: {serviceType}");
|
|
udpClient.Send(query, query.Length, multicastEndpoint);
|
|
|
|
// Collect responses
|
|
var startTime = DateTime.Now;
|
|
while ((DateTime.Now - startTime).TotalMilliseconds < timeoutMs)
|
|
{
|
|
try
|
|
{
|
|
IPEndPoint remoteEndpoint = null;
|
|
byte[] responseData = udpClient.Receive(ref remoteEndpoint);
|
|
|
|
if (IsValidMdnsResponse(responseData))
|
|
{
|
|
var services = ParseMdnsResponse(responseData, remoteEndpoint.Address.ToString());
|
|
|
|
foreach (var service in services)
|
|
{
|
|
string key = $"{service.ServiceName}.{service.ServiceType}";
|
|
if (!serviceKeys.Contains(key))
|
|
{
|
|
discoveredServices.Add(service);
|
|
serviceKeys.Add(key);
|
|
Console.WriteLine($"[mDNS] ✓ Found: {service.ToString()}");
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($"[mDNS] Found {discoveredServices.Count} service(s)");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[mDNS] Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
return discoveredServices;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers services asynchronously
|
|
/// </summary>
|
|
public static async Task<List<BonjourService>> DiscoverServicesAsync(
|
|
string serviceType = "_ipp._tcp.local.",
|
|
int timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
bool useIPv6 = false)
|
|
{
|
|
return await Task.Run(() => DiscoverServices(serviceType, timeoutMs, useIPv6));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers all available service types on the network
|
|
/// </summary>
|
|
public static List<string> DiscoverServiceTypes(int timeoutMs = DEFAULT_TIMEOUT_MS)
|
|
{
|
|
Console.WriteLine("[mDNS] Discovering available service types...");
|
|
|
|
var services = DiscoverServices("_services._dns-sd._udp.local.", timeoutMs);
|
|
var serviceTypes = new HashSet<string>();
|
|
|
|
foreach (var service in services)
|
|
{
|
|
if (!string.IsNullOrEmpty(service.ServiceType))
|
|
{
|
|
serviceTypes.Add(service.ServiceType);
|
|
}
|
|
}
|
|
|
|
return serviceTypes.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an mDNS query packet
|
|
/// </summary>
|
|
private static byte[] BuildMdnsQuery(string serviceType, ushort transactionId)
|
|
{
|
|
using (var ms = new System.IO.MemoryStream())
|
|
using (var writer = new System.IO.BinaryWriter(ms))
|
|
{
|
|
// DNS Header
|
|
writer.Write((byte)((transactionId >> 8) & 0xFF));
|
|
writer.Write((byte)(transactionId & 0xFF));
|
|
|
|
// Flags: Standard query (0x0000)
|
|
writer.Write((byte)0x00);
|
|
writer.Write((byte)0x00);
|
|
|
|
// Question count: 1
|
|
writer.Write((byte)0x00);
|
|
writer.Write((byte)0x01);
|
|
|
|
// Answer count: 0
|
|
writer.Write((byte)0x00);
|
|
writer.Write((byte)0x00);
|
|
|
|
// Authority count: 0
|
|
writer.Write((byte)0x00);
|
|
writer.Write((byte)0x00);
|
|
|
|
// Additional count: 0
|
|
writer.Write((byte)0x00);
|
|
writer.Write((byte)0x00);
|
|
|
|
// Question section
|
|
WriteQName(writer, serviceType);
|
|
|
|
// Type: PTR (12)
|
|
writer.Write((byte)0x00);
|
|
writer.Write((byte)0x0C);
|
|
|
|
// Class: IN (1)
|
|
writer.Write((byte)0x00);
|
|
writer.Write((byte)0x01);
|
|
|
|
return ms.ToArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a DNS name in QNAME format
|
|
/// </summary>
|
|
private static void WriteQName(System.IO.BinaryWriter writer, string name)
|
|
{
|
|
var labels = name.TrimEnd('.').Split('.');
|
|
|
|
foreach (var label in labels)
|
|
{
|
|
if (string.IsNullOrEmpty(label))
|
|
continue;
|
|
|
|
writer.Write((byte)label.Length);
|
|
writer.Write(Encoding.ASCII.GetBytes(label));
|
|
}
|
|
|
|
writer.Write((byte)0x00); // Null terminator
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates if response is a proper mDNS response
|
|
/// </summary>
|
|
private static bool IsValidMdnsResponse(byte[] response)
|
|
{
|
|
if (response == null || response.Length < 12)
|
|
return false;
|
|
|
|
// Check if it's a response (QR bit set)
|
|
return (response[2] & 0x80) != 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses mDNS response packet
|
|
/// </summary>
|
|
private static List<BonjourService> ParseMdnsResponse(byte[] response, string sourceIp)
|
|
{
|
|
var services = new List<BonjourService>();
|
|
|
|
try
|
|
{
|
|
int offset = 0;
|
|
|
|
// Parse header
|
|
ushort transactionId = ReadUInt16(response, ref offset);
|
|
ushort flags = ReadUInt16(response, ref offset);
|
|
ushort questionCount = ReadUInt16(response, ref offset);
|
|
ushort answerCount = ReadUInt16(response, ref offset);
|
|
ushort authorityCount = ReadUInt16(response, ref offset);
|
|
ushort additionalCount = ReadUInt16(response, ref offset);
|
|
|
|
// Skip questions
|
|
for (int i = 0; i < questionCount; i++)
|
|
{
|
|
SkipQName(response, ref offset);
|
|
offset += 4; // Type and Class
|
|
}
|
|
|
|
// Parse answers
|
|
var ptrRecords = new Dictionary<string, List<string>>();
|
|
var srvRecords = new Dictionary<string, SrvRecord>();
|
|
var txtRecords = new Dictionary<string, Dictionary<string, string>>();
|
|
var aRecords = new Dictionary<string, string>();
|
|
|
|
// Parse all record sections
|
|
int totalRecords = answerCount + authorityCount + additionalCount;
|
|
|
|
for (int i = 0; i < totalRecords; i++)
|
|
{
|
|
try
|
|
{
|
|
string name = ReadQName(response, ref offset);
|
|
ushort type = ReadUInt16(response, ref offset);
|
|
ushort rclass = ReadUInt16(response, ref offset);
|
|
uint ttl = ReadUInt32(response, ref offset);
|
|
ushort dataLength = ReadUInt16(response, ref offset);
|
|
int dataStart = offset;
|
|
|
|
switch (type)
|
|
{
|
|
case 12: // PTR
|
|
string ptrTarget = ReadQName(response, ref offset);
|
|
if (!ptrRecords.ContainsKey(name))
|
|
ptrRecords[name] = new List<string>();
|
|
ptrRecords[name].Add(ptrTarget);
|
|
break;
|
|
|
|
case 33: // SRV
|
|
ushort priority = ReadUInt16(response, ref offset);
|
|
ushort weight = ReadUInt16(response, ref offset);
|
|
ushort port = ReadUInt16(response, ref offset);
|
|
string target = ReadQName(response, ref offset);
|
|
srvRecords[name] = new SrvRecord
|
|
{
|
|
Port = port,
|
|
Target = target,
|
|
Priority = priority,
|
|
Weight = weight,
|
|
TTL = ttl
|
|
};
|
|
break;
|
|
|
|
case 16: // TXT
|
|
var txtData = ParseTxtRecord(response, offset, dataLength);
|
|
txtRecords[name] = txtData;
|
|
offset += dataLength;
|
|
break;
|
|
|
|
case 1: // A (IPv4)
|
|
if (dataLength == 4)
|
|
{
|
|
var ip = new IPAddress(new[]
|
|
{
|
|
response[offset],
|
|
response[offset + 1],
|
|
response[offset + 2],
|
|
response[offset + 3]
|
|
});
|
|
aRecords[name] = ip.ToString();
|
|
offset += 4;
|
|
}
|
|
break;
|
|
|
|
case 28: // AAAA (IPv6)
|
|
if (dataLength == 16)
|
|
{
|
|
byte[] ipBytes = new byte[16];
|
|
Array.Copy(response, offset, ipBytes, 0, 16);
|
|
var ip = new IPAddress(ipBytes);
|
|
aRecords[name] = ip.ToString();
|
|
offset += 16;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
offset += dataLength;
|
|
break;
|
|
}
|
|
|
|
// Ensure we're at the right position
|
|
offset = dataStart + dataLength;
|
|
}
|
|
catch
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Build service objects from collected records
|
|
foreach (var ptrEntry in ptrRecords)
|
|
{
|
|
foreach (var instanceName in ptrEntry.Value)
|
|
{
|
|
var service = new BonjourService
|
|
{
|
|
DiscoveredAt = DateTime.Now,
|
|
RawResponse = response
|
|
};
|
|
|
|
// Parse instance name (e.g., "My Printer._ipp._tcp.local.")
|
|
ParseServiceName(instanceName, service);
|
|
|
|
// Get SRV record
|
|
if (srvRecords.ContainsKey(instanceName))
|
|
{
|
|
var srv = srvRecords[instanceName];
|
|
service.Port = srv.Port;
|
|
service.HostName = srv.Target;
|
|
service.TTL = srv.TTL;
|
|
|
|
// Get IP address from A/AAAA record
|
|
if (aRecords.ContainsKey(srv.Target))
|
|
{
|
|
service.IPAddress = aRecords[srv.Target];
|
|
}
|
|
}
|
|
|
|
// Get TXT record
|
|
if (txtRecords.ContainsKey(instanceName))
|
|
{
|
|
service.TxtRecords = txtRecords[instanceName];
|
|
}
|
|
|
|
services.Add(service);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[mDNS Parser] Error: {ex.Message}");
|
|
}
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses service name components
|
|
/// </summary>
|
|
private static void ParseServiceName(string fullName, BonjourService service)
|
|
{
|
|
service.FullServiceName = fullName;
|
|
|
|
var parts = fullName.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
if (parts.Length >= 3)
|
|
{
|
|
service.ServiceName = parts[0];
|
|
service.ServiceType = $"{parts[1]}.{parts[2]}";
|
|
|
|
if (parts.Length >= 4)
|
|
{
|
|
service.Domain = string.Join(".", parts.Skip(3));
|
|
}
|
|
else
|
|
{
|
|
service.Domain = "local";
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses TXT record key-value pairs
|
|
/// </summary>
|
|
private static Dictionary<string, string> ParseTxtRecord(byte[] data, int offset, int length)
|
|
{
|
|
var result = new Dictionary<string, string>();
|
|
int end = offset + length;
|
|
|
|
while (offset < end)
|
|
{
|
|
byte len = data[offset++];
|
|
if (len == 0 || offset + len > end)
|
|
break;
|
|
|
|
string txt = Encoding.UTF8.GetString(data, offset, len);
|
|
offset += len;
|
|
|
|
var eqIndex = txt.IndexOf('=');
|
|
if (eqIndex > 0)
|
|
{
|
|
string key = txt.Substring(0, eqIndex);
|
|
string value = txt.Substring(eqIndex + 1);
|
|
result[key] = value;
|
|
}
|
|
else
|
|
{
|
|
result[txt] = "";
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a DNS name from the packet (handles compression)
|
|
/// </summary>
|
|
private static string ReadQName(byte[] data, ref int offset)
|
|
{
|
|
var labels = new List<string>();
|
|
int jumps = 0;
|
|
int maxJumps = 10;
|
|
bool jumped = false;
|
|
int returnOffset = -1;
|
|
|
|
while (offset < data.Length)
|
|
{
|
|
byte length = data[offset];
|
|
|
|
// Check for compression pointer
|
|
if ((length & 0xC0) == 0xC0)
|
|
{
|
|
if (!jumped)
|
|
returnOffset = offset + 2;
|
|
|
|
int pointer = ((length & 0x3F) << 8) | data[offset + 1];
|
|
offset = pointer;
|
|
jumped = true;
|
|
jumps++;
|
|
|
|
if (jumps > maxJumps)
|
|
break;
|
|
|
|
continue;
|
|
}
|
|
|
|
offset++;
|
|
|
|
if (length == 0)
|
|
break;
|
|
|
|
if (offset + length > data.Length)
|
|
break;
|
|
|
|
string label = Encoding.ASCII.GetString(data, offset, length);
|
|
labels.Add(label);
|
|
offset += length;
|
|
}
|
|
|
|
if (jumped && returnOffset != -1)
|
|
offset = returnOffset;
|
|
|
|
return string.Join(".", labels);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Skips a DNS name in the packet
|
|
/// </summary>
|
|
private static void SkipQName(byte[] data, ref int offset)
|
|
{
|
|
while (offset < data.Length)
|
|
{
|
|
byte length = data[offset++];
|
|
|
|
if (length == 0)
|
|
break;
|
|
|
|
if ((length & 0xC0) == 0xC0)
|
|
{
|
|
offset++;
|
|
break;
|
|
}
|
|
|
|
offset += length;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads 16-bit unsigned integer (big-endian)
|
|
/// </summary>
|
|
private static ushort ReadUInt16(byte[] data, ref int offset)
|
|
{
|
|
ushort value = (ushort)((data[offset] << 8) | data[offset + 1]);
|
|
offset += 2;
|
|
return value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads 32-bit unsigned integer (big-endian)
|
|
/// </summary>
|
|
private static uint ReadUInt32(byte[] data, ref int offset)
|
|
{
|
|
uint value = (uint)((data[offset] << 24) | (data[offset + 1] << 16) |
|
|
(data[offset + 2] << 8) | data[offset + 3]);
|
|
offset += 4;
|
|
return value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// SRV record data structure
|
|
/// </summary>
|
|
private class SrvRecord
|
|
{
|
|
public ushort Priority { get; set; }
|
|
public ushort Weight { get; set; }
|
|
public ushort Port { get; set; }
|
|
public string Target { get; set; }
|
|
public uint TTL { get; set; }
|
|
}
|
|
} |