diff --git a/.gitignore b/.gitignore index d5a68784..0356049b 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,4 @@ FodyWeavers.xsd # macOS .DS_Store +ISSUE_TRIAGE.md diff --git a/RackPeek.Domain/Api/UpsertInventoryUseCase.cs b/RackPeek.Domain/Api/UpsertInventoryUseCase.cs index ca0efb60..a5187ac7 100644 --- a/RackPeek.Domain/Api/UpsertInventoryUseCase.cs +++ b/RackPeek.Domain/Api/UpsertInventoryUseCase.cs @@ -63,9 +63,18 @@ public async Task ExecuteAsync(ImportYamlRequest request) { List? incomingResources = incomingRoot.Resources; IReadOnlyList currentResources = await repo.GetAllOfTypeAsync(); + IReadOnlyList currentConnections = await repo.GetConnectionsAsync(); + // Line discovered resources up with what they already map to before anything // else looks at names, so the diff below reports against the right resources. - DiscoveryIdResolver.ResolveNames(currentResources, incomingResources, incomingRoot.Connections); + // A dry run gets the same reconciliation but is not allowed to improve stored + // names, because that rewrites the inventory and a dry run must not. + DiscoveryIdResolver.ResolveNames( + currentResources, + incomingResources, + incomingRoot.Connections, + currentConnections, + !request.DryRun); IGrouping? duplicate = incomingResources .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase) @@ -123,7 +132,6 @@ public async Task ExecuteAsync(ImportYamlRequest request) { else if (oldYaml != newYaml) response.Updated.Add(incoming.Name); } - IReadOnlyList currentConnections = await repo.GetConnectionsAsync(); List? mergedConnections = ConnectionMerger.Merge( currentConnections, incomingRoot.Connections, diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs index 0cae8102..e6a8e77b 100644 --- a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using RackPeek.Domain.Resources; using RackPeek.Domain.Resources.Connections; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; namespace RackPeek.Domain.Discovery; @@ -19,11 +21,21 @@ public static class DiscoveryIdResolver { /// the stored resources the ids point at. Also rewrites runsOn references /// between incoming resources — and the payload's , /// which name resources the same way — so a rename does not break the tree. + /// + /// The one exception is , which lets a + /// stored placeholder nobody chose be replaced by a real name this payload + /// knows — see . That rewrites the stored side, so + /// only a caller that is about to persist should ask for it, and it must hand + /// over for the same reason the incoming + /// side hands over its own. + /// /// public static void ResolveNames( IReadOnlyList existing, IReadOnlyList incoming, - IReadOnlyList? connections = null) { + IReadOnlyList? connections = null, + IReadOnlyList? storedConnections = null, + bool improveStoredNames = false) { var incomingWithId = incoming .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId)) .ToList(); @@ -39,6 +51,7 @@ public static void ResolveNames( .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase); Dictionary existingByMac = BuildMacMap(existing); + Dictionary existingByIp = BuildIpMap(existing); // Tolerant of a hand-edited file that managed to get two resources of the // same name: the first wins, rather than crashing the import. @@ -49,13 +62,37 @@ public static void ResolveNames( var renames = new Dictionary(StringComparer.OrdinalIgnoreCase); + var storedRenames = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Resource resource in incomingWithId) { - var resolved = ResolveName(resource, existingById, existingByName, existingByMac); + // Captured before resolution, which may null the id as part of unifying. + var offeredName = resource.Name; + var offeredId = resource.DiscoveryId; + + var resolved = ResolveName(resource, existingById, existingByName, existingByMac, existingByIp); + + if (resolved.Equals(offeredName, StringComparison.OrdinalIgnoreCase)) + continue; + + // The stored card is about to lend its name to this one. If that name is a + // placeholder nobody chose and this collector has a real one, the better name + // should win instead — so the card improves as more is learned about it. + if (improveStoredNames + && existingByName.TryGetValue(resolved, out Resource? stored) + && !existingByName.ContainsKey(offeredName) + && CanImproveName(stored, offeredName, offeredId)) { + storedRenames[stored.Name] = offeredName; + + // The name index has to follow, or a later card in this same payload would + // resolve onto a name that no longer exists. + existingByName.Remove(stored.Name); + stored.Name = offeredName; + existingByName[offeredName] = stored; - if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)) continue; + } - renames[resource.Name] = resolved; + renames[offeredName] = resolved; resource.Name = resolved; } @@ -64,7 +101,190 @@ public static void ResolveNames( RewriteConnections(connections, renames); } + // The stored side has its own references to fix up, and its own connections. The + // incoming side gets the same treatment because a payload may well be a re-push of + // previously exported YAML, which still names the resource the way it was stored. + if (storedRenames.Count > 0) { + RewriteRunsOn(existing, storedRenames); + + // Now that the hosts answer to their new names, the services named after the + // old ones follow. Done here so the renames below travel together. + foreach ((var from, var to) in RenameServicesAfterTheirHost(existing, storedRenames, existingByName)) + storedRenames[from] = to; + + RewriteConnections(storedConnections, storedRenames); + RewriteRunsOn(incoming, storedRenames); + RewriteConnections(connections, storedRenames); + } + PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName); + AnchorRunsOnByIp(existing, incoming, existingByName); + } + + /// + /// Carries a service's name along when the host it runs on stops being a + /// placeholder. + /// + /// A sweep names what it finds on a port after the host it found it on, so a + /// machine it could only call host-1a2b3c4d gets a host-1a2b3c4d-ssh + /// beside it. When the firewall or the hypervisor later supplies the real name + /// the host becomes forgejo and the service is left announcing a machine + /// that no longer exists — the link still resolves, but the name reads as a + /// leftover, which is exactly what it is. + /// + /// + /// Only names this collector's own convention produced are touched: the + /// service must be named for the old host and must actually run on it, must + /// not be a name a person chose, and the name it would take must be free. + /// + /// + private static Dictionary RenameServicesAfterTheirHost( + IReadOnlyList existing, + Dictionary hostRenames, + Dictionary existingByName) { + var renamed = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (Service service in existing.OfType()) { + if (service.IsUserNamed()) + continue; + + foreach ((var oldHost, var newHost) in hostRenames) { + if (!service.Name.StartsWith($"{oldHost}-", StringComparison.OrdinalIgnoreCase)) + continue; + + // runsOn has already been rewritten, so this is the new name by now. A + // service merely named like the host without running on it is a + // coincidence, and coincidences are not renamed. + if (!service.RunsOn.Contains(newHost, StringComparer.OrdinalIgnoreCase)) + continue; + + var candidate = $"{newHost}{service.Name[oldHost.Length..]}"; + + if (existingByName.ContainsKey(candidate)) + break; + + renamed[service.Name] = candidate; + existingByName.Remove(service.Name); + service.Name = candidate; + existingByName[candidate] = service; + + break; + } + } + + return renamed; + } + + /// + /// Whether a stored card's name is a placeholder that this collector can improve + /// on. + /// + /// A discovered card is named from whatever the collector could see, and when + /// that was nothing it falls back to a slug of its own id — host-1a2b3c4d + /// says only that something is there. A later run, or a collector that can see + /// more, often does know the machine's name: a firewall knows what it handed + /// out over DHCP, a hypervisor knows what its guest is called. Keeping the + /// placeholder in that case would mean the inventory never improved. + /// + /// + /// Only ever placeholder to real name, and never over a name a person chose. + /// Real to real is left alone on purpose: two collectors that each know a + /// different name for a machine would otherwise rename it back and forth on + /// every run. + /// + /// + private static bool CanImproveName(Resource stored, string offeredName, string? offeredId) => + !stored.IsUserNamed() + && IsGeneratedName(stored.Name, stored.DiscoveryId) + && !IsGeneratedName(offeredName, offeredId); + + /// + /// Whether a name is the slug-of-its-own-id form + /// falls back to when the collector had nothing better to offer. + /// + private static bool IsGeneratedName(string name, string? discoveryId) => + !string.IsNullOrWhiteSpace(discoveryId) + && name.EndsWith($"-{DiscoveryId.ShortSuffix(discoveryId)}", StringComparison.OrdinalIgnoreCase); + + /// + /// Gives a service whose runsOn names nothing the host it is plainly + /// running on: the system at its own address. + /// + /// A collector that cannot see the machine it is talking to has to guess the + /// host's name — docker over TCP sends whatever the engine calls itself, which + /// need not match any resource — and the link then dangles. The address is + /// evidence the guess is not: a service answering on 192.0.2.57 is running on + /// whatever owns 192.0.2.57. + /// + /// + /// Deliberately conservative. It only fills a link that resolves to nothing, + /// only when exactly one system claims that address, and never across a + /// resource that already has a working parent — an ambiguous address is no + /// evidence at all, and a wrong parent is worse than a missing one. + /// + /// + private static void AnchorRunsOnByIp( + IReadOnlyList existing, + IReadOnlyList incoming, + Dictionary existingByName) { + var services = incoming.OfType().ToList(); + + if (services.Count == 0) + return; + + var incomingNames = new HashSet( + incoming.Select(r => r.Name), + StringComparer.OrdinalIgnoreCase); + + // Both sides count: the host may have arrived in this very payload (discover + // docker emits it alongside its services) or be sitting in the inventory already. + // Stored systems are gathered separately because they win — see below. + Dictionary> storedByIp = IndexByIp(existing); + Dictionary> arrivingByIp = IndexByIp(incoming); + + foreach (Service service in services) { + var ip = service.Network?.Ip; + + if (string.IsNullOrWhiteSpace(ip)) + continue; + + var anchored = service.RunsOn.Any(name => + existingByName.ContainsKey(name) || incomingNames.Contains(name)); + + if (anchored) + continue; + + // A stored system beats one arriving in this payload when both claim the + // address. They are usually the same machine seen twice — a hypervisor knows + // its guest by name and specification, a sweep only found something + // answering — and the stored card is the one a person recognises. + List? candidates = + storedByIp.TryGetValue(ip, out List? stored) ? stored + : arrivingByIp.TryGetValue(ip, out List? arriving) ? arriving + : null; + + if (candidates is not [var host]) + continue; + + service.RunsOn = [host]; + } + } + + private static Dictionary> IndexByIp(IReadOnlyList resources) { + var byIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (SystemResource system in resources.OfType()) { + if (string.IsNullOrWhiteSpace(system.Ip)) + continue; + + if (!byIp.TryGetValue(system.Ip, out List? names)) + byIp[system.Ip] = names = []; + + if (!names.Contains(system.Name, StringComparer.OrdinalIgnoreCase)) + names.Add(system.Name); + } + + return byIp; } /// @@ -103,7 +323,8 @@ private static string ResolveName( Resource resource, Dictionary existingById, Dictionary existingByName, - Dictionary existingByMac) { + Dictionary existingByMac, + Dictionary existingByIp) { // Known id: the stored resource wins on name, whatever the user has renamed it to. if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched)) return matched.Name; @@ -113,6 +334,9 @@ private static string ResolveName( if (TryUnifyByMac(resource, existingByMac, out var unifiedName)) return unifiedName; + if (TryUnifyByIp(resource, existingByIp, out unifiedName)) + return unifiedName; + // Unknown id and the name is free: nothing to reconcile. if (!existingByName.TryGetValue(resource.Name, out Resource? sameName)) return resource.Name; @@ -191,6 +415,110 @@ private static bool TryUnifyByMac( return false; } + /// + /// The bridge for machines a sweep cannot identify by MAC at all: ARP is + /// link-local, so a host on any subnet but the scanner's own yields no MAC and + /// its identity falls back to its address. Once a hypervisor reports its guests' + /// addresses, that same address is the only thing tying the sweep's find to the + /// guest the inventory describes in full. + /// + /// Which of the two arrived first must not matter, so this reads the same in + /// both directions: one scan-grade card that produced no MAC, one agent-grade + /// card, one address, same kind. The agent-grade identity always wins — it is + /// dropped from the incoming card when the incoming card is the scan (so the + /// merge cannot downgrade the stored one) and kept when the incoming card is + /// the agent (so the merge upgrades the stored one). + /// + /// + /// Two scan cards can bridge as well, but only when exactly one of them saw a + /// MAC. A firewall's neighbour table gives an address and the NIC + /// answering at it; a sweep of a subnet it does not sit on gives an address + /// and nothing else. Those are not two stand-ins — one is a direct observation + /// of a specific interface and the other is "something replied" — so the + /// MAC-bearing card wins the identity and the address-only card folds into it. + /// + /// + /// Narrow on purpose. Against an agent-grade card the scan side must have no + /// MAC at all: one that has a MAC either unified through the MAC bridge + /// already or genuinely disagrees, and disagreement is not evidence. Two cards + /// of equal standing never bridge — both agent-grade, both scan-grade with a + /// MAC, or both scan-grade without one — because an address adds nothing when + /// neither side can better it. And the address must be claimed by exactly one + /// stored card, which guarantees: two cards on one + /// address is a conflict or an overlapping subnet, neither of which is + /// evidence of anything. + /// + /// + private static bool TryUnifyByIp( + Resource resource, + Dictionary existingByIp, + out string unifiedName) { + unifiedName = string.Empty; + + if (resource is not SystemResource { Ip: { } ip } || string.IsNullOrWhiteSpace(ip)) + return false; + + if (!existingByIp.TryGetValue(ip, out Resource? stored) + || stored.GetType() != resource.GetType()) + return false; + + var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme; + var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme; + + var incomingHasMac = MacsOf(resource).Any(); + var storedHasMac = MacsOf(stored).Any(); + + // Which side holds the weaker identity, and so folds into the other. Null means + // the two are of equal standing and the address settles nothing. + bool? incomingIsWeaker = + incomingIsNet != storedIsNet + // Agent grade against scan grade. The scan is the weaker one, but only + // when it saw no MAC of its own — one that did either unified through the + // MAC bridge already or disagrees with the card it would be folded into. + ? (incomingIsNet ? incomingHasMac : storedHasMac) ? null : incomingIsNet + : !incomingIsNet + // Two agent-grade identities. A guest and the machine-id of the OS inside + // it are two cards on purpose; sharing an address does not change that. + ? null + // Two scan-grade cards: a MAC beats an address, and nothing beats nothing. + : incomingHasMac == storedHasMac ? null : !incomingHasMac; + + if (incomingIsWeaker is not { } weaker) + return false; + + // Same as the MAC bridge: the weaker identity is dropped so the merge cannot + // downgrade the stronger one. Where the stronger card is the one arriving, its id + // survives and the merge stamps it onto the stored card instead. + if (weaker) + resource.DiscoveryId = null; + + unifiedName = stored.Name; + + return true; + } + + /// + /// Systems by address, excluding any address more than one of them claims — an + /// ambiguous address is not evidence. + /// + private static Dictionary BuildIpMap(IReadOnlyList existing) { + var byIp = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (SystemResource system in existing.OfType()) { + if (string.IsNullOrWhiteSpace(system.Ip)) + continue; + + if (!byIp.TryAdd(system.Ip, system)) + ambiguous.Add(system.Ip); + } + + foreach (var ip in ambiguous) + byIp.Remove(ip); + + return byIp; + } + /// The MACs a resource claims, from its "mac" and "macs" labels, normalised. private static IEnumerable MacsOf(Resource resource) { IEnumerable raw = [ diff --git a/RackPeek.Domain/Discovery/DockerApiClient.cs b/RackPeek.Domain/Discovery/DockerApiClient.cs index a204770e..f096d0f5 100644 --- a/RackPeek.Domain/Discovery/DockerApiClient.cs +++ b/RackPeek.Domain/Discovery/DockerApiClient.cs @@ -98,12 +98,31 @@ private static (HttpClient Client, string Endpoint) Create(string? dockerHost) { return (UnixSocketClient(path), dockerHost); } + // Anything else is rejected here rather than at send time. HttpClient accepts an + // ssh:// or npipe:// URI happily and only throws NotSupportedException on the + // first request — which is not in the caller's catch list, so a DOCKER_HOST that + // `docker context` set up quite normally crashed with a stack trace instead of + // saying what was wrong. + if (!StartsWithScheme(dockerHost, "tcp://") + && !StartsWithScheme(dockerHost, "http://") + && !StartsWithScheme(dockerHost, "https://")) + // A UriFormatException on purpose: both front ends already turn that into + // "not a usable Docker endpoint", so there is one phrasing for a bad endpoint + // rather than two. + throw new UriFormatException( + "Use a unix socket (unix:///var/run/docker.sock) or a TCP endpoint " + + "(tcp://host:2375). For an ssh:// context, forward the socket first — " + + "ssh -L 2375:/var/run/docker.sock user@host — and point --docker-host at that."); + // tcp:// is the scheme people have in DOCKER_HOST, but it is plain HTTP on the wire. var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase)); return (new HttpClient { BaseAddress = uri }, dockerHost); } + private static bool StartsWithScheme(string value, string scheme) => + value.StartsWith(scheme, StringComparison.OrdinalIgnoreCase); + private static HttpClient UnixSocketClient(string socketPath) { var handler = new SocketsHttpHandler { ConnectCallback = async (_, cancellationToken) => { diff --git a/RackPeek.Domain/Discovery/INetworkProbe.cs b/RackPeek.Domain/Discovery/INetworkProbe.cs index 2332b2dc..b39500b5 100644 --- a/RackPeek.Domain/Discovery/INetworkProbe.cs +++ b/RackPeek.Domain/Discovery/INetworkProbe.cs @@ -32,6 +32,27 @@ public interface INetworkProbe { /// The host's reverse-DNS name, or null when it has none worth keeping. Task ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default); + /// + /// The subject line of the certificate a TLS port presents, raw, or null when the + /// port is closed or speaks no TLS. Self-signed certificates are the norm on a + /// homelab — Proxmox and OPNsense both ship one naming the host — so the + /// certificate is read without being trusted, and nothing is ever sent over the + /// connection. + /// + Task ReadTlsSubjectAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// The first line a port volunteers on connect, before anything is sent to it — + /// what SSH greets with. Null when the port is closed or stays silent. + /// + Task ReadTcpBannerAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// The head of an HTTP response to GET /: status line, headers, and enough + /// body to reach a <title>. Null when the port serves no HTTP. + /// + Task ReadHttpHeadAsync(string ip, int port, bool tls, TimeSpan timeout, CancellationToken cancellationToken = default); + /// /// The subnet of the first up, non-loopback IPv4 interface with a gateway — what /// `--cidr` defaults to. Null when the machine has no such interface. diff --git a/RackPeek.Domain/Discovery/IProxmoxClient.cs b/RackPeek.Domain/Discovery/IProxmoxClient.cs index 20531c49..5ecf130f 100644 --- a/RackPeek.Domain/Discovery/IProxmoxClient.cs +++ b/RackPeek.Domain/Discovery/IProxmoxClient.cs @@ -41,4 +41,16 @@ Task GetGuestConfigAsync( string endpoint, int vmId, CancellationToken cancellationToken = default); + + /// + /// Addresses the guest reports for its own interfaces — the only way to learn a + /// DHCP guest's address, since the config only carries one when it was set + /// statically. Needs the guest agent for a VM and a running container for LXC, + /// so an empty list is the normal answer for anything that has neither. + /// + Task> GetGuestAddressesAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default); } diff --git a/RackPeek.Domain/Discovery/MacVendorLookup.cs b/RackPeek.Domain/Discovery/MacVendorLookup.cs new file mode 100644 index 00000000..79411f3e --- /dev/null +++ b/RackPeek.Domain/Discovery/MacVendorLookup.cs @@ -0,0 +1,106 @@ +namespace RackPeek.Domain.Discovery; + +/// +/// Turns a MAC address into the organisation IEEE assigned its OUI to — the only +/// identity a silent device on the wire ever volunteers. A camera that answers no +/// port and has no PTR record is still recognisably an Espressif or a Ubiquiti. +/// Pure: the table is generated (see ), never fetched. +/// +public static class MacVendorLookup { + /// + /// True when the address was made up by the device rather than assigned by a + /// manufacturer — the locally-administered bit is set. Modern phones and laptops + /// randomise per network for privacy, so these carry no vendor at all, and the + /// OUI half is meaningless rather than merely unknown. Saying so is more useful + /// than reporting whichever company happens to own the matching block. + /// + public static bool IsLocallyAdministered(string? mac) { + var octet = FirstOctet(mac); + + return octet >= 0 && (octet & 0x02) != 0; + } + + /// + /// The assigned vendor, "Randomised (locally administered)" for a self-assigned + /// address, or null when the OUI is not in the curated table. Null means "we do + /// not know", never "no vendor". + /// + public static string? Lookup(string? mac) { + var prefix = NormalisePrefix(mac); + + if (prefix == null) + return null; + + var index = IndexOf(prefix); + + // The table is consulted before the locally-administered check on purpose: + // hypervisors mint guest addresses out of that range (KVM's 52:54:00), so the + // bit alone would report a VM as anonymous when its prefix names the emulator. + if (index < 0) + return IsLocallyAdministered(mac) + ? "Randomised (locally administered)" + : null; + + var vendorIndex = MacVendorTable.Records[index + 6] - MacVendorTable.FirstIndexChar; + + return vendorIndex >= 0 && vendorIndex < MacVendorTable.Vendors.Length + ? MacVendorTable.Vendors[vendorIndex] + : null; + } + + /// The first six hex digits, upper-cased, or null when that cannot be read. + private static string? NormalisePrefix(string? mac) { + if (string.IsNullOrWhiteSpace(mac)) + return null; + + Span digits = stackalloc char[6]; + var count = 0; + + foreach (var c in mac) { + if (!Uri.IsHexDigit(c)) + continue; + + digits[count++] = char.ToUpperInvariant(c); + + if (count == 6) + return new string(digits); + } + + return null; + } + + private static int FirstOctet(string? mac) { + var prefix = NormalisePrefix(mac); + + return prefix == null + ? -1 + : Convert.ToInt32(prefix[..2], 16); + } + + /// + /// Binary search over the packed records. Returns the index of the matching + /// record's first char, or -1. + /// + private static int IndexOf(string prefix) { + var records = MacVendorTable.Records; + var low = 0; + var high = records.Length / MacVendorTable.RecordLength - 1; + + while (low <= high) { + var mid = (low + high) / 2; + var at = mid * MacVendorTable.RecordLength; + + var comparison = string.CompareOrdinal(records, at, prefix, 0, 6); + + if (comparison == 0) + return at; + + if (comparison < 0) + low = mid + 1; + else + high = mid - 1; + } + + return -1; + } +} diff --git a/RackPeek.Domain/Discovery/MacVendorTable.g.cs b/RackPeek.Domain/Discovery/MacVendorTable.g.cs new file mode 100644 index 00000000..7b34c699 --- /dev/null +++ b/RackPeek.Domain/Discovery/MacVendorTable.g.cs @@ -0,0 +1,653 @@ +// +// Generated by generate-oui-table.py from the IEEE OUI registry +// (https://standards-oui.ieee.org/oui/oui.csv). Do not edit by hand — add a +// vendor pattern to the generator and re-run it instead. +// +// 10868 assignments across 87 vendors, packed as fixed-width +// "PPPPPPv" records (6 hex prefix chars + one vendor index char), sorted so +// MacVendorLookup can binary-search them without building a dictionary. +// + +namespace RackPeek.Domain.Discovery; + +internal static class MacVendorTable { + internal const int RecordLength = 7; + + internal const char FirstIndexChar = '!'; + + internal static readonly string[] Vendors = [ + "AMD", + "ASRock", + "ASUS", + "AVM (Fritz!Box)", + "Amazon", + "Apple", + "Aqara", + "Aquantia", + "Arlo", + "Arris/CommScope", + "Aruba", + "Asustor", + "Axis", + "Beelink", + "Broadcom", + "Buffalo", + "Chelsio", + "Cisco", + "D-Link", + "Dahua", + "Dell", + "Edimax", + "Espressif", + "Fortinet", + "FriendlyELEC", + "Gigabyte", + "Google", + "HPE/HP", + "Hardkernel (ODROID)", + "Hikvision", + "Huawei", + "IKEA", + "Intel", + "Juniper", + "Khadas", + "LG", + "Lenovo", + "MSI", + "Mellanox", + "Microsoft", + "MikroTik", + "Minisforum", + "NVIDIA", + "Netgate", + "Netgear", + "Nintendo", + "Nordic Semiconductor", + "Parallels", + "Philips", + "Pine64", + "Proxmox", + "QEMU/KVM", + "QNAP", + "Radxa", + "Raspberry Pi", + "Realtek", + "Reolink", + "Ring", + "Roku", + "Sagemcom", + "Samsung", + "Seagate", + "Sercomm", + "Shelly", + "Signify (Hue)", + "Silicon Labs", + "Sonoff", + "Sonos", + "Sony", + "Sophos", + "Supermicro", + "Synology", + "TP-Link", + "Technicolor", + "Tenda", + "TerraMaster", + "Texas Instruments", + "Tuya", + "Ubiquiti", + "VMware", + "VirtualBox", + "Western Digital", + "Wyze", + "Xen", + "Xiaomi", + "Zotac", + "Zyxel" + ]; + + internal static readonly string Records = + "00000C200009750000B460000F0]00014220001432000144500014Ae00016320001642000196200019720001C720001C920001E6<0001E7<0002162000217200023D200024A2" + + "00024B200026CQ00027D200027E20002A5<0002B3A0002B920002BA20002C9G0002FC20002FD200033120003322000347A00036B200036C2000393&00039F20003A020003E32" + + "0003E420003FD20003FE20003FFH00040E$00041Fe000423A0004272000428200044BK00044D200044E200046D200046E200049A200049B20004C020004C120004CF^0004DD2" + + "0004DE20004EA<00050020005012000502&0005312000532200054EQ00055D300055E200055F2000569p00057320005742000585B00059A200059B20005B5/0005DC20005DD2" + + "000628200062A20006522000653200065B500067C20006C120006D620006D720006F6200070D200070E20007400000743100074F2000750200077D2000784200078520007AB]" + + "0007B320007B420007E9A0007EB20007EC2000802<0008202000821200082F2000830200083120008322000874500087C200087D2000883<0008A320008A420008C220008C6Q" + + "0008C7<0008E220008E3200090F8000911200091220009432000944200095BM00095CQ00097B200097C20009B620009B720009BFN0009E820009E920009FBQ000A27&000A412" + + "000A422000A57<000A8A2000A8B2000A95&000AB72000AB82000AD9e000AEBi000AF32000AF42000AF7/000B452000B462000B57b000B5F2000B602000B852000B86<000BBE2" + + "000BBF2000BCD<000BDB5000BFC2000BFD2000C29p000C302000C312000C42I000C50^000C6E#000C852000C862000CCAr000CCE2000CCF2000CE68000CF1A000D0B0000D282" + + "000D292000D4B[000D565000D652000D662000D883000D93&000D9D<000DB6/000DBC2000DBD2000DEC2000DED2000E07e000E0CA000E2E6000E35A000E382000E392000E58d" + + "000E59\\000E7F<000E832000E842000E8F_000EA6#000EB3<000ED62000ED72000F1F5000F20<000F232000F242000F342000F352000F3D3000F4Fq000F61<000F8F2000F902" + + "000FB5M000FDEe000FEA:000FF72000FF82001007200100B200100D200101120010142001018/00101F2001029200102F20010542001079200107B2001083<0010A620010DBB" + + "0010E3<0010F620010FA&0010FF2001109F00110A<001111A00112020011212001124&00112F#001132h001143500115C200115D2001175A001185<001192200119320011953" + + "0011BB20011BC20011C6^0011D8#0012002001201200121EB001237m00123F500124320012442001247]001248500124Bm00125AH001279<00127F200128020012D1m0012D2m" + + "0012D920012DA20012EEe0012F0A0012FB]0012FEE001302A001315e001319200131A2001320A001321<0013463001349w00135F200136020013725001377]00137F20013802" + + "0013A9e0013C320013C420013CEA0013D4#0013E8A00141B200141C20014225001438<001451&001469200146A200146CM001478i0014A820014A920014C2<0014C3^0014EEr" + + "0014F120014F220014F6B001500A00150C$001517A00152B200152C20015305001556\\00155DH001560<0015622001563200156Do001599]0015B9]0015C1e0015C550015C62" + + "0015C720015E930015F2#0015F920015FA20016010001620e001632]001635<00163Et00164620016472001656N00166B]00166C]00166FA001676A00169C200169D20016B8e" + + "0016C720016C820016CB&0016DB]0016E6:0016EAA0016EBA001708<00170E200170F2001731#00173B2001759200175A2001783m001788a0017942001795200179A30017A4<" + + "0017ABN0017B6(0017C9]0017CBB0017D5]0017DF20017E020017E3m0017E4m0017E5m0017E6m0017E7m0017E8m0017E9m0017EAm0017EBm0017ECm0017F2&0017FAH001813e" + + "0018182001819200182Fm001830m001831m001832m001833m001834m00184DM001862^001871<00187320018742001882?00188B50018AF]0018B920018BA20018DEA0018F3#" + + "0018FE<0019062001907200191DN00192F2001930200194B\\0019552001956200195B3001963e0019A920019AA20019B950019BB<0019C5e0019CBw0019D1A0019D2A0019E0i" + + "0019E2B0019E3&0019E720019E820019FDN001A11;001A1E<001A2F2001A302001A4B<001A4D:001A4F$001A6C2001A6D2001A75e001A80e001A8A]001A8Cf001A92#001AA05" + + "001AA12001AA22001AB6m001AE22001AE32001AE9N001B0C2001B0D2001B113001B21A001B2A2001B2B2001B2FM001B532001B542001B59e001B63&001B672001B77A001B78<" + + "001B7AN001B8F2001B902001B98]001BBF\\001BC0B001BD42001BD52001BE9/001BEAN001BFC#001C0E2001C0F2001C14p001C235001C42P001C43]001C4A$001C572001C582" + + "001C62D001CA4e001CB02001CB12001CB3&001CBEN001CBFA001CC0A001CC4<001CF03001CF62001CF92001D095001D0De001D0Fi001D25]001D28e001D38^001D452001D462" + + "001D4F&001D60#001D702001D712001D730001D7D:001DA12001DA22001DB5B001DBAe001DBCN001DD8H001DE0A001DE1A001DE52001DE62001DF6]001E0B<001E10?001E132" + + "001E142001E2AM001E35N001E45e001E492001E4A2001E4F5001E52&001E583001E64A001E65A001E67A001E74\\001E75D001E792001E7A2001E7D]001E8C#001EA9N001EBD2" + + "001EBE2001EC2&001EC95001EDCe001EE1]001EE2]001EF62001EF72001F12B001F1F6001F262001F272001F29<001F32N001F33M001F3BA001F3CA001F3F$001F5B&001F6BD" + + "001F6C2001F6D2001F95\\001F9D2001F9E2001FA7e001FC5N001FC6#001FC92001FCA2001FCC]001FCD]001FD0:001FE3D001FE4e001FF3&002037^00207BA0020ED:00211B2" + + "00211C2002127i002147N00214C]00215520021562002159B00215A<00215CA00215DA00216AA00216BA0021705002191300219B500219Ee0021A020021A120021BAm0021BDN" + + "0021D1]0021D2]0021D720021D820021E9&0021FBD00220C200220D2002215#002219500223FM002241&002248H00224CN00225520022562002264<002283B00229020022912" + + "002298e0022A1?0022A5m0022A6e0022A9D0022AAN0022B030022BD20022BE20022D7N0022FAA0022FBA00230420023052002312&002314A002315A002331N002332&0023332" + + "0023342002339]00233A]002345e002348\\002354#00235D200235E200236C&00237D<002399]00239CB0023AB20023AC20023AE50023CCN0023CDi0023D4m0023D6]0023D7]" + + "0023DF&0023EA20023EB20023F1e0023F8w00240130024132002414200241D:00241EN002436&002444N00245020024512002454]00246C<002481<002483D00248C#00248De" + + "002490]002491]002497200249820024A500024B2M0024B6^0024BAm0024BEe0024C320024C420024D6A0024D7A0024DCB0024E850024E9]0024EFe0024F3N0024F720024F92" + + "0024FE$002500&00251BQ002522\"002538]0025452002546200254B&0025645002566]002567]002568?002569\\00258320025842002586i00258BG002590g00259E?0025A0N" + + "0025AEH0025B3<0025B420025B520025BC&0025E5D0025E7e002608&00260A200260B2002618#00264A&00265120026522002655<002659N00265A300265D]00265F]002688B" + + "002691\\002698200269920026B0&0026B950026BB&0026C6A0026C7A0026CA20026CB20026E2D0026F2M002709N00270C200270D200270EA002710A002719i002722o0027902" + + "0027E320028F8A0029C22002A102002A6A2002B70]002BF50002CC82002EC7?002F5C2003019200302420030402003048g003065&00306E<0030712003078200307B20030802" + + "0030852003094200309620030A320030B620030C1<0030F22003146B003192i003217200337An0034DAD0034FE?00351A20035FFm0037B7\\0038DF2003A7D2003A982003A992" + + "003A9A2003A9B2003A9C2003C102003C84b003DE1?003DE8D003EC4?003EE1&00400B2004020*004026000408C-00409620041D22004238A00425A2004268200451D200464B?" + + "004B0D?004B127004E015004E35<004F1A?00500B200500F2005013^005014200502A200503E2005050200505320050542005056p0050732005080200508B<0050A220050A72" + + "0050BA30050BD20050CC^0050D120050E220050E4&0050F020050FC600562B200566D?0056CD&0057C1D0057D22005907E00596C20059DC2005A13?005B94&005D732005F67i" + + "005F862006009200602F200603E20060472006048500604C\\00605C2006070200608320060B0<006151?006171&00620B/0062EC20064402006619?00664B?0066DC&00682B?" + + "0068EB<006B6F?006BF12006CBC2006D52&006F64]0070077007147%007204]007230O00727820072EEA0073E0]007686200778D2007888200789E\\007C2D]007D3B]007D60&" + + "007E95200805F<0080A0<0080C8300812A&0081C420081F9m008320?008621%008701]00873120087642008865&008A55?008A76&008A962008E732008EF2M00900C20090212" + + "009027A00902B200905F2009069B00906D200906F2009086200909220090A620090AB20090B120090BF20090D920090F1^0090F2200919EA009235&009337A0094EC?0097F1&" + + "00991D?009ACD?009AD22009C02<009E1E2009EC8u00A040&00A0C5w00A0C9A00A159D00A289200A2EE200A38E200A3D1200A45F?00A554A00A5BF200A62B700A6CA200A7422" + + "00A91D?00AA00A00AA01A00AA02A00AA6E200AA70D00AAFDm00AD24300ADD5?00AF1F200B04A200B064200B08E200B0C2200B0D0500B0E1200B1E3200B362&00B463Z00B5D0]" + + "00B670200B771200B8B3200BB1C?00BB3A%00BB60A00BC60200BC99>00BE3B?00BE43500BE44b00BE75200BF61]00BF77200C002_00C04F500C0FF^00C164200C1B1200C2C6A" + + "00C30Au00C3F4]00C52CB00C585&00C610&00C84E<00C88B200CAE5200CB51\\00CC05?00CC34B00CCFC200CDFE&00D006200D058200D063200D079200D090200D097200D0B7A" + + "00D0BA200D0BB200D0BC200D0C0200D0D3200D0E4200D0FF200D49EA00D6FE200D76DA00D78F200D861F00D8A2?00D9D1e00DA55200DB70&00DBDFA00DEFB200DF1D200E0142" + + "00E018#00E01E200E034200E04F200E08F200E091D00E0A3200E0B0200E0F7200E0F9200E0FC?00E0FE200E12F?00E16D200E18CA00E3B2]00E406?00E421e00E5F1000EABD2" + + "00EB2De00EBD5200EC0Au00EEAB200F28B200F361%00F39F&00F46F]00F4B9&00F5FD?00F620;00F663200F76F&00F7AD?00F81C?00F82C200F8CC\\00F952?00FA21]00FB4A7" + + "00FC8B%00FCBA200FD22200FD45<00FEC8204006E;0401A1804021F?040312>0403D6N040973<040CCE&040D84b040E3C<040F66i04106Bu04137A&041471?041552&04180F]" + + "041892?0418D6o041B6DD041BBA]041C6CA041E64&042322m042336w0423A3G0425C5?0425E8m042665&042728H042758?04292E]042AE22042EC1&0430FA2043201/04331F?" + + "043389?0433C2A0434CF&043F72G0441A5&04421A#044707m04489A&04495D?044A6C?044BB1?044BED&044F4C?0452F3&045453&0455B8?0456E5A045C6CB045D4Be045FB92" + + "04627320463D0?0464FA5046761u046865&04698FB0469F8&046C59A046C9D2046F00D047179<047295&0472EF&04749E?047503?0476B02047970?0479B7m047A0Bu047AAE?" + + "047C16F04801A?0483087048727b04885F?048C16?048C9A?049226#0495E6k0499B9&0499BB&049D05&049FCA?04A151M04A316m04A6C8A04A741204A81C?04AE47u04B0E7?" + + "04B167u04B1A1]04B247704B429]04B4FE$04B5B2&04B9E3]04BA1C?04BA8D]04BAD6304BC6D&04BD70?04BD88<04BD97204BDBF]04BE58?04BF1B504BF6Dw04BFD5&04C06F?" + + "04C1D8?04C5A4204C5CDG04C807u04C845i04C8B0;04CAED?04CB01]04CCBC?04CD15b04CF4BA04CF8Cu04D13Au04D3B0A04D3B5?04D3CF&04D4C4#04D590804D9F5#04DAD22" + + "04DB56&04E31A\\04E387204E3E5b04E451m04E4B6]04E536&04E598u04E795?04E8B9A04EA56A04EB40204ECD8A04ED33A04EE03m04EECD>04EF61504F03E?04F0EEA04F13E&" + + "04F169?04F352?04F41CI04F778e04F7E4&04F938?04F9F8i04FE31]04FE7F204FE8D?04FF08?080007&080009<08001B5080027q080028m080046e080205?08023C]08028EM" + + "0804B4m080581[0805E2B0808C2]080FE52081093]081196A0812A5%08152F]0816E3?0817352081814<0819A6?081AFD?081C6Eu081F71i081FF320820E7G0821EF]0823C6?" + + "08240B&082525u082573&082697w08276B?082CB6&082E36?082E5F<082FE9?08318B?0831A4?08357DH0836C9M08373D]083A8D7083AF27083BC1>083D88]083E5D\\0840F3k" + + "0845D12084F0A?084FA92084FF92085104?0851F2\\085411>085531I085700i0857FB%085A113085B0E8085BD6A085C1B?085D53&08606E#086202&086266#086361?08638A2" + + "086518&086698&086AC5A086AE5%086BD7b086D41&086E9C?087045&087073?087190A087402&087671B087808]08798C?087A4C?087B0F%087B12\\087B872087C39%087C43?" + + "0881F4B08849D%0887C7&088BC8;088C2C]088E90A088EDC&089115%0891A3%08920450892727089356?0894EC?089542&0896AD20896D7$0897072089734<089DF4A089E08;" + + "089E84?08A189>08A5DF]08A6BC%08A6F7708A842?08AD0A708AED6]08B258B08B339u08B3D6?08B4B1;08B4D2A08B61F708B657$08B95Fb08BD43M08BEAC608BFA0]08BFB8#" + + "08C021?08C06C?08C0EBG08C224%08C729&08C7B5&08CC68208CC81>08CCA7208D01EB08D09F208D1F9708D23EA08D40CA08D42B]08D46AD08D593m08D59D\\08D945?08DD82?" + + "08DDEBb08E64B&08E689&08E7E5?08E84F?08EB21A08EBF6?08ECA9]08ECF5208EDED408EE8B]08F1EA<08F3FB208F458?08F4AB&08F4F0208F69C&08F8BC&08F9E0708FA28?" + + "08FC88]08FD0E]08FD52b08FD58?08FF44&0C02BD]0C0535B0C07DFu0C07F3?0C0ADFm0C0E7630C0ECB?0C116720C12AD<0C1420]0C1539&0C1563&0C1773?0C184E?0C19F8&" + + "0C1B7BH0C1C57m0C1DAFu0C238D?0C264320C272420C29EF50C2A6Fb0C2C54?0C2D71&0C2E57?0C2FB0]0C3021&0C31DC?0C323A]0C3526H0C37DC?0C3B50&0C3E9F&0C413EH" + + "0C41E9?0C42A1G0C4314b0C43F9%0C45BA?0C47C9%0C4885D0C4B54i0C4BEEm0C4DE9&0C4EA070C4F9B?0C5101&0C517E&0C53B7&0C5415A0C599CB0C61CFm0C6743?0C68032" + + "0C6AC4&0C6F8B&0C7043e0C704A?0C715D]0C722Ci0C7274$0C7329_0C74C2&0C75BD20C75D2>0C771A&0C7A15A0C8063i0C8126B0C8268i0C8306?0C839A?0C8408?0C85252" + + "0C85E1&0C8610B0C8910]0C8B9570C8BA2?0C8BFDA0C8DCA]0C8FFF?0C9192A0C96BF?0C975F<0C9838u0C9A3CA0C9D92#0CA3B2&0CA8A7]0CAC8A\\0CAE39A0CAE5Fb0CAE7Dm" + + "0CAF3120CB2B7m0CB319]0CB527?0CB5B3?0CB6D230CB787?0CB78E?0CB7EC?0CB81570CBC9F&0CBEF1?0CC413;0CC47Ag0CC56C&0CC6CC?0CC6FDu0CC98AA0CCC5D&0CD0F82" + + "0CD292A0CD5D320CD6BD?0CD746&0CD99620CDBEA&0CDC7E70CDC91%0CDD24A0CDFA4]0CE0DC]0CE441&0CE4A0?0CE5A1&0CE5B5?0CE623?0CE725H0CEA14o0CEABFG0CEC80m" + + "0CEDC8u0CEE99%0CEF15i0CEFF6b0CF346u0CF5A420CFC18?0CFE45e0CFEE5m100020&10003B7100177?1002B5A1005CA210061C7100645\\1006ED21007B6]10082Cm1009F9%" + + "100BA9A100C6BM100D7FM100D8C?100E7EB1012FB>101B54?101C0C&101D6E<101DC0]101F74<1020BA7102407?1027F5i102959&1029AB]102AB3u102B41]102BAA\\102E00A" + + "102EAFm102F6BH102FCA&103025&103047]10321D?10327E?103917]1039E9B103B54?103B59]103D1CA103F44u1040F3&10417F&104210&104400?104780?10490E?104A7DA" + + "104F58<104FA8e105072_105107A105172?1051DB710521C71052BD?1057252105932[105A17n105A95i105DDC?105FADA10604B<1062E5<1062EB310653051067A3?10683FD" + + "106F3F01070FDG107100?1071B3w1077B1]107A2AH107B44#107BEFw107C61#107D1A5107DC8&1086F4?1088D3?1089FB]108CCF2108EE0]108FFE?1091A871091D1A109266]" + + "109327:1093E9&1094BB&1094EF?109693%1096C621097BD710981951098365109ABAA109ADD&109D7A?109E6B&109F41&10A1DA&10A2D3&10A30F?10A4C9&10A4DA?10A51DA" + + "10A829210A85B710A879A10ABC9]10AE60%10B1F8?10B3C6210B3D5210B3D6210B41D710B588&10B676<10B9C4&10BC36?10BD18210BD3A&10BDA3710BEF5310BF48#10BF67%" + + "10C172?10C197u10C37B#10C3AB?10C595E10C5FAw10C61F?10C735H10CABFm10CD54?10CE02%10CEA9m10CEE9&10CF0F&10D38A]10D542]10D561n10D7B0\\10D9A2;10DA43M" + + "10DA49?10DA63&10DBA2m10DDB1&10E2C9&10E376210E4C2]10E676210E7C6<10E953?10EC81]10F005A10F1F2D10F311210F60AA10F920210F96FD10FC33?10FEEDi10FFE0:" + + "140152]1402EC<140338?140498&14080871409DC?140AC5%140B9E]14109F&1413FB?14147D&14169D214187751418C3A1419232141A97&141BA0&141F78]14205E&14223B;" + + "14230A?1423F2/1423F3/142876&142B2F7142D41b142D4D&142E5E_143004?1432D1]14335C7143375w1435B7&14360Ew1436C6E143B51?143CC3?143EC2A143FA6e1442FCm" + + "144658?144920?1449C5?1449D4u144F8AA145120?145594?14563A?14568E]14579F?1458D0<1459C0M145A05&145EBC?145F94?1460CB&146393714656A?14755BA147590i" + + "147649<147740?147830K147AE4&147DDA&147E19<147F0Fm147FCE&1484732148509&14857FA148692i14876A&1488E6&1489CB?1489FD]148C4A?148F79&148FC6&14907Au" + + "149138%14946C&1495CE&1496E5]149877&14993Eu1499E2&149A10H149AA3?149CEFm149D09?149D99&149ECF5149F3C]149FE8E14A0F8?14A2A0214A32F?14A364]14A3B4?" + + "14A51A?14A6B9]14A78B414AB02?14ABC5A14ABEC<14B31F514B3A1B14B457b14B484]14B653&14B903?14B968?14BB6E]14BBCCm14BC68214BD61&14C14E;14C19F714C213&" + + "14C7C4w14C88B&14C913D14CB19<14CB65H14CC20i14CF92i14D00D&14D11F?14D169?14D19E&14D1D4?14D55C&14D64D314D864i14D881u14DAB9?14DAE9#14DDA9#14DE39?" + + "14E01D]14E1C9b14E22A214E6E4i14EB08?14EBB6i14F287&14F42A]14F65Au14F6D8A14FB70?14FEB5518002De1801F1u18022D?18037351804EDm180B1B%180BD0?180C7A\\" + + "180DF9b180F7631814F4m1816C9]1819D6]181DEAA181E78\\181EB0]182032&182195]18227E]182649A182654]182666]182A57?182A7BN182AD3B182C65m1831BF#1832DE&" + + "183386?18339D2183451&183A2D]183CB7?183D5E?183DA2A183EEF&183F47]183F70&184516m184617]1848BE%184A53&184E16]184ECB]1854CF]1855E3&185644?185680A" + + "1856C3&185936u1859F52185A585185BB3]185E0FA186024<18622C\\1862E4m186472<186590&1866DA51867B0]1868CB>18690Ab186945i1869D4]1869D8n186A81\\18703B?" + + "18742E%187A3B<187A3Eb187EB9&187F88Z188025>188090218810E&188331]188740u18895B]188B0E7188B452188B9D21890D8\\189341A1893D7m189C5D2189E2C?189EFC&" + + "18A084&18A6F7i18A905<18A99B518AA0F?18AB1D]18AF61&18AF8F&18B657?18B83D]18B842&18BB1C?18BB41?18BFB3]18C007?18C04D:18C086/18C23C'18C2BF018C58A?" + + "18CC18A18CE94]18CF24?18D0E1G18D276?18D3CF<18D6C7i18D6DD?18D98F?18DBF2518DC12b18DE50n18DED7?18E2C2]18E671&18E728218E7B0&18E7F4&18E829o18E91D?" + + "18ECE7018EE69&18EF63218EFC0_18F0E4u18F1D8&18F22Ci18F643&18F935218FAB7&18FB7B518FD74I18FE34718FF0FA1C0B8Bo1C0D7D&1C0EAF?1C0EC2&1C12B0%1C1386?" + + "1C13FA?1C151F?1C17D321C1AC0&1C1ADFH1C1B0D:1C1BB5A1C1D67?1C1D8621C1DD3&1C1FF1?1C20DB?1C222621C232C]1C2575u1C28AF<1C290471C2AB0u1C3003<1C32AC?" + + "1C34DAG1C34F1b1C3576]1C36BB&1C3ADE]1C3BF3i1C3C78&1C3CD4?1C3D2F?1C402451C42C2?1C4363?1C4419i1C4586N1C4593m1C472F?1C4D66%1C4D70A1C53F9;1C57DC&" + + "1C599B?1C5A3E]1C5A6BQ1C5CF2&1C5F2B31C61B4i1C61BF&1C627E?1C62B8]1C6349m1C66AA]1C6758?1C692071C6A1Bo1C6A76&1C6A7A21C6F65:1C7055?1C7125&1C721D5" + + "1C73E2?1C740Dw1C76F2]1C7754&1C7B21e1C7EE531C7F2C?1C84A621C8682&1C869A]1C872C#1C8B8471C8BEFu1C8E2A&1C8E5C?1C8F5771C90FFn1C9148&1C9180&1C93C4%" + + "1C98EC<1C9957A1C99DB?1C9C8CB1C9DC271C9E46&1CA681?1CAA0721CABA7&1CAECB?1CAF05]1CAF4A]1CAFF731CB3C9&1CB46C?1CB72C#1CB796?1CBA8Cm1CBDB931CC089b" + + "1CC10CA1CC1DE<1CC21281CC3AB71CCCD6u1CD11A81CD1E021CD21EB1CDBD471CDEA721CDF0F21CDF52m1CE209&1CE2CCm1CE4CB71CE4DDj1CE504?1CE57F]1CE61D]1CE62B&" + + "1CE639?1CE6AD?1CE6C721CE85D21CEAACu1CED6F$1CF29A;1CF42B?1CF64C&1CF8D0]1CF9D5&1CFA68i1CFC1721CFC2A?1CFE2B%1CFFAD?20040F5200484&2008ED?200B16m" + + "200BC52200BC7?200BCFN200CC8M200E2B&2010B1%2013E0]2014C4?201582&2015DE]201642H2016B9A201742D201A94&201BC9B201C3AN201E1D?201E88A201F3B;2021A5D" + + "202351i20256572025CCu202680]20283E?202BC1?202D07]202DF6&20326C]2032C6&203389;203462u2034FBu203543\\203626i20370622037A5&203A072203A43A203B34u" + + "203B67]203CAE&203DB2?2043A8720463A&20474752047B5\\2047DAu204C03<204C9E2204D52G204E71B204E7FM20500D720521D&205383?205476e2054FA?205531]2059D1G" + + "205E64?205EF7]206274H20658E?20677C<206980&206BE7i206BF4?206E9C]206EF172072A9u20768F&207693E2078CD&2078F0&207918A207D74&2082C0u20845F&2087EC?" + + "2088105208C0A&208C86?209148m2091DF&209339B209952u209A7D\\209BA97209BCD&209BDD?209CB4<20A171%20A200?20A2E4&20A5CB&20A60Cu20A680?20A6CD<20A716b" + + "20A766?20A8BF?20A99BH20AB37&20AB48?20AEB6?20B82B\\20BBC0220BD1DA20BEB8%20C19BA20C2B0?20C38Fm20C9D0&20CC27220CC73]20CD39m20CF30#20CFAE220D390]" + + "20D476%20D5BF]20D5C2720D778m20D80BB20DA22?20DBAB]20DBEA220DCE6i20DCFD?20DF73?20DFB9;20E15Di20E2A8&20E525?20E52AM20E7C8720E874&20ED47B20EE28&" + + "20EFBD[20F094;20F120220F17C?20F1B2n20F3A3?20F478u20F4D4&20FA85&20FE00%20FF0C?2400BA?24016F?2401C72240588;240935]240995?240A3F]240AC47240F9B>" + + "241145u241153]241551?24161B224166D?24169D2241AE6?241B7A&241EEB&241F3Ab241FA0?2420C7\\2421ABe24240E&2424B7]2426D6?2427E5?2428FD>242934;2429B0?" + + "242A042242AEA&242BD6Z242E02?242FD0i2430F8?243154?2432AE>2436DA2243FAA?24418CA2442E37244427?24456B?2446E4?244750\\244845>244885?244AF8b244B03]" + + "244B81]244BF1?244BFE#244C07?244CAB7244CE3%244ECD\\24526A42453ED524559A&24587C7245A4Co245A5Fi245AB5]245BA7&245CC5?245D92B245E48&245EBEU245F9F?" + + "246078?2460B3]2462AB72462C6?2462CE<246477u24649F?246511$246800]2468B0]246968i2469A5?246A0E<246C60?246C842246D10&246E965246F287246F8C?2471212" + + "2471525247189m247625m247645?247703A247755?247D4Dm247E122247F20\\247F3C?24813B22481C7?248A07G2491BB?24920E]24952F;249745?249EAB?249F89m24A074&" + + "24A160724A2E1&24A43Co24A452]24A487?24A52C?24A799?24AB81&24B105>24B2DE724B339&24B657224B6FD524BA23224BCF8?24BE05<24C42FQ24C613]24C696]24CE33%" + + "24CF24u24D0DF&24D337u24D5E4224D660b24D79C224D7EB724DA33?24DB94B24DBAC?24DBED]24DCC3724DEC6<24DEEB?24DF6A?24E29D?24E314&24E50F;24E5AAQ24E9B32" + + "24E9CA?24EA9B?24EB16A24EBED?24EC4A724EE9AA24F094&24F0D3]24F27F<24F40A]24F5AA]24F603?24F677&24FB65?24FBE3<24FC4EB24FCE5]2800AF52801CDG28022E&" + + "280244&2802D8]2805A57280708]280B5C&280C50A280DFCe28107B32811A8A2811EC?28167Fu2816A8H2816ADA281709?281878H281DFB?28221E?2824C9%2827BF]28285Dw" + + "282B96?282CB2i282CC4?282D7F&283152?2831F8?283334?2834A222834FF&28353A?2836F0?28372F7283737&28395E]283B823283C90m283CE4?283DC2]283F69e2840DDe" + + "2841C6?2841EC?2845AC?2848E7?2849E9&284B54&284E44?285261228534E?285471?28562F728575D&2857BE>285923u285AEB&285FDB?2864B0?286847b2868D2?286AB8&" + + "286ABA&286B35A286B5C2286C07u286ED4?286F7F228704Eo2872C6]2873F6%287681b2877F1&287AB4?287FCFA288023<288088M28808A?288335]2883C9&28848572887BAi" + + "288A1CB288EEC&288FF6&289104i289200A28924A<2893FE2289401M28940F2289529A2896B0?28987B]289C6E7289E1Em289E97?289EFC\\289F04]28A02B&28A06BA28A24BB" + + "28A44AA28A6DB?28A9AE?28AC9E228AF42]28AFFD228B2BDA28B448?28B591228B5E8m28B829B28BAB5]28BD89;28C039?28C0DAB28C1A0&28C538&28C5C8<28C5D2A28C63FA" + + "28C68EM28C709&28C7CE228CC01]28CDC1W28CF51N28CFDA&28CFE9&28D0EAA28D127u28D3EA?28D5B1&28D6EC?28DBA7b28DCC3?28DE1C]28DE65<28DEE5?28DFEBA28E02C&" + + "28E14C&28E31Fu28E34E?28E5B0?28E6A9]28E7CF&28EA0BH28EA2D&28EC95&28EC9Am28ED6A&28EE52i28EF01%28F033&28F076&28F10E528FBAE?28FF3C&2C01B522C0786?" + + "2C0823_2C08B4?2C0B97u2C0BAB?2C0BE922C0D27?2C0DA7A2C0DCFu2C10C1N2C1165b2C15BF]2C15D9?2C1809&2C195Cu2C1A01?2C1A0522C1CF7&2C1F23&2C200B&2C2080?" + + "2C2131B2C2172B2C233A<2C2768?2C27D7<2C2997H2C2B86\\2C301Aj2C3033M2C312422C326A&2C331122C3358A2C3361&2C36F2?2C36F822C3996\\2C3A91?2C3AB1?2C3AE87" + + "2C3AFD$2C3ECF22C3F3822C4053]2C4138<2C4401]2C44FD<2C4C15B2C4D54#2C4F5222C52AF?2C542D22C5491H2C54CFD2C55D3?2C56DC#2C574122C57CE&2C58B9<2C58E8?" + + "2C598AD2C59E5<2C5A0F22C5EABG2C61F6&2C63A1?2C658D22C693E?2C6B7Dm2C6BF5B2C6DC1A2C6E85A2C71FF%2C73A022C7600&2C768A<2C780E?2C79BEi2C79D7\\2C7BA0A" + + "2C7CF2&2C81BF&2C8217&2C86D222C8DB1A2C91AB$2C93FB_2C9452?2C9520&2C97B1?2C97EDe2C9975]2C9D1E?2C9D90G2C9E00e2CA042?2CA2E522CA59C>2CA774m2CA797?" + + "2CA79E?2CAA8Es2CAB00?2CAB33m2CABEB22CAE2B]2CB05DM2CB1B7G2CB43A&2CB471n2CB68F?2CB7A1?2CBABA]2CBC87&2CBCBB72CBE08&2CC253&2CC546?2CC81BI2CC8F5?" + + "2CCA16&2CCC44e2CCF58?2CCF67W2CD02D22CD066u2CD3ADm2CDA46]2CDB07A2CDF68&2CE2D9?2CE38E22CE412\\2CE5BDo2CEA7F52CEAFCA2CECA6?2CED89?2CEDB0?2CF05DF" + + "2CF0A2&2CF0EE&2CF295?2CF2A5\\2CF43272CF81422CF89B22CFB0F\\2CFDA1#2CFE4Fu3001AF2300505A300916&300E43&300EB8D300EE3(3010E4&30138B<301577w3017C8e" + + "301966]301984?301C22<302432A302478\\3024A9<30294B?3030D0m3030F973035AD&3035C5?3037A623037B3?303926e303A64A303B7C&303EA7A303FBB<304511m304596?" + + "30469AM30487Dn30499E?304D1F%304E1B?3050CEu30560F:305714&305A3A#3061A2?306222]30636B&3063EAB3066D0?3067A1\\306893i306A4F[306A85]307467]307496?" + + "307512e30766FD3076F57307A05?307AD2&307C4A?307C5EB308216&30839873085A9#308730?30894AA3089A6?3089ECN308AF7?308BB22308D99<308DD4?308ECF?309048&" + + "3090AB&3093BC\\309610?30963B?3096FB]309C23F309E62?30A033&30A1FA?30A2C2?30A30F?30A7F5&30A8DBe30A998?30AAE4?30AEA4730AF7Em30B49Ei30B4B8D30B5C2i" + + "30B64FB30BD13w30C0AE&30C50F?30C599#30C6F7730C7AE]30C922730C9CC]30CBF8]30CDA7]30D042530D17E?30D4E2?30D53E&30D587]30D6C9]30D7A1&30D875&30D9D9&" + + "30DE4Bi30DE52]30E044;30E04F&30E171<30E226&30E283m30E37AA30E396?30E3A4A30E4D8?30E4DB230E98E?30EB15?30EDA0730F335?30F600\\30F65D<30F6EFA30F70D2" + + "30F7C5&30F9EDe30FB10b30FBB8?30FC68i30FCEBD30FD38;30FD65?30FE6C&30FEFA230FFFD?3400A3?340286A34029C334033D<3403DEm34080433408BC&3408E1m340962>" + + "340A333340A98?340E22&34105Dm3410BE&3410D0?3410F4b341298&3412F9?3413E8A34145F]3414B5m341513m34159E&3417DD_3417EB5341B2D2341CF0u341E6B?3425B4b" + + "3425BE%342601?342840&342865B342912?342AF1m342B6E&342D0D]342EB6?342EB7A342FBDN343111]34318F&3431C4$343638?34363B&343916;343A20<343DA9?343DC40" + + "343EA4Z34415DA344262&3446EC?3448ED534495B\\344DF7D344EE2?345184?3451C9&3453D2\\3454EF2345840?34588A2345A60F345D9E\\345DA82345E08[345F4573460F9i" + + "34628823464A9<346679?346691&3468B5m346AC2?346B46\\346BD3?346E68?346F9023470692347146?34732D234735A5347916?347C25&347DF6A347E00?347E5Cd3480B3u" + + "3481C4$348296&3482C5]3483D5?3484E4m348518734865D73488182348A12<348A3B?348A7B]348AAE\\348C5E&348D13b34936FB3494547349671?349672i3497F6#34987A7" + + "3498B5M34A137?34A2A2?34A395&34A84E234A8A0?34A8EB&34AA8B]34AB37&34AB95734AF2CN34AFB3%34B1EB&34B1F7m34B20A?34B354?34B472734B7DA734B883234B98Du" + + "34BDC8234BE00]34C059&34C232]34C386&34C3AC]34C3FD234C459m34C515<34C7E9;34C93DA34CDB0734CDBE?34CE00u34CFF6A34D270%34D693?34D868/34DAA1&34DB9C\\" + + "34DBFD234DDCC;34DE1AA34E12DA34E1A9$34E2FD&34E3FB]34E6ADA34E6D7534E894i34EAE7734ED1B234EE16&34EFD7?34F015u34F043]34F084]34F39AA34F5D7?34F64BA" + + "34F68D&34F716i34F8DD&34F8E7234FA1Cu34FCB9<34FCEFD34FD6A&34FD70A34FE77&34FFF3?380025A380195]3802DE_380484&3809FB&380A94]380B3Cm380B40]380E4D2" + + "380F4A&380FAD?3810D5$3810F0<38142853816B3<3816D1]3817B1\\3817C3<38182B738184Ce381868A381C1A2381F8Dn382028?38205623821C7<3822E2<3822F4?3825F3G" + + "382C4A#382CE5n382DD1]382DE8]3830F9D38327AI3833C5H3835FB\\38378B?38396C?38398Fb383E517383FE8?38420Bd3844BE73847BC?38484C&384A80]384C4F?384DD2?" + + "384E56m384F49B385247?38539C&38563DH385B44b385CFBb386233&3863BB<3865B2&3866F0&386893A3868A4]386A77]386DEDB386EB2?386FF4%387035N3870F2?3871DE&" + + "38736E?387862e387A0EA387F8B&3881D7m388345i3886F7;3887D5A38881E?3888A4&38892C&388A06]388B59;388C50D388CEF]388F30]389052?3890A523891B72389496]" + + "3894EDM3898E9?389AF6]389CB2&389E4C<38A44B?38A4EDu38A5C9n38A659\\38AA09238AB41m38AF29438B3F7?38B54D&38BAB0/38BAF8A38BC01?38BD7A<38C0EA838C1EDu" + + "38C22DG38C43A&38C6BDu38C6CEN38C986&38CA84<38CADA&38D09C?38D269m38D40B]38D547#38DEADA38E13D&38E1F4\\38E2C4m38E60Au38EAA7<38EADDB38EB47?38EC0D&" + + "38ECE4]38ED18238F18Fj38F195?38F20DB38F73D%38F7F1?38F889?38F9D3&38FB14?38FC34?38FC98A38FDF8238FF5953C01EFe3C0518]3C058E?3C0630&3C06A7i3C0754&" + + "3C0771e3C07D7&3C08CDB3C08F623C0A7A]3C0B59n3C0D0D73C0E2323C0F0273C135Au3C13BB?3C13CC23C15C2&3C15FB?3C1710\\3C195E]3C1BF8>3C1E0433C1EB5&3C20F6]" + + "3C219CA3C22FB&3C240A?3C25F853C26E423C286D;3C2983]3C2C3053C2CA6u3C2CCD&3C2DB7m3C2EF5b3C2EF9&3C2EFF&3C306F?3C3174;3C318A]3C333233C3464&3C366A?" + + "3C3712$3C3786M3C381F?3C3824u3C38F4e3C39C8&3C3B77&3C410E23C46D8i3C4711?3C4A92<3C4AC9?3C4DBE&3C5002&3C510E23C5282<3C52A1i3C5447?3C573123C576C]" + + "3C5836\\3C585D\\3C58C2A3C59C0?3C5A37]3C5AB4;3C5CC4%3C5EC323C6104B3C610573C6200]3C62F0_3C64CFi3C65D1?3C678C?3C6A48i3C6AA7A3C6AD2i3C6D66K3C6D89&" + + "3C71BF73C7787?3C7843?3C7895i3C7C3F#3C7D0A&3C7DB1m3C7F6Eu3C81D8\\3C8375H3C842773C846Ai3C869A?3C8A1F73C8AB0B3C8B6EG3C8B7F23C8BFE]3C8C93B3C8D20;" + + "3C90E0?3C93F4?3C94D5B3C9872_3C9BC6?3C9C0FA3C9D56?3CA10D]3CA161?3CA308m3CA37E?3CA62F$3CA6F6&3CA82A<3CA916?3CA9ABN3CA9F4A3CAB8E&3CAFB7u3CB233?" + + "3CB922?3CBBFD]3CBD3Eu3CBF60&3CBFD7&3CC03E?3CC5C7?3CCD36&3CCD40&3CCD57u3CCD5D?3CCE7323CD0F8&3CD92B<3CDC7573CDCBC]3CDD57&3CDF1E23CDFBD?3CE002m" + + "3CE064m3CE072&3CE36B43CE441%3CE4B0m3CE824?3CE86E<3CE90E73CE9F7A3CECEFg3CEF8C43CF011A3CF692?3CF75Dw3CF7A4]3CF808?3CF862A3CFA06H3CFA43?3CFA80?" + + "3CFB02&3CFDFEA3CFEAC23CFFD8?40017A2400634?4006A0m4006D52400877u400EB9?4011C3]401277H40148224014AD?40163B]40167E#40169Fi401C83A401CD4?4022D87" + + "4024D2?4025C2A402619&402641?402A8F7402BA1e402BD67402E71m403004&403059b40313Cu40331A&4035E6]4036B7B403802b403B7B?403CFC&403F8Ci4040A7e40410D?" + + "40424424044CE?4044F7N4045C4?404A03w404CCA7404D7F&404D8E?404F42?40538CD4055392405B7F/405CFD5405D82M405EF6]405FC2m4065A3\\406768?4067922406C8F&" + + "406F27?4070F5&407183B4074E0A4076A9?407911&407912m407D0F?407F5FB40831D&4086CB34089C2&4089C6%408D5C:408E2CH408EDF?408F9DB409151740921A&409595i" + + "40984Em4098AD&409BCD3409C28&409EA4B40A2DB%40A3CCA40A44A;40A654?40A677B40A6B7A40A6D9&40A6E8240A746/40A8F0<40A9CF%40ACBF>40AE30i40B034<40B076#" + + "40B0FAD40B15C?40B395&40B3FA&40B4CD%40B4F0B40B570>40B5C1240B6E7?40B70E?40B837e40B93C<40BA09540BC60&40BD32m40C3BC?40C711&40C729\\40C73CA40CBA8?" + + "40CBC0&40CE24240D133A40D160&40D28AN40D32D&40D3AE]40DA5C&40DCA5?40DE24]40DEADB40E3D6<40E64B&40EB21?40EC99A40ECBDA40ED00i40EDCF&40EE6D?40EEDD?" + + "40F078240F201\\40F3B0m40F407N40F49F240F4EC240F520740F6BC%40F946&440010&440049%44004D?44032CA4403A7244053F\\4405B8?44070B;4409C6?4409DA&440C4B?" + + "441030;441244<441524\\441622H4416FA]44179374418FD&4419B6>441A5C2441B88&441BF67441D647441EA1<44227C?44237Cu4425F4&44272E?442A60&442B03244303F?" + + "443192<44321D?443583&4438E8A443D54%443E8Am444201%4447CC>4448C1<444988A4449C0K444A37u444ADB&444C0C&444E1A]444E6D$44552B]4455B1?4455C4?44579Fu" + + "4459E3?445BED<445CE9]445E82w4463B6&4463C2w44643C244650D%446690i446747?446A2E?446B1Fm446D6C]446D7F%446EE5?447147u44746Ce447654?447831?44783E]" + + "447B307447B45%4482E5?448346m448500A44881624488BEm448A5BF448DD52448F17]4490BB&4494FCM449BC1?449E8B&449F46?449FDAb44A038?44A10E&44A191?44A3BBA" + + "44A56EM44A642>44A7F4&44A842544A8FC&44AA50B44ADB1\\44ADD9244AE25244AE44?44AF28A44B176744B32Di44B3C5?44B4A0G44B4B2%44B6BE244BB3B;44BD8D744BDC8u" + + "44BE0B?44C15Cm44C20C244C346?44C3B6?44C532?44C63C]44C65D&44C7FC?44CBADu44D3CA244D453\\44D454\\44D4E0e44D5CC%44D791?44D884&44D9E7o44DA30&44DBBE?" + + "44DF65u44E213u44E2F8b44E4D9244E517A44E59B?44E66E&44E853&44E968?44E9DD\\44EA30]44EAD8m44ECCEB44EE14m44F09E&44F21B&44F459]44F477B44F770u44FB42&" + + "480020<480031?4800B32480234?4805E2?480A28&480EECi480FCF<481258?48128F?48137E]481389G481BA42482254i4825F3?48262C&4827C5?4827E274827EA]482952\\" + + "482CA0u482CD0?482E722482F6B<482FD7?483106K483177N4831B774831DB?48352B&483584?4835AB&483871?483A028483B38&483C0C?483F72]483FDA7483FE9?4840D5A" + + "48435A?48437C&4843DD%4844F7]484520A4846FB?48474B?484982?484996?4849C7]484AE9<484BAA&484C29?484C86?484D7E5485073H485169]4851B7A4851C5A4855197" + + "485702?4857D2/485929D485A0DB485B39#485D35$485F08i485F2D%48605FD4860BC&4861EE]486264)486276?486345?48684AA48701Em48706F?487310B487410248746E&" + + "48785B>48785E%48794D]487B2FH487B6B?487D2Ei48800224882DF?4883C7\\48849Dm4886E8H488759u4889E7A488B0A2488C63?488EEF?488F5AI48902FD4890F054891D52" + + "489A58]489D317489DD1]489EBD<489ECB<48A170248A195&48A3BDm48A472A48A516?48A5E7N48A6B8d48A91C&48A98AI48AABB\\48AD08?48AD9AA48AFF3748B02DK48B25D?" + + "48B423%48B4C3<48B8A3&48BA4E<48BCE1]48BD4A?48BF6B&48C381i48C796]48CA43748CA68&48CDD3?48CFA9?48D24F\\48D539?48D6D5;48D705&48DB50?48DC2D?48DF37<" + + "48E150A48E15C&48E1CA&48E27E\\48E729748E9F1&48EA62<48EDE6w48EE0C348EF1C]48EF61?48F17FA48F1EBN48F6EE748F7BC?48F8DB?48FC07?48FD8E?48FDA3u4C00822" + + "4C01F724C0220u4C034FA4C0F3EA4C10D5i4C11AE74C11BF44C16FCB4C1744%4C17EB\\4C195D\\4C1D96A4C1F86>4C1FCC?4C20B8&4C21D0e4C2498m4C2B3B?4C2E5E]4C2EB4&" + + "4C2FD7?4C306AN4C3275&4C3488A4C3946]4C3BDFH4C3C16]4C3CE2?4C3FD3m4C421E24C445BA4C4553b4C496CA4C49E3u4C4AB4B4C4E3524C5077?4C53FD%4C5499?4C55B2u" + + "4C569D&4C5739]4C57CA&4C5BB3b4C5D3C24C5D6A&4C5E0CI4C5F70A4C60AD%4C60DEM4C617E?4C62DF>4C631B?4C6371u4C63AD?4C66A6]4C6BE8&4C6D58B4C710C24C710D2" + + "4C734FB4C74BF&4C752574C762554C776D24C77CBA4C796EA4C7975&4C79BAA4C7A8824C7C5F&4C7CD9&4C8093A4C80FB;4C820C&4C842174C858A04C889E?4C8BEF?4C8D53?" + + "4C8D79&4C8E19u4C9614B4C97A1b4C97CC&4C9EFFw4C9FF1&4CA56D]4CA64D24CA919n4CA954A4CAB4F&4CAD35&4CAE13?4CAEA3<4CB04AA4CB087?4CB16C?4CB199&4CB910&" + + "4CBB47K4CBC4824CBCA5]4CBD8F>4CC38274CC53Ew4CC5D954CC64Cu4CC95E]4CCA95?4CCBEA?4CCC6AF4CCDB6&4CCF7C<4CD012&4CD0CB?4CD0DD?4CD0F924CD1A1?4CD546<" + + "4CD587<4CD629?4CD71754CD98F54CDA38m4CDD31]4CDE48?4CE0DBu4CE17524CE17624CE20Fu4CE650&4CE65E?4CE67604CE6C0&4CEB42A4CEBB0]4CEBD674CEC0F24CECEE]" + + "4CEDFB#4CEFC0%4CF202u4CF475?4CF55B?4CF5DC>4CF95D?4CFB45?4CFBFE_5000E025000E6G50016B?5001BB]5001D9?50029175004B8?50060425006AB25006F5[5007C3%" + + "500B23?500B26?500F802500FF5k50125Ce5014C1?5017FF2501CB02501CBF2501D93?501FC6&5021EC?50236DN5023A2&50284AA502873?502B73k502DA2A502F9BA502FA82" + + "503237&50325Fb503275]50338Bm503B70&503CC4E503DA1]503DC6u503DD1i503DE52503EAAi503F50?504172?50464A?50465D#50492125049B0]504A6EM504B9E?504F3Bu" + + "5050A4]5051A9m505527D505663m5056BF]50578A&5057A8250586F?505C882505DAC?5061BF2506382?506391?50642Bu506583m5065F3<5066E5?5067AE25067F0w50680A?" + + "5068AC?506A03M506B4BG506F0C\\506F77?5071642507224m5076AFA507705]50787D75078B0?507A55&507AC5&507C6FA508114&508140<5082D5&508492A508569]5087892" + + "508811u5089D1?508A06n508A7F?508BB9n508CB1m508D62?508D9E?508E49u508F4Cu5091E3i50926Au5092B9]5093CE?509546?509839u509893m50995A%509A4C5509A88?" + + "509EA7]509F27?50A009u50A1F3?50A4C8]50A67F&50A6D8&50A72B?50ACB9?50B03Be50B127&50B7C3]50BA84250BC96&50BD5Fi50C4DD050C58DB50C709B50C7BFi50C8E5]" + + "50D2F5u50D45C%50D4F7i50DAD6u50DCE7%50DDAB]50DE06&50E039w50E085A50E467Z50E4E0<50E538>50E549:50E636$50EAD6&50EB71A50EBF6#50EC50u50ED3C&50EEB5m" + + "50F0D3]50F14Am50F265&50F351&50F4EB&50F520]50F5DA%50F722250F7ED?50F958?50FA84i50FC9F]50FE39u525400T540295?5404A6#540764?54077DM540910&540DF9?" + + "540F57b54102E?54104F]5412CB?541310?5414F3A5416A57541E56B54211D?54219D]542259?542369?5425EA?542618?54263De542696&542906&542A1Bd542A43&542B1C%" + + "542B8D&542F2B?54320475432C7&5433CB&5434EF?543631A5439DF?543AD6]543CED\\5440AD]544249e5443B2754443B?5444A3]544538m5447CC\\54481055448E6u544A002" + + "544A16m544B8CB544C8AH544E90&544EF0[54511B?5451DE2545284?5453EDe5455D5?545618?545925?545AA67546009;54606D?5462E2&5464D9\\546749;546990?546C0Em" + + "546CEBA5471DD?54724F&54735A?547595i5475D0254778A<54781A2547C692547DCDm547FEE2548028<54833Aw5486BC25488DE2548998?548ABA2548C81>548D5AA549209?" + + "5492BE]549963&549B12]549B24G549DEA7549F13&549F355549FC6254A050#54A274254A51B?54A637?54A6DB?54A703i54AE27&54AF97i54B121?54B27E\\54B802]54B80A3" + + "54B8DB&54BAD6?54BD79]54BF55B54BF64554C415>54C480?54C80Fi54CF8D?54D17D]54D299i54D7E3<54D9C6?54DCE9b54DD21?54DD4F]54E019Z54E032B54E15B?54E43A&" + + "54E4EDA54E61B&54E6FCi54E6FDe54EAA8&54EBE9&54ECB0_54EF43?54EF44'54F0B1<54F201]54F283b54F294?54F607?54F6E2?54FA3E]54FB66\"54FCF0]54FEEBm5800BBB" + + "5803FB>58044Fi580987%580A202580AD4&580E85\\580EE6G580FA5&581122#58170Ce581862e581CF8A581DD8\\581F28?581FAA&582059u582071]5820B1<582429;582575?" + + "58263Ab58278C0582A93&582ABD7582AF7?582B0Am582BD37582F40N582FF7\\58355D?5835D92583653&583BC2b583F54D58404E&584120i584498u584822e5850ED>5851A3&" + + "585595&5855CA&58569F25856AA&5856C2?58605F?5864C4&58666D&58687A\\586B14&586C25A586D0C<586D67A5873D1?5873D8&587961H5879E0]587A62m587F57&587F66?" + + "588336?588670B58879F?588A5A5588B1C2588BF3w588C817588CCFb588D092588E81b589043\\5891CFA589351?5893D8m5893E8&58946BA5894AE?58957E?58960AD58961DA" + + "58971E25897BD2589A3E%58A023A58A15Fm58A2B5D58A2E1G58A639]58A839A58A8E8%58ABFB?58AC78258AD12&58AE2B?58AEA8?58B035&58B03EN58B10F]58B18F?58B623u" + + "58B965&58BAD4?58BC27258BDA3N58BE72?58BF25758BFEA258C38B]58C5CB]58CB52;58CE2AA58CF79758D061?58D15Am58D349&58D56E358D61Fo58D759?58D812i58D9D5k" + + "58DF59258E28F&58E434B58E488%58E6BA&58E6C5758EA1Fu58F2FC?58F39C258F8D7?58F987?58FB3E?58FB84A58FDB1D5C013B75C0214u5C0272b5C0339?5C07A6?5C0947&" + + "5C0979?5C0B3B?5C0CE6N5C10C5]5C13AC&5C13CC&5C167D?5C1720?5C1BF4&5C1DD9&5C23C2&5C2573G5C260A55C2E59]5C313Em5C319225C337B;5C345B>5C3977B5C3AA2b" + + "5C3C27]5C3E0625C3E1B&5C4071u5C4527B5C475EZ5C4879?5C4979$5C497D]5C4CA9?5C501525C50D9&5C5136]5C514FA5C5181]5C521EN5C5230&5C5284&5C546D?5C5948&" + + "5C5AC725C5E0A]5C5EABB5C5EBB?5C5F67A5C60BA<5C6117?5C628Bi5C63B085C63BFi5C647A?5C648Ew5C64F125C6783A5C6A80w5C6B32m5C6F69/5C7017&5C7075?5C70A3D" + + "5C710D25C78F8?5C7D5E?5C80B6A5C838F25C843Ce5C8505?5C865C]5C8730&5C879CA5C899Ai5C89BC&5C8A38<5C8B6B%5C8D4E&5C9157?5C9175&5C95AE&5C9666e5C969D&" + + "5C97F3&5C9960]5C9977&5C9AA1?5C9BA6&5CA2A2m5CA47D<5CA48A25CA62D25CA64Fi5CA6E6i5CA86A?5CAAFDd5CAC3D]5CADBA&5CADCF&5CAF06D5CB00A?5CB12E25CB13E\\" + + "5CB26DA5CB395?5CB43E?5CB47EA5CB524e5CB8B7&5CB901<5CBA2C<5CBA37H5CBD9A?5CC0A0?5CC1D7]5CC1F2?5CC307?5CC5D4A5CC787?5CC7C1b5CCB99]5CCD5BA5CCF7F7" + + "5CD06Eu5CD2E4A5CD33D]5CD89E?5CD99835CDC49]5CE0C5A5CE17625CE28Cw5CE42AA5CE50Cu5CE747?5CE883?5CE8EB]5CE91E&5CE931i5CED8C<5CEDF4]5CF4ABw5CF51A4" + + "5CF5DA&5CF6DC]5CF7E6&5CF821m5CF938&5CF96A?5CF9DD55CFA25\\5CFC66260019476001B1?600308&6006E3&600810?600F6B&60109E?60123C?60156Fi60183A?6018955" + + "601AC7N601F56?602232o6025ED<602602m6026AA26026EF<60292Bi602E20?602ED5&6030B3?6030D4&603197w6032B1i60334B&6036DDA603A7Ci603AAF]603CEED603D29?" + + "603E5F&60452EA6045CB#6045CD\\6046D4m604DE1?604F5B?605355?605375?6055F976056B1?605718A6057C8&605B305605E4F?605E65G605FAA?60629AB60634C3606405m" + + "606525&606720A60684E]606944&606BBD]606BFFN606C66A606EE8u60706C;6070C0&60735C260756CD607771m6077E2]6079C9?607EC9&607ECD?607FCB]608110&608246&" + + "608306?608334?608373&6083DAb6083E7i6084BD0608B0E&608C4A&608E08]608F5C]609217&6092C8[609316&609578]6095BD&6096A4?609866m609AC1&609BB4?60A10A]" + + "60A2C6?60A37D&60A3E3i60A423b60A44C#60A4B7i60A4D0]60A5E2A60A6C5?60A751?60A954260AAEF?60AB67u60AF6D]60B0E8?60B4A2]60B58D$60B647b60B6E1m60B763b" + + "60B76E;60B9C0260BBEB?60BD83?60BEC4&60C547&60C5AD]60C78DB60CA3A&60CE41?60CE86_60CF84#60D039&60D0A9]60D178260D1D8b60D755?60D9A0E60D9C7&60DD70&" + + "60DD8EA60DE18&60DE44?60DE94?60DEF3?60E327i60E32BA60E3ACD60E701?60E85Bm60EFABb60F18A?60F262A60F445&60F549&60F620d60F677A60F723u60F81D&60FA9D?" + + "60FACD&60FB42&60FDA6&60FEC5&60FF12]64006A56400F1264028Fb64037F]64078C?6407F6]6408642640980u640BD7&640C91&640D22D64122526413AB?64168D26416F0?" + + "6417CD]6418DF\\641B2F]641C10m641CAE]641CB0]64200C&642315?642753?64294336429FF?642CAC?642E41?642F1C?643135&643136G643150<6432A8A6433AAG6433DBm" + + "643AEA2643E0A?643E8C?6441E6&6442C2G644842&64497DA644A7DA644C36A644ED7<645106<6451F4?6453E0?645601i6456B5]6457BAA645A36&645AED&645D86A645DF4]" + + "645E10?646140?646306u64644Au64649BB646624\\6466B3i6466D8]6467CD?64694Em646CB2]646D2F&646D4E?646D6C?646E97i646EE0A647002i647033&647060m6476BA&" + + "647791]647924?647999\\6479F0A647B1E\\647BCE]647BD4m648099A64876C?648788B648914764899AD6489F1]648CBBm648F3E26490C1u64956CD649A63Z649ABE&649B8Fm" + + "649C8Em649D38;649E31u649EF1m649EF3264A0E7264A198?64A200u64A28A?64A3CB&64A5C3&64A651?64A963?64AC2BB64ACE0]64AE0C264B0A6&64B0E8?64B310]64B473u" + + "64B5C6N64B5F2]64B708764B853]64B9E8&64BC0CD64BC43?64BC58A64BD6D&64BF6B?64C045&64C2DED64C394?64C3D6B64C753&64C905&64C9F1%64CC2Eu64CDC2%64CFD9m" + + "64D0D6]64D154I64D2C4&64D4DAA64D562?64D69AA64D7C0?64D814264D989264DB8B>64DD68w64DDE9u64DE6DA64E4A5D64E682&64E7D8]64E833764E881<64E950264F69D2" + + "64F705?64F81C?64FA2B\\64FD29464FD96\\680125%6804892680571]6805CAA680715A680927&6809477680AE2b681324?6813F3%681590\\681729A681A47&681BEF?681C522" + + "68228EB6822E5?6823B0m6825DD7682737]68286Ce6828CF<682C7B2682E3Co682F67&683036&683045?683421A6837E9%683B09&683B782683E26A683EC0&684406m684465&" + + "684571?6845CC&684749m6847C5A684898]684983?684A5F&684AAE?684AE9]684C25?684DB6u684F645685134<68545AA6854FD%68572Dn685ACF]685B35&685D43A685E1Cm" + + "685EDD&686372?68644B&68672576867C7\\686CE6H686DBC>68709Eb6871612687251o6872C3]68764Fe687724i6879092687A64A687BDCB687D6B]687DAC]687DB42687FF0i" + + "6881E0?6883CB&6886A726887C626889C1?688F84?68951B?68962E?68967B&6899CD2689A87%689B43?689C70&689CE22689DD27689E0B2689E19m689E6A?689FD4%68A03E?" + + "68A0F6?68A46A?68A593&68A729&68A828?68A86D&68AB1E&68ABA9\\68ABBCu68AE20&68B599<68B5E3?68B691%68B6B3768B8BBu68BC0C268BDAB268BFC4]68C44Cu68C63A7" + + "68C6ACA68C90Bm68CAC4&68CAE4268CC6E?68CCAE868D79Ao68D927?68D93C&68D972268DBCA&68DBF5%68DDB7i68DFDDu68DFE4]68E1DC068E209?68E580&68E59E268E74Am" + + "68E7C2]68EBAE]68EC8A@68ECC5A68ED57B68EE8F768EF43&68EFBD268EFDC&68F38EB68F543?68F63B%68F7D8H68F90FA68FB7E&68FCCA]68FE71768FEF7&68FF7Bi6C006B]" + + "6C028C?6C02E0<6C030926C03B526C047A?6C0699u6C06D6?6C0B5E<6C0C9A%6C0DC4u6C0E0De6C1270&6C13D526C146E?6C1544H6C1632?6C198F36C19C0&6C1A75?6C1AEAm" + + "6C1C7146C1D2C?6C1F8A&6C205626C23B9e6C2636?6C2995A6C29D226C2B5956C2E85\\6C2F2C]6C2F80A6C2F8A]6C302Am6C310E26C3491?6C3AFF&6C3B6BI6C3BE5<6C3C8C5" + + "6C3DD876C3E6D&6C4008&6C410E26C416A26C41DE?6C442A?6C483Fu6C4A85&6C4CBCi6C4CE2A6C4D73&6C4EF626C4F89w6C4FA126C504D26C51BF?6C51E4?6C5563]6C558D?" + + "6C55B1%6C5697%6C5AB0i6C5CB1b6C5D3AH6C5E3B26C5F1CE6C60D0?6C626DF6C62FEB6C63F8o6C67EF?6C688A%6C6A77A6C6C0F?6C6CD326C709F&6C70CB]6C710D26C71D2?" + + "6C722036C72E7&6C7637?6C77F0?6C78C1B6C79B8m6C7E67&6C7F49?6C8243?6C8336]6C8375/6C8814A6C8BD326C8D7726C8DC1&6C92CF/6C9313G6C9466A6C94F8&6C96CF&" + + "6C9961\\6C998926C999D%6C9CED26CA042b6CA100A6CAB0526CAB31&6CACC2]6CB0CEM6CB133&6CB158i6CB227e6CB2AE26CB2FDm6CB45676CB4FD?6CB749?6CB7E2?6CB7F4]" + + "6CBAB8\\6CC217<6CC26B&6CC374m6CC49F<6CC84076CCDD6M6CD032D6CD1E5?6CD63F?6CD68AD6CD6E326CD704?6CDD3026CDDBC]6CE21076CE4A4b6CE5C9&6CE85C&6CE873i" + + "6CE874?6CEBB6?6CECEBm6CF049:6CF373]6CF37F<6CF6DAA6CF784u6CFA8926CFD22b6CFE54A6CFFCE\\7001B5270039F770041D7700514D700810A700971]700B01\\700B4F2" + + "700F6A270105C270106F<701124&701384&7014A6&7015FBA7018A7270192F?701AB8A701CE7A701F3C]701F53270217Fu7022FE&70287D;70288B]702AD5]702C09N702F35?" + + "70317F&703217A7035092703A0E<703A51u703ACB;703C69&703EAC&7040FF?704698?70480F&7048F7N7049A2w704BCA7704CA2&704CA58704D7B#704E6B?704EE0]704F57i" + + "705464b7054F5?705681&705A0F<705AAC]705FA3u70617B27062B837062CB&70662Ae7066B9?70695A2706BB92706D152706E10?706E6D270700D&707013?70708B27070AA%" + + "7070D5?70720DE70723C?7072FE&707362?7073CB&70792DG707990?7079B32707BE8?707CE3?707DA1\\707DB9270810527081EB&7085C2\"7086C1m708976n708A09?708BCD#" + + "708CB6?708CF2&7090B7?709684&709751u709AC4?709C45?709CD1A709E29e70A04BA70A2B3&70A6CCA70A741o70A8A5H70A8D3A70A8E3?70A983270AC08b70AE2A&70AED5&" + + "70AF09770B13D]70B306&70B317270B51A?70B5E8570B7E4/70B8F6770B950m70BB5B&70BBE9u70BC10H70BC48270BD96270C288A70C59Cb70C7F2?70C9C6270CA9B270CD0DA" + + "70CD60&70CE8C]70CF49A70D07Eb70D313?70D379270D823A70D8C2A70DA48270DB98270DDEF?70DEE2&70DF2F270E422270E56Em70E72C&70E997?70EA1A270EA5A&70EBA5?" + + "70ECE4&70EF00&70F087&70F088N70F096270F35A270F8AEH70F927]70F94A&70FD45?70FD46]70FF76m7400E8?7402E1m7403BD074042BE7404F1A7405A5i740AE1?740B12b" + + "740C2E2740CEE?740EA4&7410E0/7411B227413EAA7414D0&741575u7415F5&74190A]741BB2&741EB1]7422BB?742344u742435?742554K7426AC274272C!742869?7428AEG" + + "742917&742959&742972B742981m743174&74342B?743675B743822u743989i743AF4A743C24?743CDE<743F8E&743FC2>744218&74427F$74428B&744401M74452D?74458A]" + + "7446A0<7446B3m744D28I744D6D?744DBD77450CD?7451BAu74563C:7458F3%745909?745AAA?7460FA?7463C2?74650C&746A84m746DFA]747069?7470FDA74718B&7473B4&" + + "747446;747548%74761FH747786&74782757478A68748114&7483C2o748469N74860B274867A57486E2574872E?74882A?7488BB27489277748B23]748D08&748DAA?748F3C&" + + "748FC227498F40749B89?749D79_749D8F?749E75<749EAF&749EF5]74A02F274A063?74A2E6274A528?74A58Cm74A6CD&74A722D74A7EA%74A981?74ACB9o74AD98274B587&" + + "74B725?74B839m74B8A8?74BC6BA74C14F?74C17ED74C246%74C412H74C929474CA60d74CC40&74D02B#74D21D?74D285m74D423%74D435:74D5E8&74D637%74D6E5?74D6EAm" + + "74D809H74D83EA74DA38674DA78<74DA88i74DADA374DAEAm74E182m74E1B6&74E20C%74E28CH74E2E7274E2F5&74E50BA74E5F9A74E6B8D74E6E2574E798B74E987&74E9BF?" + + "74E9D8774EA3Ai74EB80]74ECB2%74F2FAu74F441]74F67A]74F90F?74F92Co74F9CAN74FA29o74FC77?74FECEi78009E]78028B&7802B127802F8u780473m7804E3?7806C9?" + + "78078F?78084D?780CB8A780CF02780E3E?78119D27811DCu781699?7817BE?7818A8?7818EC87819F7B781A32?781C3C7781C9Db781DBA?781FDB]782051i7820A5N7821847" + + "782327]7824AF#7824BE2782599?7825AD]7828CAd782B46A782B60?782BCB5782DAD?7831C1&78321B37833C6]783409?7834B4?783716]783A84&783F4D&7840E4]78421C7" + + "7844FDi784558o7845B3?7845C457845DC?7846D4]78471D]784859<7849D7]784F43&784F9BB78507CB78521A]785333u78542E3785536.785773?785860?78595E]785B64?" + + "785C5E?785DC8D785ECC&78605Bi786089]786256?7864A027864C0&786559\\7867D7&786A89?786C1C&786C84%78725D2787462A78753E&787826?787871m787984&787B8A&" + + "787E61&788102_78818CN788371?78843Ce78851727885F4?78862EH78886D&788A20o788CB5i788DAF\\78929CA7894B4_78960D&78966E?7898E83789987u789A18I789ED0]" + + "789F70&789FAA?78A03F%78A106i78A2A0N78A3E4&78A504m78A7C7&78A873]78ABBB]78AC44578ACC0<78AF08A78B46A?78B554?78B5F2?78B6FE]78BAF9278BC1A278BDBC]" + + "78C05Ai78C11D]78C213\\78C3E9]78C57Dw78C5E5m78C5F8?78C881e78C884?78CA39&78CD55m78CF2F?78CFF9?78D162&78D294M78D752?78D75F&78D840u78DA6E278DAAF?" + + "78DB2Fm78DC87?78DD33?78DEE4m78E0C5]78E103%78E22C?78E36D778E3B5<78E3DE&78E7D1<78EB46?78EE4C778F09B?78F1C6278F238]78F557?78F5FD?78F7BE]78F882D" + + "78FBD8&78FD94&78FE3DB78FF57A7C004D?7C010Am7C0191&7C034C\\7C035Eu7C03ABu7C03D8\\7C04D0&7C0A3F]7C0BC6]7C0C5F77C0CF1u7C0CFA?7C0ECE27C10C9#7C11BE&" + + "7C11CB?7C162A?7C1689\\7C1960?7C19E3;7C1AC0?7C1B93?7C1C68]7C1CF1?7C1DD9u7C210D27C210E27C214AA7C2302]7C2499&7C2586B7C25A387C2664\\7C296F&7C2A31A" + + "7C2ACA&7C2ADBu7C2C6777C2EBD;7C2EDD]7C310E27C31FAb7C33F9?7C3626?7C3866m7C38AD]7C3985?7C3B2D&7C3D2B?7C3E74?7C49EBu7C4B26&7C4D8F<7C4FAD77C4FCD&" + + "7C5049&7C5079A7C573C<7C5758<7C59B1&7C5A1Cf7C5CF8A7C6097?7C6130&7C6166%7C62E727C6305%7C6456]7C646CD7C669A?7C669Dm7C67A2A7C67AB[7C68B9?7C692B?" + + "7C69F627C6D12H7C6D62&7C6DF8&7C70DBA7C72E7m7C739877C73EB?7C752D]7C7635A7C7668?7C7716w7C787E]7C78B2s7C7A91A7C7BBF]7C7D3D?7C852F?7C876727C87CE7" + + "7C8931?7C8956]7C8BB5]7C8BCAi7C8C09G7C8EE4m7C9122]7C942A?7C94B2Q7C95F327C97E1?7C9824\\7C9A1D&7C9EBD77CA177?7CA1AE&7CA23E?7CA449u7CA62A<7CA8EC<" + + "7CAB60&7CAD4F27CAD7427CB0C2A7CB15D?7CB27DA7CB35327CB566A7CB59Bi7CB59F?7CBB8AN7CC06F&7CC0AAH7CC180&7CC225]7CC255g7CC294u7CC2C6i7CC385?7CC3A1&" + + "7CC537&7CC6B6b7CC882?7CC8DF&7CC95A57CCCB8A7CD1AD&7CD1C3&7CD2DA&7CD3E5?7CD4A8\\7CD54477CD566%7CD62C&7CD661u7CD95C;7CD9A0?7CDC73?7CDFA177CE269m" + + "7CE2CAB7CE53F?7CE87F\\7CE8B177CEC79m7CECB1&7CEDC6%7CF05F&7CF17Ei7CF31BD7CF34D&7CF666n7CF854]7CF88027CF90E]7CFADF&7CFC16&7CFD6Bu7CFE90G7CFF4D$" + + "80000BA80006E&80045F&800518?800794]800C67&800CF9%800D3F]801242&801316A801382?8013BEB80184458018A7]801934A801970]801D39&801F0268020DA\\8020FD]" + + "802395$80248F2802689380276C2802AA8o802DBF2802EC3?802EDE?8030DCm8030E0<8031F0]803253A8035C1u803773M8038BC?8038FBA80398C]803C04i803C20?803FD4&" + + "804126?80433FB80456B78045DDA804715&804786]80482Cs80489F>804971&804A14&804AF2d804B50b804DCB?804E70]804E81]8053E0780542D]80549C]8054D9?8054E3&" + + "805719]805A04D805A708805FC5&806036?806132280646F780647Cn80656D]80657C&8065997806933?806A002806D71%806F1C?806FB0m80711FB80717A?807264?8075BF]" + + "807B3E]807C62>807D14?807D3A7807DF9G807FF8B80802C8808223&8083F6&808489A8086D9]8086F2A808917i808943]808ABD]808DB7<808F1Di808F97u80929F&80953A&" + + "809621E809698&8099CFm8099E7e809B20A809FF5]80A63C%80A997&80ACACB80AD16u80AE54i80AF19&80B03D&80B54E780B575?80B655A80B686?80B989&80BE05&80BEAF>" + + "80C01EA80C16E<80C41Bm80C5E6H80CC12?80CC9CM80CE62<80CEB9]80CF41E80CFA2?80D09B?80D1CE&80D2E5N80D4A5?80D605&80DACE?80DB17B80E01D280E1BF?80E4BAA" + + "80E63Cu80E650&80E82C<80E86F280EA07i80EA0Bw80EA96&80ED2C&80F1A4?80F1B2780F3DA780F5AE>80F5B5m80FB06?8400D2e8400EC`840328B840511&84083AA840C6D2" + + "840D8E7840F4C&84119E]84144DA8415D3?84160C/8416F9i841888B841985]841B5EM841B77A841EA3\\841FE878421F1?842289]842519]8425DB]842712b842789&842859%" + + "842999&842AFD<842B2B5842E14b842E27]842F57&843497<8437D5]843835&84398F8843A4BA843CFC?843DC62843E03\\843E92?844167&844693u8446FE?844765?844880%" + + "845075?845181]845234B8454DF?8455A5]845733H845A3E2845B12?845C315845CF3A845F04]8463D6H8464DD?84683EA846878&846993<847127b84716A?847293m847637?" + + "847848o84788B&8478AC2847B57A847BEB5847CEE2847D7E2847E40m84802D2848506&8488E1&8489AD&848A8D2848C8D&848E0C&848EDFe848F695849265A8492E5?8493A0?" + + "849437&849459>849866]8498A7m849A40>849FB5?84A06E\\84A134&84A1D1\\84A423\\84A466]84A6C8A84A824;84A8E4?84A93E<84A9C4?84AB1A&84AC16&84AD58?84AD8D&" + + "84AEDEu84AFEC084B153&84B1E2H84B1E4&84B261284B4DBb84B517284B541]84B59CB84B802284B890i84BA20b84BB26m84BE52?84C065N84C0EF]84C1C1B84C5A6A84C692m" + + "84C7BB784C7EAe84C9B2384CC63?84CCA8784D1C1A84D328&84D3D5?84D47E<84D6D0%84D7DE?84D81Bi84DBA4?84DBAC?84DD20m84DD84284E342n84E657e84E8CB084E986?" + + "84EAED[84EB0CG84EB18m84EBEF284EE7F?84EEE4]84EF18A84F147284F3EB784F703784FCAC&84FCE6784FCFE&84FD27b84FDD1A84FE40?84FFC228801F9m88074BD880AA3B" + + "880CE0m880F62b880FA2\\881032&88108F?881196?8813BF7881566?8815C5?8817A8&881908&881A14b881DFC2881E5A&881FA1&88200D&88225B<882510<882593i8828B3?" + + "8828FBB88299C]8829BF%882C31&882F92u883037B883314m88365FD8836CF?883A30<883D24;883F27?883F4Am883FD3?884033?88403B?8843E12884477?884604u884AEAm" + + "884D7C&884F5928851F2&8851FB<8852EBu88532EA885395&8853D4?88541F;88546Bm8856A6788572178857EE0885A922885E54]8863C5?8863DF&886440&886639?88665A&" + + "8866A5&8867DC?88693D?886B6E&886BDB&886C60u886D2D?886EEB?886FD45887015&88708CE8871E5%887477?8875562887598]8876B93887873A887ABC2887E9B&8881B9?" + + "888322]888603?88892F?888E68?888FA4?889009B88908D2889986i889B39]889CAD2889F6F]88A0BE?88A25EB88A29EW88A2D7?88A303]88A479&88A6C6\\88A9B7&88ACC0w" + + "88ADD2]88AE07&88B111A88B291&88B4BE?88B7EB&88B945&88B951u88BA74b88BCC1?88BD45]88BFE4?88C08B&88C227?88C22D088C255m88C344;88C397u88C663&88C6E8?" + + "88C9D0D88C9E8e88CB87&88CE3F?88CEFA?88CF98?88CFCDm88D546&88D7F6#88D82EA88D98FB88DA04?88DDB8?88DE39>88DEA9[88E056?88E0F3B88E3AB?88E64BB88E87F&" + + "88E9A4<88E9FE&88F031288F077288F155788F2CE&88F3D5w88F4DAA88F56E?88F6DC?88F872?88FC5D28C006D&8C04BA58C0572?8C0879m8C08AA&8C0D76?8C0FC9?8C10D4\\" + + "8C142A28C15C7?8C1759A8C17B6?8C1952%8C1ABF]8C1D96A8C1E8028C210Ai8C2114?8C22D2>8C2505?8C26AA&8C2937&8C2A85%8C2DAA&8C2E72]8C3066o8C3396&8C3446?" + + "8C34FD?8C3AE3D8C3BADM8C426D?8C44A528C459878C47BE58C4962[8C4B1478C4EBB%8C4F0078C53C3u8C554AA8C5646D8C56C5N8C5877&8C5973w8C5AC1?8C5AF8u8C5EBD?" + + "8C604F28C6422e8C65A3b8C683A?8C6A3B]8C6BDB?8C6D77?8C6FB9b8C705AA8C71F8]8C73DAb8C7712]8C7909<8C79F5]8C7A3Du8C7AAA&8C7B9D&8C7C92&8C8283&8C83E1]" + + "8C83E8?8C844228C8474/8C8590&8C85C1<8C861E&8C862A?8C86DDi8C89A5F8C8ACD?8C8B48b8C8B83m8C8C2978C8D28A8C8EF2&8C8FE9&8C902Di8C913AG8C91A4&8C941F2" + + "8C946128C94DF78C986B&8C9885]8C9A8F\\8CA2F5?8CA3EC]8CA5CF?8CA6DFi8CA96D?8CA982A8CAAB578CAACEu8CB0E9]8CB50E28CB64F28CB87EA8CBEBEu8CBFA6]8CBFEA7" + + "8CC246&8CC5B4\\8CC5D0]8CC681A8CC8CD]8CC9E9?8CCDE8N8CCE4E78CCF0958CD066m8CD0B2u8CD9D6u8CDCD4<8CDEE6]8CDEF9u8CE5C0]8CE5EF?8CE748>8CE9B448CE9EEA" + + "8CE9FF58CEA48]8CEBC6?8CEC4B58CEC7B&8CEDE1o8CF67DM8CF681b8CF8C5A8CFABA&8CFADD?8CFD18?8CFD4978CFDDE\\8CFE57&9000DB]900117?90013B\\9002A94900325?" + + "900628]9006DB?9006F2m9009D0h9009DFA900A48]900A84G900CC8;901057A901195%90150679016BA?90173F?9017AC?9017C8?901F09b901F94i9020C2<9020D7H90235B%" + + "9025F2?9027E4&902AEEu902B34:902BD2?902C09&902E1CA9035A2&9035EAb90380C790395Eb90395F%903C92&903FC3?903FEA?9041B2o904528N904748e9047C2A904846m" + + "90486CZ9049FAA904C81<904CC5&904D4A\\904DE2&904E2B?9051F8<90567129059AFm905A08g905E44?905F7A&9060F1&9061AEA90623F&90633B]90649B79064AD?906584A" + + "90671C?906989]906AEBH906CAC8907065m9070697907240&907282\\9074F2?9077EE2907841A9078B2u907BC6m90808F?90812A&908158&908175]90840D&9088552908A802" + + "908C43&908D6C&908D6E5908D783909497?9094E43909507?9096F309097D579097F3]909838?909A4Ai909A77m909B6F&909C4A&909F22w90A25B&90A57D?90A5AF?90A822%" + + "90AB96b90AC6D&90AE1Bi90B021A90B0ED&90B11C590B144]90B176A90B21F&90B339790B622]90B790&90B931&90BA09?90C115e90C1C6&90C97E?90CAFA;90CC7A?90CCDFA" + + "90CDE8&90CEB8m90D733?90D7EBm90DA72790DD5D&90E17B&90E202m90E2BAA90E317G90E5B1790E6BA#90E95E290EB50290ECEA&90EEC7]90EF68w90F1AA]90F644?90F652i" + + "90F80C]90F82E%90F970?90F9B7?90FB5Du90FD61&90FD9Fb9400B0?9401C2]94049C?9408C7?940B19?940BCD&940C6Di940C98&940D4B2940E5F&940E6B?940EE7?94105A5" + + "9415B2?941625&941700u941865M941882<941DD4;942157&942453?942533?94261D?94270EA942A6Fo942AD6m942B68&942DDC]9433D82943469b94350A]943589?9437F7?" + + "94390EA943A91%943B22M943C96\\943CC67943FC2<943FD6&9440C9<9440F3?944560;9446672944788?9449F6?944A0C_945044m945103]9451DC7945244]9453FFA9454C57" + + "9457A5<9458CBN945915%945AEA?945AFC%945C9A&946010?9460D5<94626Du9463D1]946424<94659CA946DAEG9476B7]94772B?947AF4?947BAEu947BE7]947D77?9487E0u" + + "948854m948978&948B93u948BC1]948E6DN949010?949426&9495A0;94988F\\949AA9H949CBE?949F3Ed94A07D?94A081b94A25D?94A4F9?94A67EM94A990794A9A8m94AD05?" + + "94AD23&94AEF0294B01F&94B10A]94B216b94B271?94B40F<94B43A]94B555794B609A94B86DA94B97E794BF2D&94BF94B94CE0F?94CE2Ce94CFB0?94D00D?94D2BC?94D331u" + + "94D469294D54D?94D771]94D9B3i94DB56e94DBDA?94DE80:94DEB8b94DF34?94E129]94E1AC>94E23CA94E300?94E36Dm94E4BA?94E686794E6BA]94E6F7A94E70BA94E7EA?" + + "94E7F3?94E96A&94E9EE?94EA32&94EB2C;94EC32b94EF50i94F128<94F392894F6A3&94F6D6&94F7ADB94FE22?94FEF4\\94FF06<94FF3C89800C6&9801A7&98038Am98038Ei" + + "98039BG9803D8&98045F?98063C]980709?98072Dm98076A&980C33b980D51?980D67w980D6F]980DAF&9810E8&9812E0u98171Au981A35?981CA2&981DFA]981E19\\98226E%" + + "98247B?98254Ai982AC3b982AFD?982CBCA982D68]982FF8?983268b9835ED?98398E]983A1F;983B8FA983C8C&983DAE7983F60?983FE8]9840BB598415CN984265\\9842F5&" + + "9843FAA9844CE?98460A&984827i984874?984925B984B06?984BE1<984E8A]984FEEA98502E&9851FC?985207?9852B1]98535F?98541BA985945m98597AA985A98?985AEB&" + + "985DADm985F41A985FD3H9860CA&986110?98698A&98751A?987552u987A14H987BF3m987EB5?9880EE]98818A?988389]9884E3m98868BB98876C?98886C?9888E07988924m" + + "988B0A>988B5D\\988D46A988F00<98909659897CCi9898FB;989BCB$989C57?989DE5>989E63&989F1E?98A2C0298A316798A375?98A5F9&98A965$98AD1D?98AF65A98B08B]" + + "98B379&98B3EF?98B6E9N98B8BAD98B8BC]98B8E3&98BA5Fi98BD80A98C08A?98C377798CA33&98CAB5&98CCF3%98CDAC798CF7D&98D293;98D3D7?98D6BB&98D6F7D98D742]" + + "98D7E1298D863798DAC4i98DD60&98DED0i98DF82>98E081u98E0D9&98E255N98E743598E7F4<98E7F5?98E859&98E8FAN98EE94u98F04C298F07Bm98F083?98F0AB&98F112>" + + "98F2B3<98F3F6?98F487m98F4AB798F621u98F9CC498FA2Ee98FAE3u98FB27]98FE3EA98FE54W98FE94&98FEE1&98FFD0E9C0298]9C0351?9C04EB&9C0591G9C05D6o9C098B2" + + "9C09CA?9C139E79C146349C1A25&9C1C12<9C1D36?9C1D58m9C207B&9C216Ai9C2183/9C2472\\9C2595]9C28B3&9C28EF?9C28F7u9C293F&9C2976A9C2A83]9C2E7A]9C2EA1u" + + "9C35EB&9C3708<9C37CBe9C37F4?9C381829C3928]9C3AAF]9C3DCFM9C3E53&9C4782i9C4929?9C4DC229C4E2029C4E36A9C4F5F;9C4FDA&9C52F8?9C5322i9C541629C5636?" + + "9C5766?9C57AD29C583C&9C5884&9C5A80B9C5A81u9C5C8E#9C5CF9e9C5FB0]9C6076&9C61D7?9C63C0G9C648B&9C64A1b9C65B0]9C65EBA9C669729C67D6A9C69D1?9C6B00\"" + + "9C6C15H9C713A?9C7370?9C73B1]9C741A?9C746F?9C760E&9C7613Z9C79E3&9C7BEF<9C7DA3?9C823F?9C8306]9C84BF&9C8ACBB9C8BA0&9C8C6E]9C8CD8<9C8E99<9C8E9C?" + + "9C924F&9C9567?9C9613E9C96D579C971BA9C9774?9C9793?9C99A0u9C9C1F79C9D7Eu9C9E6E79C9E71?9C9ED5u9CA118?9CA2F4i9CA513]9CA615i9CA9B829CA9C5&9CAA1BH" + + "9CAFCA29CB150A9CB2B2?9CB2E8?9CB654<9CBCA6<9CBCF0u9CBFCD?9CC172?9CC394&9CC7A6$9CC893B9CC8E9%9CC9EBM9CCC0179CCC83B9CD35B]9CD36DM9CD57D29CD6433" + + "9CDA3EA9CDAA8&9CDAB7<9CDBAF?9CDC71<9CDF8A?9CE063]9CE17629CE33F&9CE374?9CE635N9CE65E&9CE6E7]9CEC61?9CF1D4[9CF27E]9CF387&9CF3AC&9CF48E&9CF9A1B" + + "9CFA76&9CFC01&9CFC28&9CFCE8AA002A5AA002DC%A00460MA00798]A0086F?A00A9A?A00E98?A00F372A01081]A01828&A01B29\\A01B9E]A01C8D?A01D48" + + "A4004E2A400E2?A402B7%A402B9AA406E9mA407B6]A40801%A408F5\\A40987&A409B3?A40CC32A40E75A416C0&A416E7?A4178B?" + + "A418752A41A3AiA41F725A42249\\A426CAAA42902>A42A953A42B8CMA42BB0iA4307A]A43135&A434D9AA434F1mA4373E?A43828?A438CCNA439B3uA43B0E?A43FA7A44C112A44CC85A44E31AA45046uA4515EBA4530E2A45590uA456302A459BFbA45C25mA45C27NA45D36A4A46B?A4A490]A4A5842A4A64EGA4A930uA4AAFE?" + + "A4AC0F?A4B0F5mA4B197&A4B1C1AA4B2392A4B4392A4B61E?A4B805&A4BA70uA4BA76?A4BADB5A4BB6D5A4BDC4?A4BE2B?A4BF01AA4C0E1NA4C1E8NA4C337&A4C34EmA4C361&" + + "A4C3BEuA4C3F0AA4C494AA4C54E?A4C64F?A4C69A]A4C6F0&A4C74B?A4C788uA4CAA0?A4CB8F7A4CC37bA4CCB3uA4CF127A4CF99&A4D18C&A4D1D2&A4D23E&A4D578mA4D5C2>" + + "A4D931&A4D990]A4DA32mA4DCBE?A4DCD52A4DD58?A4E11ABA4E287uA4E57C7A4E975&A4EBD3]A4EF0CGA4F00F7A4F1E8&A4F6E6&A4F6E8&A4F841&A4F8FFoA4F921&A4F933A" + + "A4FC14&A4FF9FuA8032A7A80600]A809B1?A80C0D2A80C63?A80DE1?A81087mA8154DiA816B2DA816D0]A81AF1&A81B6AmA82066&A823FEDA82948iA829DCiA82BB9]A82BCD?" + + "A82BD5uA82C89&A830BC]A8346A]A83512?A83759?A83B5C?A83CA55A83ED3?A842A1iA842E37A84616?A846747A848FA7A8494D?A84A28&A84B4D]A84FB12A85081?A8515B]" + + "A851AB&A852D4ACBC32&ACBCB5&ACBCD92ACBD70?ACC1EEuACC33A]ACC5B4?ACC906&ACCB51>ACCC8E-ACCCFC%ACCF5C&ACCF85?" + + "ACD0747ACD4002ACD75B\\ACDCCA?ACDFA1&ACE011?ACE215?ACE2D3B402162B405A1uB40931?B40AD8eB40B1D]B40EDEAB40F3BkB4107A%B4107Bm" + + "B41324;B414892B414E6?B41513?B41584]B41678BB418D1&B41974&B41A1D]B41BB0&B41DC4?B41F4DeB423A2;B42802AB42BB9?B42E99:B43052?B43522bB437D83B43836?" + + "B4394C?B43A28]B43A31bB43A457B43AE2?B43B52\\B440A4&B440DC]B44326?B44389?B445065B44BD2&B44C3B4B44C902B45253^B4527DeB4527EeB452A9mB454F2?B45575&" + + "B456E3&B45976&B45BD1iB45CB5GB45D50B4A4E32B4A5EF_B4A64A7B4A898?" + + "B4A8B92B4AC9DmB4AEC1&B4B024iB4B055?B4B291DB4B2E98B4B52FBC2CE62BC2EF6?BC305B5BC325F4" + + "BC32B2]BC3329eBC33ACbBC33DB\\BC351EnBC36F7?BC37D3&BC3898ABC3BAF&BC3D85?BC3F8F?BC4486]BC455B]BC4699iBC4760]BC4A562BC4C78?BC4CA0?BC4CC4&BC4F2D&" + + "BC5274]BC52B7&BC542FABC5436&BC5451]BC5A562BC5E33>BC5FF4\"BC60A7eBC6193uBC620E?BC671C2BC6778&BC68C3?BC6A29mBC6AD1uBC6C21&BC6E64eBC6EE2ABC72B1]" + + "BC744BNBC74EA&BC7574?BC765E]BC7670?BC76C5?BC7737ABC79AD]BC7ABF]BC7B72?BC7E8B]BC7EC3wBC7F7B?BC7FA4uBC804E&BC8385HBC851F]BC89A6NBC89A7&BC89C1&" + + "BC8D1F2BC8D7EbBC926B&BC9307]BC932AbBC9789?BC97E1/BC9911wBC9930?BC9A53?BC9B5E>BC9C31?BC9EBBNBC9F58&BC9FE4BCAEC5#BCB0E7?BCB1F3]BCB2CC]BCB30E2BCB863&BCBAC2>BCBB58&BCBCCA?BCBF2E#BCC427?BCC4932BCCD7F?BCCD99A" + + "BCCE25NBCCF4FwBCD074&BCD11F]BCD177iBCD206?BCD22CABCD2952BCD5ED\\BCD7A5C05234?C056E3>C0582E2" + + "C05B44uC05BBD?C05D897C06118iC06194?C0626B2C06380mC06394&C064E42C06599]C067AF2C06C0C&C06DED>C07009?C07831?C07AD6]C07BBC2C083C9?C0847A&C084E0?" + + "C087EB]C08997]C08B05?C08B2A2C08C602C08D51%C091B9%C0956D&C095CF%C09AD0&C09B63?C09B9EbC09F42&C0A0BB3C0A36D?C0A4CFNC0A53E&C0A5E8AC0A600&C0A810A" + + "C0A938?C0AB2B?C0AC54\\C0B22F&C0B47D?C0B550/C0B5CD?C0B5DDbC0B658&C0B6F9AC0B883AC0BC9A?C0BDC8]C0BFA7BC0BFAC?C0BFC0?C0C7DB&C0C9E3iC0CCF8&C0CDD67" + + "C0CECD&C0D012&C0D026?C0D044\\C0D193?C0D2DD]C0D3C0]C0D46B?C0D5E2]C0D60AmC0D6D5HC0DA5E?C0DCD7?C0DCDA]C0DFEDBC0E018?C0E1BE?C0E3FB?C0E422mC0E42Di" + + "C0E579?C0E862&C0F2FB&C0F4E6?C0F6C2?C0F6EC?C0F853nC0F87F2C0F9B0?C0FD6F\\C0FFA8?C0FFD4MC403A8AC40415MC40528?C40683?C4072F?C409B7BC40ACB2C40B31&" + + "C40BCBuC40D96?C40F08AC41234&C412EC?C412F53C41411&C4143C2C41688?C4168F&C416C8?C4170E?C418E9]C418FC2C41C07]C42155?C42360AC4278C?C42996aC42AD0&" + + "C42B44?C42C03&C42F90>C4345B?C4346BC8A776?C8A823]C8B1CD&" + + "C8B29BAC8B5ADCC1531ACC167E2CC1E56?CC1E97?CC208C?CC20AC]CC20E8&CC2119]CC2293%CC22FE&CC25EF&CC2746&CC28AA#CC29F5&CC2D21k" + + "CC2DB7&CC2DE0ICC2F71ACC3089GCC3296?CC32E5iCC3331mCC33BB\\CC3429iCC35D9oCC36BBbCC36CF2CC38E1?CC3BFBZCC3D82ACC3DD1?CC3E5FD4E8802D4E8B2]D4E95Em" + + "D4E98AAD4E9F47D4EB682D4F057ND4F0EAuD4F242?D4F32DAD4F46F&D4F513mD4F547;D4F5EFDC080F&DC094C?DC0A697DC0B092DC0B34DDC0C5C&DC1057&" + + "DC121D?DC15C8$DC16B2?DC1B48mDC1BA1ADC1ED57DC2148ADC215CADC21E2?DC2727?DC2B2A&DC2B61&DC2C6EIDC2D3C?DC333D?DC3714&DC38E1BDC396F$DC39792DC3A5E[" + + "DC415F&DC41A9ADC42C8?DC44B6]DC4546ADC45B8&DC4628ADC4A3EDCD2FC?DCD2FD?DCD3A2&DCD444?DCD7A0?DCD83B2DCD916?DCDA0C7DCDB27?DCDCE2]DCDEE3mDCE541&DCE55B;DCEAE73DCEB942DCED83uDCEE06?" + + "DCEF09MDCEF80?DCF31CmDCF4015DCF4CA&DCF7192DCF756]DCFB020DCFB48ADCFE18iE00084?E0036B]E005C5iE00630?E0071BE0BDA0&E0BF0BbE0BFB2&E0C250ME0C264AE0C377]E0C3EA&E0C767&E0C79DmE0C932AE0C97A&E0CA3C>E0CB1D%E0CB4E#E0CBEE]" + + "E0CC7A?E0CCF8uE0CDB8?E0D045AE0D083]E0D1732E0D362iE0D462?E0D464AE0D4912E0D4E8AE0D55DAE0D55E:E0D7BAmE0D8485E0DA90?E0DB10]E0DB555E0DCFFuE0DEF2m" + + "E0DF13>E0E0FC?E0E258AE0E2E67E0E37C?E0E5CFmE0E751NE0EB40&E0EFBFNE0F330?E0F442?E0F5C6&E0F62DBE0F6B5NE0F728%E0F847&E0FFF1mE4013B&E4029BAE406BFb" + + "E406E0?E4072B?E407B4?E40A16?E40A75bE40D36AE40EEE?E41088]E4115BE4DB6DuE4DC43?E4DCCC?E4DE40E8A245BE8A34E?E8A55ABE8A660?E8A6CA?" + + "E8A72FHE8A730&E8AACB]E8ABF3?E8AC23?E8ADA6\\E8B0C5AE8B1FCAE8B2655E8B2AC&E8B4C8]E8B5D05E8B6C2BE8B7482E8BA17uE8BA702E8BCE42E8BDD1?E8BE81\\E8BFB8A" + + "E8BFE1AE8C1D7QE8C386&E8C3C5?E8C829AE8C913]E8CC183E8CD2D?E8CF835E8D2FF\\E8D3222E8D52B;E8D765?E8D775?E8D87E%E8D8D1ECAA25]ECAA8F?ECADB8&ECADE03ECB157\\ECB1D7ECC9FF7ECCB30?ECCE132ECCED7&ECD09FuECD508&ECD61B7ECDA3B7ECDCAA&ECDD242" + + "ECE09B]ECE1A92ECE3347ECE61D?ECE7A7AECE9D2&ECE9F5?ECEBB8F84E17eF84E58]F84E73&F84F572F85329?F854B8%F85548mF85971AF85B1B7F85B6E]F85C24dF85EA0AF860F0FCA0F3?FCA13E]" + + "FCA183%FCA27E?FCA5C8&FCA621]FCA667%FCA89BmFCA9F5uFCAA14:FCAA81&FCAAB6]FCAB90?FCB214&FCB3AAAFCB3BCAFCB4677FCB69D4FCB6D8&FCBCD1?FCC17DuFCC233#" + + "FCC734]FCC766?FCCA40eFCD733iFCD749%FCD848&FCD908uFCDE90]FCDEC5mFCE1A6?FCE26C&FCE33C?FCE5F0]FCE8C07FCE998&FCE9D8%FCEB7B?FCECDAoFCEFD7?FCF136]" + + "FCF152eFCF528wFCF5C47FCF738?FCF77B?FCF8AEAFCFBFB2FCFC48&"; +} diff --git a/RackPeek.Domain/Discovery/NetworkProbe.cs b/RackPeek.Domain/Discovery/NetworkProbe.cs index 77f498c2..4f12f78c 100644 --- a/RackPeek.Domain/Discovery/NetworkProbe.cs +++ b/RackPeek.Domain/Discovery/NetworkProbe.cs @@ -1,6 +1,9 @@ using System.Net; using System.Net.NetworkInformation; +using System.Net.Security; using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; using RackPeek.Domain.Resources.Services.Networking; namespace RackPeek.Domain.Discovery; @@ -83,6 +86,135 @@ public async Task TryConnectAsync( } } + public async Task ReadTlsSubjectAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + try { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); + + // Every certificate is accepted: homelab gear is self-signed by default and + // the certificate is being read for its name, never trusted for security. + // The callback goes in the options only — setting it in the constructor too + // makes AuthenticateAsClientAsync throw. + await using var ssl = new SslStream(client.GetStream(), false); + + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions { + TargetHost = ip, + RemoteCertificateValidationCallback = (_, _, _, _) => true + }, + cts.Token); + + return ssl.RemoteCertificate is { } certificate + ? new X509Certificate2(certificate).Subject + : null; + } + catch { + // Closed, plaintext, or a handshake this runtime will not do — all "no name". + return null; + } + } + + public async Task ReadTcpBannerAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + try { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); + + await using NetworkStream stream = client.GetStream(); + + var buffer = new byte[256]; + var read = await stream.ReadAsync(buffer, cts.Token); + + return read > 0 + ? Encoding.ASCII.GetString(buffer, 0, read).Trim() + : null; + } + catch { + return null; + } + } + + public async Task ReadHttpHeadAsync( + string ip, + int port, + bool tls, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + try { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); + + Stream stream = client.GetStream(); + SslStream? ssl = null; + + if (tls) { + ssl = new SslStream(stream, false); + + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions { + TargetHost = ip, + RemoteCertificateValidationCallback = (_, _, _, _) => true + }, + cts.Token); + + stream = ssl; + } + + try { + // HTTP/1.0 so the server closes the connection itself rather than leaving + // the read waiting on a keep-alive timeout. + var request = Encoding.ASCII.GetBytes( + $"GET / HTTP/1.0\r\nHost: {ip}\r\nUser-Agent: rackpeek-discover\r\nAccept: */*\r\nConnection: close\r\n\r\n"); + + await stream.WriteAsync(request, cts.Token); + await stream.FlushAsync(cts.Token); + + // Enough for the headers and a near the top of the body. Capped so + // a host streaming megabytes cannot hold the sweep open. + var buffer = new byte[8192]; + var total = 0; + + while (total < buffer.Length) { + var read = await stream.ReadAsync(buffer.AsMemory(total), cts.Token); + + if (read == 0) + break; + + total += read; + } + + return total > 0 + ? Encoding.UTF8.GetString(buffer, 0, total) + : null; + } + finally { + if (ssl != null) + await ssl.DisposeAsync(); + else + await stream.DisposeAsync(); + } + } + catch { + return null; + } + } + public Cidr? LocalSubnet() { try { foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) { diff --git a/RackPeek.Domain/Discovery/NetworkScanFacts.cs b/RackPeek.Domain/Discovery/NetworkScanFacts.cs index 6d963cdb..28a13074 100644 --- a/RackPeek.Domain/Discovery/NetworkScanFacts.cs +++ b/RackPeek.Domain/Discovery/NetworkScanFacts.cs @@ -12,7 +12,27 @@ public sealed record NetworkHostFact( string? Mac, string? Hostname, bool AnsweredPing, - IReadOnlyList<int> OpenPorts); + IReadOnlyList<int> OpenPorts) { + /// <summary> + /// A name a service volunteered — a TLS certificate's CN, an SSH greeting, an + /// HTTP title. Null when nothing answered or nothing said anything useful. Never + /// overrides <see cref="Hostname" />: a PTR record is the network's own answer. + /// </summary> + public ServiceIdentity? Identity { get; init; } + + /// <summary> + /// Applications that named themselves over HTTP, one per port. These describe + /// what the host runs rather than what it is, so they become Service resources + /// rather than deciding the host's name. + /// </summary> + public IReadOnlyList<ServiceIdentity> Services { get; init; } = []; + + /// <summary> + /// The organisation IEEE assigned <see cref="Mac" />'s OUI to, or a note that the + /// address is self-assigned. Null when there is no MAC, or none is known for it. + /// </summary> + public string? Vendor { get; init; } +} /// <summary>How to sweep. The defaults suit a quiet home /24.</summary> public sealed record NetworkScanOptions { @@ -31,4 +51,21 @@ public sealed record NetworkScanOptions { /// <summary>How many hosts are probed at once.</summary> public int Concurrency { get; init; } = 128; + + /// <summary> + /// Whether living hosts are asked what they are — a TLS certificate CN, an SSH + /// greeting, an HTTP title. Costs a handful of short connections per host and is + /// the difference between "host-1a2b3c4d" and "pve-node-01". Off makes the sweep a + /// pure liveness check again. + /// </summary> + public bool IdentifyServices { get; init; } = true; + + /// <summary>Cap on each identity probe; these run per living host, not per address.</summary> + public TimeSpan IdentifyTimeout { get; init; } = TimeSpan.FromMilliseconds(1200); + + /// <summary> + /// Ports a living host is checked against, over and above <see cref="Ports" />. + /// Wider because it is paid per living host rather than per address. + /// </summary> + public IReadOnlyList<int> IdentityPorts { get; init; } = WellKnownPorts.Identity; } diff --git a/RackPeek.Domain/Discovery/NetworkScanMapper.cs b/RackPeek.Domain/Discovery/NetworkScanMapper.cs index 511f335b..096afe6f 100644 --- a/RackPeek.Domain/Discovery/NetworkScanMapper.cs +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -1,4 +1,5 @@ using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.Services.Networking; using RackPeek.Domain.Resources.SystemResources; @@ -18,11 +19,18 @@ public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) { DiscoveryId.NetworkScheme, host.Mac ?? $"ip:{host.Ip}"); + // A PTR record is the network's own answer and wins. Failing that, whatever a + // service volunteered beats a hash of the MAC — "pve-node-01" over "host-1a2b3c4d". + var label = DiscoveryNaming.HostLabel(host.Hostname); + + if (string.IsNullOrEmpty(label) && NamesAHost(host.Identity)) + label = DiscoveryNaming.HostLabel(host.Identity!.Name); + var system = new SystemResource { Kind = SystemResource.KindLabel, Name = DiscoveryNaming.Unique( DiscoveryNaming.Suggest( - DiscoveryNaming.HostLabel(host.Hostname), + label, "host", discoveryId), discoveryId, @@ -37,15 +45,96 @@ public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) { if (host.Mac != null) system.Labels["mac"] = host.Mac; + // Named for what it is: the organisation that owns the NIC's OUI, which is + // not the same claim as who made the machine — a Proxmox guest's NIC says + // Proxmox while the box underneath it is a Dell. + if (host.Vendor != null) + system.Labels["nic-vendor"] = host.Vendor; + + // Kept even when the name came from somewhere else: it records what the host + // actually said, which is how someone judges whether the name is trustworthy. + if (host.Identity != null) + system.Labels["identified-by"] = + $"{Describe(host.Identity.Source)}:{host.Identity.Port} {host.Identity.Name}"; + if (allIps.Count > 1) system.Labels["ips"] = string.Join(",", allIps); resources.Add(system); + + // Something listening on a port is a service, and a service is a resource in + // its own right rather than a note on the host. The host says where it is; + // each port says what it serves. + IReadOnlyDictionary<int, ServiceIdentity> identified = + host.Services.ToDictionary(s => s.Port); + + // A port that answered a banner probe is open by definition, so the two lists + // agree in practice — but an identified service must not go missing if they + // ever disagree. + IEnumerable<int> ports = host.OpenPorts + .Concat(identified.Keys) + .Distinct(); + + foreach (var port in ports) { + var serviceId = DiscoveryId.Create( + DiscoveryId.NetworkScheme, + $"{host.Mac ?? $"ip:{host.Ip}"}:{port}"); + + // What the service said about itself beats what its port number implies, + // because a port is a convention and an answer is evidence. The exception + // is an appliance whose management page just says its own name back: a + // second card called opnsense tells no one anything, where an opnsense-https + // sitting on opnsense says exactly what it is. + var announced = identified.TryGetValue(port, out ServiceIdentity? found) + ? DiscoveryNaming.HostLabel(found.Name) + : string.Empty; + + var serviceLabel = announced.Length > 0 + && !announced.Equals(system.Name, StringComparison.OrdinalIgnoreCase) + ? announced + : $"{system.Name}-{WellKnownPorts.NameFor(port)}"; + + resources.Add(new Service { + Kind = Service.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest(serviceLabel, "service", serviceId), + serviceId, + taken), + DiscoveryId = serviceId, + Network = new Network { + Ip = host.Ip, + Port = port, + Protocol = "TCP" + }, + RunsOn = [system.Name] + }); + } } return resources; } + /// <summary> + /// Whether an identity is a claim about the machine rather than about something + /// running on it. Only a certificate is: an X.509 common name is a host name by + /// construction, which is why a Proxmox node's certificate says "pve-node-01". + /// <para> + /// A page title names an application — and a host may run several, so naming + /// the machine after whichever answered first is arbitrary. Those become + /// Services instead. An SSH greeting names only the daemon; naming from it + /// produced five cards called "openssh" on a real sweep. + /// </para> + /// </summary> + private static bool NamesAHost(ServiceIdentity? identity) => + identity is { Source: IdentitySource.TlsCertificate }; + + private static string Describe(IdentitySource source) => source switch { + IdentitySource.TlsCertificate => "tls", + IdentitySource.SshBanner => "ssh", + IdentitySource.Http => "http", + _ => "dns" + }; + /// <summary> /// One MAC answering on several addresses — a gateway's VIPs and aliases — is /// still one machine, so it becomes one card: the lowest address as the card's diff --git a/RackPeek.Domain/Discovery/NetworkScanner.cs b/RackPeek.Domain/Discovery/NetworkScanner.cs index 0785b3f9..ac975f7c 100644 --- a/RackPeek.Domain/Discovery/NetworkScanner.cs +++ b/RackPeek.Domain/Discovery/NetworkScanner.cs @@ -73,11 +73,29 @@ public static async Task<IReadOnlyList<NetworkHostFact>> ScanAsync( } })); + // Interrogate the living: finish the port sweep the liveness check cut short, + // then ask what they are. Only living hosts are asked, so the cost is a few + // short connections per host rather than per address. + (IReadOnlyList<int> Open, ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)[] interrogated = + options.IdentifyServices + ? await Task.WhenAll(alive.Select(h => + InterrogateAsync(probe, options, h.Ip, h.Open, gate, cancellationToken))) + : [ + .. alive.Select(h => + ((IReadOnlyList<int>)h.Open, (ServiceIdentity?)null, (IReadOnlyList<ServiceIdentity>)[])) + ]; + var facts = new List<NetworkHostFact>(alive.Count); for (var i = 0; i < alive.Count; i++) { - (var ip, var ping, List<int> open) = alive[i]; - facts.Add(new NetworkHostFact(ip, macByIp.GetValueOrDefault(ip), names[i], ping, open)); + (var ip, var ping, _) = alive[i]; + var mac = macByIp.GetValueOrDefault(ip); + + facts.Add(new NetworkHostFact(ip, mac, names[i], ping, interrogated[i].Open) { + Identity = interrogated[i].Identity, + Services = interrogated[i].Services, + Vendor = MacVendorLookup.Lookup(mac) + }); } return facts @@ -85,6 +103,157 @@ public static async Task<IReadOnlyList<NetworkHostFact>> ScanAsync( .ToList(); } + /// <summary>Ports that speak TLS, where a certificate may name the machine.</summary> + private static readonly HashSet<int> _tlsPorts = [443, 8443, 8006, 9443]; + + /// <summary>Ports that speak plain HTTP, where a page title may name an application.</summary> + private static readonly HashSet<int> _httpPorts = [80, 8080, 8000, 3000, 8123, 9000, 9090, 8096, 7860, 11434, 32400]; + + /// <summary> + /// What to ask each open port, best evidence first. Only open ports are asked — + /// the sweep has just established which those are, and a handshake with a closed + /// port buys nothing but a timeout. + /// <para> + /// A certificate goes first because an X.509 common name is a host name by + /// construction. A page title is an application's name, which is a different + /// claim. An SSH greeting is last and names only the daemon. + /// </para> + /// </summary> + private static IEnumerable<(int Port, IdentitySource Source, bool Tls)> Candidates( + IReadOnlyList<int> openPorts) { + foreach (var port in openPorts.Where(_tlsPorts.Contains)) + yield return (port, IdentitySource.TlsCertificate, true); + + foreach (var port in openPorts.Where(_httpPorts.Contains)) + yield return (port, IdentitySource.Http, false); + + // Something is listening but nobody curated the port: it could be either, so + // try both rather than assume. + foreach (var port in openPorts.Where(p => + p != 22 && !_tlsPorts.Contains(p) && !_httpPorts.Contains(p))) { + yield return (port, IdentitySource.TlsCertificate, true); + yield return (port, IdentitySource.Http, false); + } + + if (openPorts.Contains(22)) + yield return (22, IdentitySource.SshBanner, false); + } + + /// <summary> + /// Everything worth asking a host that has already proven it is alive: which of + /// the probed ports are open, and what its services say they are. + /// <para> + /// The liveness sweep stops at the first answer on purpose — it only needs to + /// know the host exists. That leaves the port list incomplete for a host that + /// answered, and empty for one that answered ping, so it is finished here. The + /// ports are worth having for their own sake (554 says camera, 445 says file + /// server) and they are the best possible targets for the banner probes below: + /// a service on a port nobody curated is exactly the one worth asking. + /// </para> + /// </summary> + private static async Task<(IReadOnlyList<int> Open, ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)> InterrogateAsync( + INetworkProbe probe, + NetworkScanOptions options, + string ip, + IReadOnlyList<int> knownOpen, + SemaphoreSlim gate, + CancellationToken cancellationToken) { + await gate.WaitAsync(cancellationToken); + + try { + var open = new List<int>(knownOpen); + + // The liveness list plus the wider identity list: the host is alive, so the + // extra connections are paid once for it rather than once per address. + foreach (var port in options.Ports.Concat(options.IdentityPorts).Distinct()) { + if (open.Contains(port)) + continue; + + if (await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken)) + open.Add(port); + } + + open.Sort(); + + (ServiceIdentity? identity, IReadOnlyList<ServiceIdentity> services) = + await IdentifyAsync(probe, options, ip, open, cancellationToken); + + return (open, identity, services); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return (knownOpen, null, []); + } + finally { + gate.Release(); + } + } + + /// <summary> + /// Asks every open port what it is and sorts the answers into the two different + /// claims they make. + /// <para> + /// A certificate common name names the machine — that is what an X.509 CN is + /// for — so the first one found becomes the host's identity. A page title + /// names an application, which is a fact about what the host runs rather than + /// about the host, so each one becomes a Service instead. An SSH greeting + /// names only the daemon and is kept as a last-resort annotation. + /// </para> + /// Never throws: an unidentified host is still a found host. + /// </summary> + private static async Task<(ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)> IdentifyAsync( + INetworkProbe probe, + NetworkScanOptions options, + string ip, + IReadOnlyList<int> openPorts, + CancellationToken cancellationToken) { + ServiceIdentity? host = null; + ServiceIdentity? fallback = null; + var services = new List<ServiceIdentity>(); + + try { + foreach ((var port, IdentitySource source, var tls) in Candidates(openPorts)) { + // One application per port: a port that already answered has nothing + // further to say, and asking again only costs time. + if (services.Exists(s => s.Port == port)) + continue; + + var name = source switch { + IdentitySource.TlsCertificate => ServiceIdentityParser.ParseTlsSubject( + await probe.ReadTlsSubjectAsync(ip, port, options.IdentifyTimeout, cancellationToken)), + IdentitySource.SshBanner => ServiceIdentityParser.ParseSshBanner( + await probe.ReadTcpBannerAsync(ip, port, options.IdentifyTimeout, cancellationToken)), + _ => ServiceIdentityParser.ParseHttpIdentity( + await probe.ReadHttpHeadAsync(ip, port, tls, options.IdentifyTimeout, cancellationToken)) + }; + + if (name == null) + continue; + + var identity = new ServiceIdentity(name, source, port); + + switch (source) { + case IdentitySource.TlsCertificate: + host ??= identity; + break; + + case IdentitySource.Http: + services.Add(identity); + break; + + default: + fallback ??= identity; + break; + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + // One host's probe timing out must not abandon the sweep, nor lose whatever + // the earlier ports already said. + } + + return (host ?? services.FirstOrDefault() ?? fallback, services); + } + /// <summary>One host's liveness check; null when nothing answered.</summary> private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync( INetworkProbe probe, diff --git a/RackPeek.Domain/Discovery/OpnsenseApiClient.cs b/RackPeek.Domain/Discovery/OpnsenseApiClient.cs new file mode 100644 index 00000000..028b1c15 --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseApiClient.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Headers; +using System.Net.Security; +using System.Text; + +namespace RackPeek.Domain.Discovery; + +/// <summary>Reads an OPNsense firewall's API. The IO half of firewall discovery.</summary> +public interface IOpnsenseClient { + /// <summary>Where this client is pointed, for error messages.</summary> + string Endpoint { get; } + + /// <summary> + /// Every neighbour the firewall currently has an ARP entry for, across every + /// subnet it routes. + /// </summary> + Task<IReadOnlyList<OpnsenseNeighbour>> GetNeighboursAsync(CancellationToken cancellationToken = default); +} + +/// <summary> +/// Talks to the OPNsense API with a key and secret, which OPNsense issues per user +/// and sends as HTTP basic credentials. A key can be given a read-only role and +/// revoked on its own, so it is used rather than a login. +/// </summary> +public sealed class OpnsenseApiClient : IOpnsenseClient, IDisposable { + public const string KeyEnvironmentVariable = "RPK_OPN_KEY"; + public const string SecretEnvironmentVariable = "RPK_OPN_SECRET"; + + /// <summary> + /// The ARP endpoint, newest spelling first. OPNsense 25.7 renamed its API actions + /// from camelCase to snake_case and kept the old names only for a while — an + /// integration pinned to either one breaks on half the installations out there. + /// Asking for each in turn costs one extra request against older firmware and + /// nothing against new. + /// </summary> + private static readonly string[] _arpPaths = [ + "api/diagnostics/interface/get_arp", + "api/diagnostics/interface/getArp" + ]; + + private readonly HttpClient _httpClient; + + /// <param name="allowUntrustedCertificate"> + /// OPNsense ships with a self-signed certificate and most installations keep it. + /// Opt-in all the same. + /// </param> + public OpnsenseApiClient( + string host, + string key, + string secret, + bool allowUntrustedCertificate = false, + HttpClient? httpClient = null) { + Endpoint = Normalise(host); + + _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate)); + _httpClient.BaseAddress = new Uri(Endpoint + "/"); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{key}:{secret}"))); + } + + public string Endpoint { get; } + + public void Dispose() => _httpClient.Dispose(); + + public async Task<IReadOnlyList<OpnsenseNeighbour>> GetNeighboursAsync( + CancellationToken cancellationToken = default) { + HttpRequestException? last = null; + + foreach (var path in _arpPaths) { + try { + return OpnsenseResponseParser.ParseArp(await GetAsync(path, cancellationToken)); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + // Wrong spelling for this firmware; try the other. + last = ex; + } + } + + throw last ?? new HttpRequestException("The firewall has no ARP endpoint this understands."); + } + + /// <summary> + /// A bare name gets https, matching how the web UI is reached. The path is + /// trimmed so callers may paste a URL straight out of the browser. + /// </summary> + public static string Normalise(string host) { + var trimmed = host.Trim().TrimEnd('/'); + + if (!trimmed.Contains("://", StringComparison.Ordinal)) + trimmed = "https://" + trimmed; + + var uri = new Uri(trimmed); + + return uri.GetLeftPart(UriPartial.Authority); + } + + private async Task<string> GetAsync(string path, CancellationToken cancellationToken) { + using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken); + + // A key without the right privilege is the common setup mistake, and OPNsense + // answers it with a redirect to the login page rather than a 401. + if (response.StatusCode is System.Net.HttpStatusCode.Found + or System.Net.HttpStatusCode.MovedPermanently + or System.Net.HttpStatusCode.Unauthorized + or System.Net.HttpStatusCode.Forbidden) + throw new HttpRequestException( + $"The firewall refused the API key ({(int)response.StatusCode}). Check the key and secret, " + + "and that its user holds the Diagnostics: ARP Table privilege.", + null, + response.StatusCode); + + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(cancellationToken); + } + + private static HttpClientHandler Handler(bool allowUntrustedCertificate) { + var handler = new HttpClientHandler(); + + if (allowUntrustedCertificate) + handler.ServerCertificateCustomValidationCallback = + (_, _, _, _) => true; + + return handler; + } +} diff --git a/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs new file mode 100644 index 00000000..15d02d66 --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs @@ -0,0 +1,105 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// Turns a firewall's neighbour table into System resources. +/// <para> +/// The cards are seeded exactly as <see cref="NetworkScanMapper" /> seeds its +/// own — the network scheme, keyed on the MAC — because they describe the same +/// thing by the same evidence: a machine observed on the network rather than +/// asked about itself. That makes the two collectors interchangeable. A host the +/// firewall knows and a host a sweep found are one card, whichever ran first, +/// with no special case anywhere to say so. +/// </para> +/// </summary> +public static class OpnsenseDiscovery { + public static async Task<List<Resource>> ReadAsync( + IOpnsenseClient client, + bool includePublic = false, + CancellationToken cancellationToken = default) => + ToResources(await client.GetNeighboursAsync(cancellationToken), includePublic); + + /// <summary> + /// Whether an address belongs to a network someone runs themselves: the RFC 1918 + /// ranges plus the carrier-grade block an ISP may hand out. + /// <para> + /// A firewall's WAN leg has neighbours too, and they are the ISP's equipment + /// rather than anything the user owns. Recording them would also put a public + /// address into a file people commit to git, which is a surprising thing for + /// an inventory of a home lab to do on its own. Anyone documenting a fleet on + /// public addresses can ask for them. + /// </para> + /// </summary> + public static bool IsPrivate(string ip) { + var parts = ip.Split('.'); + + if (parts.Length != 4 || !int.TryParse(parts[0], out var a) || !int.TryParse(parts[1], out var b)) + return false; + + return a switch { + 10 => true, + 172 => b is >= 16 and <= 31, + 192 => b == 168, + 100 => b is >= 64 and <= 127, // carrier-grade NAT + _ => false + }; + } + + public static List<Resource> ToResources( + IReadOnlyList<OpnsenseNeighbour> neighbours, + bool includePublic = false) { + var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var resources = new List<Resource>(); + + foreach (OpnsenseNeighbour neighbour in neighbours + .Where(n => includePublic || IsPrivate(n.Ip)) + .OrderBy(n => Order(n.Ip))) { + var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, neighbour.Mac); + + var system = new SystemResource { + Kind = SystemResource.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest( + DiscoveryNaming.HostLabel(neighbour.Hostname), + "host", + discoveryId), + discoveryId, + taken), + DiscoveryId = discoveryId, + // Sparse for the same reason a scan's cards are: the firewall knows where + // a machine is and what its NIC is, never what runs on it. Writing a guess + // here would overwrite the real values on the next run of a collector that + // does know. + Ip = neighbour.Ip + }; + + system.Labels["mac"] = neighbour.Mac; + + // Ours first so the vocabulary matches every other card, the firewall's own + // lookup second — it carries the whole IEEE registry, so it answers for the + // prefixes the curated table leaves out. + var vendor = MacVendorLookup.Lookup(neighbour.Mac) ?? neighbour.Manufacturer; + + // The organisation that owns the NIC's OUI — not a claim about who made the + // machine, which is a different thing entirely. + if (vendor != null) + system.Labels["nic-vendor"] = vendor; + + resources.Add(system); + } + + return resources; + } + + private static uint Order(string ip) { + try { + return Resources.Services.Networking.IpHelper.ToUInt32(ip); + } + catch (ArgumentException) { + // A malformed address still deserves a card; it just sorts last. + return uint.MaxValue; + } + } +} diff --git a/RackPeek.Domain/Discovery/OpnsenseModels.cs b/RackPeek.Domain/Discovery/OpnsenseModels.cs new file mode 100644 index 00000000..c40eac6e --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseModels.cs @@ -0,0 +1,120 @@ +using System.Text.Json; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// One neighbour the firewall has seen, from its ARP table. +/// <para> +/// This is the record a sweep cannot produce for anything off its own segment: +/// ARP is link-local, so a host on another subnet gives a scanner an address and +/// nothing else. The firewall routes every subnet, so its table carries the MAC +/// for all of them — and a MAC is the identity that survives a DHCP re-lease. +/// </para> +/// </summary> +public sealed record OpnsenseNeighbour( + string Ip, + string Mac, + string? Hostname, + string? Manufacturer, + string? Interface); + +public static class OpnsenseResponseParser { + /// <summary> + /// Reads the ARP table. OPNsense answers either a flat array or, through the + /// search wrapper, an object with a <c>rows</c> array; both shapes are accepted so + /// the caller need not care which endpoint answered. + /// </summary> + public static List<OpnsenseNeighbour> ParseArp(string json) { + var neighbours = new List<OpnsenseNeighbour>(); + + using var document = JsonDocument.Parse(json); + + JsonElement root = document.RootElement; + + JsonElement rows = root.ValueKind switch { + JsonValueKind.Array => root, + JsonValueKind.Object when root.TryGetProperty("rows", out JsonElement r) => r, + _ => default + }; + + if (rows.ValueKind != JsonValueKind.Array) + return neighbours; + + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + foreach (JsonElement entry in rows.EnumerateArray()) { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + // An address the firewall holds itself. Every routed subnet contributes one, + // and they are all the same box — which is a Firewall, not the handful of + // Systems this would otherwise invent. + if (IsTrue(entry, "permanent")) + continue; + + // The entry is still listed after it ages out; it says where something used + // to be, which is not evidence that it is there now. + if (IsTrue(entry, "expired")) + continue; + + var mac = ArpTableParser.NormaliseMac(Text(entry, "mac")); + var ip = Text(entry, "ip"); + + if (mac == null || string.IsNullOrWhiteSpace(ip)) + continue; + + // Broadcast and multicast are not machines. + if (mac is "ff:ff:ff:ff:ff:ff" || IsMulticast(mac)) + continue; + + // One row per machine: a host answering on several of the firewall's + // interfaces is still one machine, and the first row carries its address. + if (!seen.Add(mac)) + continue; + + neighbours.Add(new OpnsenseNeighbour( + ip, + mac, + Clean(Text(entry, "hostname")), + Clean(Text(entry, "manufacturer")), + Clean(Text(entry, "intf_description")) ?? Clean(Text(entry, "intf")))); + } + + return neighbours; + } + + /// <summary> + /// A locally administered group address — the low bit of the first octet marks + /// multicast, which no host owns. + /// </summary> + private static bool IsMulticast(string mac) => + Convert.ToInt32(mac[..2], 16) % 2 == 1; + + private static string? Text(JsonElement element, string name) => + element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + /// <summary> + /// OPNsense writes these as real booleans in some versions and as the strings + /// "1"/"true" in others. + /// </summary> + private static bool IsTrue(JsonElement element, string name) { + if (!element.TryGetProperty(name, out JsonElement value)) + return false; + + return value.ValueKind switch { + JsonValueKind.True => true, + JsonValueKind.String => value.GetString() is "1" or "true" or "yes", + JsonValueKind.Number => value.TryGetInt32(out var number) && number != 0, + _ => false + }; + } + + /// <summary>Blank and placeholder values arrive as empty strings or dashes.</summary> + private static string? Clean(string? value) { + var trimmed = value?.Trim(); + + return string.IsNullOrEmpty(trimmed) || trimmed is "-" or "(none)" ? null : trimmed; + } +} diff --git a/RackPeek.Domain/Discovery/ProxmoxApiClient.cs b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs index 24ceda0f..2618d128 100644 --- a/RackPeek.Domain/Discovery/ProxmoxApiClient.cs +++ b/RackPeek.Domain/Discovery/ProxmoxApiClient.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Net.Security; namespace RackPeek.Domain.Discovery; @@ -143,6 +144,35 @@ public async Task<ProxmoxGuestConfig> GetGuestConfigAsync( } } + public async Task<IReadOnlyList<ProxmoxGuestAddress>> GetGuestAddressesAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default) { + // Every failure here means the same thing: the guest cannot say where it is. + // No agent installed, agent not running, container stopped, guest deleted since + // it was listed, or a token without VM.Monitor — all leave the address unknown, + // which is what the guest config already told us. + try { + var path = endpoint == LxcEndpoint + ? $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/interfaces" + : $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/agent/network-get-interfaces"; + + var json = await GetAsync(path, cancellationToken); + + return endpoint == LxcEndpoint + ? ProxmoxResponseParser.ParseContainerInterfaces(json) + : ProxmoxResponseParser.ParseAgentInterfaces(json); + } + catch (HttpRequestException) { + return []; + } + catch (JsonException) { + // A node that answers the agent call with an error body rather than a status. + return []; + } + } + private async Task<string> FirstNodeAsync(CancellationToken cancellationToken) { IReadOnlyList<ProxmoxNode> nodes = await GetNodesAsync(cancellationToken); diff --git a/RackPeek.Domain/Discovery/ProxmoxDiscovery.cs b/RackPeek.Domain/Discovery/ProxmoxDiscovery.cs new file mode 100644 index 00000000..763e631b --- /dev/null +++ b/RackPeek.Domain/Discovery/ProxmoxDiscovery.cs @@ -0,0 +1,107 @@ +using RackPeek.Domain.Resources; + +namespace RackPeek.Domain.Discovery; + +/// <summary> +/// Reads a whole Proxmox endpoint and maps it to resources. +/// <para> +/// Lives here rather than in a command so the CLI and the MCP tool run the same +/// code. They used to hold a copy each, and the copies had already drifted — the +/// MCP one dropped the guests' MACs, which silently cost every guest its chance +/// of unifying with a network scan. +/// </para> +/// </summary> +public static class ProxmoxDiscovery { + public static async Task<List<Resource>> ReadAsync( + IProxmoxClient client, + CancellationToken cancellationToken = default) { + var scope = await client.GetIdentityScopeAsync(cancellationToken); + IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken); + + var nodes = new List<ProxmoxNode>(); + var guests = new List<ProxmoxGuest>(); + + foreach (ProxmoxNode listedNode in listed) { + // Node detail needs a broader permission than listing guests does, so it is + // enrichment rather than a requirement — a read-only token still gets a tree. + ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken); + nodes.Add(node); + + var nodeName = node.Name; + + foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) { + IReadOnlyList<ProxmoxGuest> listedGuests = + await client.GetGuestsAsync(nodeName, endpoint, cancellationToken); + + // The list call knows nothing about the OS, and for a container it does + // not know the address either. Both live in the guest's own config — one + // call per guest, so they run concurrently rather than one at a time. + ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g => + client.GetGuestConfigAsync(nodeName, endpoint, g.VmId, cancellationToken))); + + // Ask the running guests where they are. The config only carries an + // address when someone set one statically, so on a DHCP estate this is + // the difference between every guest having an address and none of them + // having one — and an address is what lets a guest line up with the host + // a network sweep found at that address. + IReadOnlyList<ProxmoxGuestAddress>[] addresses = await Task.WhenAll( + listedGuests.Select(g => IsRunning(g) + ? client.GetGuestAddressesAsync(nodeName, endpoint, g.VmId, cancellationToken) + : Task.FromResult<IReadOnlyList<ProxmoxGuestAddress>>([]))); + + for (var i = 0; i < listedGuests.Count; i++) { + IReadOnlyList<string> macs = configs[i].Macs ?? []; + + guests.Add(listedGuests[i] with { + Os = configs[i].Os, + Ip = configs[i].Ip ?? SelectGuestIp(addresses[i], macs), + Disks = configs[i].DiskBytes, + PassthroughAddresses = configs[i].PassthroughAddresses, + Macs = macs + }); + } + } + } + + return ProxmoxResourceMapper.ToResources(scope, nodes, guests); + } + + /// <summary> + /// The address that belongs to the guest itself. + /// <para> + /// A guest agent reports every interface inside the machine, and a guest that + /// runs containers has several: Docker's <c>docker0</c> and its per-network + /// bridges, Home Assistant's <c>hassio</c>, any VPN tunnel. Recording + /// 172.17.0.1 as the machine's address would be worse than recording nothing, + /// because every Docker host on the estate reports the same one. + /// </para> + /// <para> + /// The NIC MACs Proxmox assigned are the discriminator: they are already read + /// from the guest's config, and an interface carrying one is a NIC the + /// hypervisor gave the guest rather than something the guest invented. When + /// the MACs are unknown — a container, or a config the token cannot read — + /// nothing is claimed, since a guess here is indistinguishable from a fact. + /// </para> + /// </summary> + public static string? SelectGuestIp( + IReadOnlyList<ProxmoxGuestAddress> addresses, + IReadOnlyList<string> configuredMacs) { + if (addresses.Count == 0 || configuredMacs.Count == 0) + return null; + + var allowed = new HashSet<string>( + configuredMacs.Select(ArpTableParser.NormaliseMac).Where(m => m != null)!, + StringComparer.OrdinalIgnoreCase); + + if (allowed.Count == 0) + return null; + + return addresses + .Where(a => ArpTableParser.NormaliseMac(a.Mac) is { } mac && allowed.Contains(mac)) + .Select(a => a.Ip) + .FirstOrDefault(); + } + + private static bool IsRunning(ProxmoxGuest guest) => + string.Equals(guest.Status, "running", StringComparison.OrdinalIgnoreCase); +} diff --git a/RackPeek.Domain/Discovery/ProxmoxModels.cs b/RackPeek.Domain/Discovery/ProxmoxModels.cs index d23264c1..52573225 100644 --- a/RackPeek.Domain/Discovery/ProxmoxModels.cs +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -62,6 +62,12 @@ public sealed record ProxmoxGuest { public IReadOnlyList<string> Tags { get; init; } = []; + /// <summary> + /// <c>running</c>, <c>stopped</c> and friends, from the guest list. Only a running + /// guest can be asked where it is, and a stopped one has no address to report. + /// </summary> + public string? Status { get; init; } + /// <summary>Filled in from the guest's config, which is the only place it is known.</summary> public string? Os { get; init; } @@ -424,6 +430,7 @@ public static long ParseSize(string volume) { Cores = GetInt(element, "cpus") ?? 0, MemoryBytes = GetLong(element, "maxmem") ?? 0, DiskBytes = GetLong(element, "maxdisk") ?? 0, + Status = GetString(element, "status"), Tags = ParseTags(GetString(element, "tags")) }; } @@ -455,18 +462,138 @@ private static IEnumerable<JsonElement> Data(string json) { ? value.GetString() : null; - private static int? GetInt(JsonElement element, string name) => - element.TryGetProperty(name, out JsonElement value) && value.TryGetInt32(out var result) - ? result - : null; + /// <summary> + /// A number Proxmox may have written as a string. + /// <para> + /// Its perl backend quotes numeric fields inconsistently — <c>"vmid":"100"</c> + /// and <c>"maxdisk":"512110190592"</c> both turn up across versions. The + /// <c>TryGetInt32</c> family does not return false for a string, it throws, so + /// reading one unguarded took the whole run down with a stack trace. The kind + /// is checked first and a quoted number parsed, which is what the caller meant + /// either way. + /// </para> + /// </summary> + private static int? GetInt(JsonElement element, string name) { + if (!element.TryGetProperty(name, out JsonElement value)) + return null; - private static long? GetLong(JsonElement element, string name) => - element.TryGetProperty(name, out JsonElement value) && value.TryGetInt64(out var result) - ? result - : null; + return value.ValueKind switch { + JsonValueKind.Number when value.TryGetInt32(out var number) => number, + JsonValueKind.String when int.TryParse(value.GetString(), out var parsed) => parsed, + _ => null + }; + } + + /// <inheritdoc cref="GetInt" /> + private static long? GetLong(JsonElement element, string name) { + if (!element.TryGetProperty(name, out JsonElement value)) + return null; + + return value.ValueKind switch { + JsonValueKind.Number when value.TryGetInt64(out var number) => number, + JsonValueKind.String when long.TryParse(value.GetString(), out var parsed) => parsed, + _ => null + }; + } + + /// <summary> + /// Addresses a QEMU guest reports through its guest agent + /// (<c>agent/network-get-interfaces</c>). The agent sees every interface inside + /// the guest, including the bridges Docker and Home Assistant create, so the + /// caller filters by the NIC MACs Proxmox actually assigned — see + /// <see cref="ProxmoxDiscovery.SelectGuestIp" />. + /// </summary> + public static List<ProxmoxGuestAddress> ParseAgentInterfaces(string json) { + var addresses = new List<ProxmoxGuestAddress>(); + + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("data", out JsonElement data) + || data.ValueKind != JsonValueKind.Object + || !data.TryGetProperty("result", out JsonElement result) + || result.ValueKind != JsonValueKind.Array) + return addresses; + + foreach (JsonElement iface in result.EnumerateArray()) { + if (iface.ValueKind != JsonValueKind.Object) + continue; + + var name = GetString(iface, "name"); + var mac = GetString(iface, "hardware-address"); + + if (!iface.TryGetProperty("ip-addresses", out JsonElement ips) + || ips.ValueKind != JsonValueKind.Array) + continue; + + foreach (JsonElement entry in ips.EnumerateArray()) { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (!string.Equals(GetString(entry, "ip-address-type"), "ipv4", StringComparison.OrdinalIgnoreCase)) + continue; + + var ip = GetString(entry, "ip-address"); + + if (IsUsableAddress(ip)) + addresses.Add(new ProxmoxGuestAddress(name, mac, ip!)); + } + } + + return addresses; + } + + /// <summary> + /// Addresses a container reports through <c>lxc/{vmid}/interfaces</c>, which is a + /// flat list rather than the agent's nested shape and spells the address with its + /// prefix (<c>10.0.0.5/24</c>). + /// </summary> + public static List<ProxmoxGuestAddress> ParseContainerInterfaces(string json) { + var addresses = new List<ProxmoxGuestAddress>(); + + using var document = JsonDocument.Parse(json); + + if (!document.RootElement.TryGetProperty("data", out JsonElement data) + || data.ValueKind != JsonValueKind.Array) + return addresses; + + foreach (JsonElement iface in data.EnumerateArray()) { + if (iface.ValueKind != JsonValueKind.Object) + continue; + + var ip = GetString(iface, "inet"); + + // "10.0.0.5/24" — the prefix belongs to the interface, not to the address + // the inventory records. + var slash = ip?.IndexOf('/') ?? -1; + + if (slash > 0) + ip = ip![..slash]; + + if (IsUsableAddress(ip)) + addresses.Add(new ProxmoxGuestAddress( + GetString(iface, "name"), + GetString(iface, "hwaddr"), + ip!)); + } + + return addresses; + } + + /// <summary> + /// Whether an address is worth recording: a real IPv4 that is neither loopback + /// nor the 169.254 a guest assigns itself when DHCP fails. + /// </summary> + private static bool IsUsableAddress(string? ip) => + !string.IsNullOrWhiteSpace(ip) + && !ip.StartsWith("127.", StringComparison.Ordinal) + && !ip.StartsWith("169.254.", StringComparison.Ordinal) + && ip.Count(c => c == '.') == 3; } /// <summary>The parts of a guest's config worth recording. Everything is optional.</summary> +/// <summary>One address a guest reports for one of its own interfaces.</summary> +public sealed record ProxmoxGuestAddress(string? Interface, string? Mac, string Ip); + public sealed record ProxmoxGuestConfig( string? Os, string? Ip, diff --git a/RackPeek.Domain/Discovery/ServiceIdentityParser.cs b/RackPeek.Domain/Discovery/ServiceIdentityParser.cs new file mode 100644 index 00000000..36d1e325 --- /dev/null +++ b/RackPeek.Domain/Discovery/ServiceIdentityParser.cs @@ -0,0 +1,211 @@ +using System.Text.RegularExpressions; + +namespace RackPeek.Domain.Discovery; + +/// <summary>Where a host's name came from, best evidence first.</summary> +public enum IdentitySource { + /// <summary>A PTR record — the network's own answer, so it wins.</summary> + ReverseDns, + + /// <summary>The Common Name on the certificate a TLS port presented.</summary> + TlsCertificate, + + /// <summary>The greeting an SSH server sends before anything is asked of it.</summary> + SshBanner, + + /// <summary>An HTTP <c>Server</c> header or page title.</summary> + Http +} + +/// <summary>A name a service volunteered, and what volunteered it.</summary> +public sealed record ServiceIdentity(string Name, IdentitySource Source, int Port); + +/// <summary> +/// Reads a host's name out of what its services say when you connect to them. This +/// is the identification half of <c>nmap -sV</c>, reduced to the three banners that +/// actually name homelab gear: a TLS certificate's CN, an SSH greeting, and an HTTP +/// <c>Server</c> header or page title. Pure — <see cref="INetworkProbe" /> does the +/// talking, everything here just reads what came back. +/// </summary> +public static class ServiceIdentityParser { + /// <summary> + /// Names that identify software rather than a machine, or are placeholders the + /// installer never changed. Keeping them would label every appliance of a kind + /// with the same name, which is worse than no name at all. + /// </summary> + private static readonly HashSet<string> _uselessNames = new(StringComparer.OrdinalIgnoreCase) { + "localhost", + "localhost.localdomain", + "example.com", + "www.example.com", + "default", + "changeme", + "server", + "ubuntu", + "debian", + "raspberrypi", + "openwrt", + "*" + }; + + /// <summary> + /// The Common Name from a certificate's subject. Handles both the OpenSSL-style + /// "CN=host, O=org" and the .NET "CN=host, O=org" orderings, and tolerates the + /// slash-separated form some tools print. + /// </summary> + public static string? ParseTlsSubject(string? subject) { + if (string.IsNullOrWhiteSpace(subject)) + return null; + + Match match = Regex.Match( + subject, + @"CN\s*=\s*(?<cn>[^,/]+)", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + if (!match.Success) + return null; + + var cn = match.Groups["cn"].Value.Trim().Trim('"'); + + // A wildcard certificate names a domain, not this machine. + if (cn.StartsWith("*.", StringComparison.Ordinal)) + return null; + + return Clean(cn); + } + + /// <summary> + /// The software an SSH server announces, e.g. "SSH-2.0-OpenSSH_9.6" -> "OpenSSH". + /// This names what the host runs rather than the host itself, which is still worth + /// having: "dropbear" says embedded appliance, "OpenSSH" says general-purpose box. + /// </summary> + public static string? ParseSshBanner(string? banner) { + if (string.IsNullOrWhiteSpace(banner)) + return null; + + Match match = Regex.Match( + banner, + @"^SSH-\d+\.\d+-(?<software>[^\s\r\n]+)", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + if (!match.Success) + return null; + + var software = match.Groups["software"].Value; + + // Trim the version: "OpenSSH_9.6p1" and "OpenSSH_8.4p1" are the same answer to + // "what is this", and a version in a resource name goes stale on the next patch. + var cut = software.IndexOfAny(['_', '-']); + + if (cut > 0) + software = software[..cut]; + + return Clean(software); + } + + /// <summary> + /// A name from an HTTP response head: the page title if it says something, else + /// the <c>Server</c> header. Titles win because "Home Assistant" identifies a box + /// far better than "nginx" does. + /// </summary> + public static string? ParseHttpIdentity(string? responseHead) { + if (string.IsNullOrWhiteSpace(responseHead)) + return null; + + // An error page's title describes the error, not the host — "HTTP Status 400 – + // Bad Request" is a name no one would recognise. The Server header below is + // still trustworthy on an error response, so only the title is gated. + if (IsSuccessful(responseHead)) { + Match title = Regex.Match( + responseHead, + @"<title[^>]*>(?<title>[^<]{1,120})", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + if (title.Success) { + var cleaned = Clean(LeadingPhrase(title.Groups["title"].Value)); + + if (cleaned != null) + return cleaned; + } + } + + Match server = Regex.Match( + responseHead, + @"^Server:\s*(?[^\r\n]{1,80})", + RegexOptions.IgnoreCase | RegexOptions.Multiline, + TimeSpan.FromSeconds(1)); + + if (!server.Success) + return null; + + var value = server.Groups["server"].Value; + + // "nginx/1.24.0 (Ubuntu)" -> "nginx": the version is noise in a name. + var slash = value.IndexOf('/'); + + if (slash > 0) + value = value[..slash]; + + return Clean(value); + } + + /// + /// The part of a page title before its first separator. Titles are written for + /// people and routinely carry a tagline or a page name after the product — + /// "Forgejo: Beyond coding. We Forge." names a machine far worse than "Forgejo" + /// does. The remainder is dropped only when what precedes it can stand alone. + /// + private static string LeadingPhrase(string title) { + var cut = title.IndexOfAny([':', '|', '–', '—', '·', '»']); + + if (cut <= 0) + return title; + + var lead = title[..cut].Trim(); + + // "RackPeek" splits usefully; ": the homelab tool" does not, and a two-character + // lead is more likely a stray colon than a product name. + return lead.Length >= 3 ? lead : title; + } + + /// + /// Whether the response's status line is a 2xx or 3xx. A head with no recognisable + /// status line is treated as unsuccessful: the title of something that is not + /// plainly a working page is not worth naming a machine after. + /// + private static bool IsSuccessful(string responseHead) { + Match status = Regex.Match( + responseHead, + @"^HTTP/\d(?:\.\d)?\s+(?\d{3})", + RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); + + return status.Success + && int.TryParse(status.Groups["code"].Value, out var code) + && code is >= 200 and < 400; + } + + /// + /// Collapses whitespace, drops anything that is only punctuation or digits, and + /// rejects the placeholder names that would label half a rack identically. + /// + private static string? Clean(string? raw) { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var value = Regex.Replace(raw.Trim(), @"\s+", " ", RegexOptions.None, TimeSpan.FromSeconds(1)); + + if (value.Length is 0 or > 120) + return null; + + // A CN that is a bare number (some appliances ship a serial as the CN) or pure + // punctuation names nothing a person would recognise. + if (!value.Any(char.IsLetter)) + return null; + + return _uselessNames.Contains(value) ? null : value; + } +} diff --git a/RackPeek.Domain/Discovery/WellKnownPorts.cs b/RackPeek.Domain/Discovery/WellKnownPorts.cs index 6468ee18..84579d3b 100644 --- a/RackPeek.Domain/Discovery/WellKnownPorts.cs +++ b/RackPeek.Domain/Discovery/WellKnownPorts.cs @@ -20,4 +20,78 @@ public static class WellKnownPorts { 8443, // alt https 9100 // node-exporter / jetdirect ]; + + /// + /// The wider list a host is checked against once it has already proven it is + /// alive. Liveness is paid per address, so stays short; + /// this runs only on hosts that answered, where a dozen more connections cost + /// nothing and buy two things — a service on one of these ports is a strong + /// statement about what the machine is, and it is a target for the banner probes + /// that actually name it. + /// + public static readonly IReadOnlyList Identity = [ + .. Defaults, + 554, // rtsp — cameras + 1883, // mqtt — home automation brokers + 3000, // grafana / forgejo / many node apps + 3306, // mysql + 5432, // postgres + 5900, // vnc + 6379, // redis + 7860, // gradio / stable-diffusion + 8000, // alt http + 8096, // jellyfin + 8123, // home assistant + 9000, // portainer / minio + 9090, // prometheus / cockpit + 11434, // ollama + 32400 // plex + ]; + + /// + /// What a port conventionally carries, for naming the service found on it. A port + /// number is a convention rather than a guarantee, so this only ever supplies a + /// name — anything a service actually said about itself wins over it. + /// + private static readonly Dictionary _names = new() { + [21] = "ftp", + [22] = "ssh", + [23] = "telnet", + [25] = "smtp", + [53] = "dns", + [80] = "http", + [443] = "https", + [445] = "smb", + [554] = "rtsp", + [631] = "ipp", + [1883] = "mqtt", + [2375] = "docker", + [2376] = "docker", + [3000] = "http", + [3306] = "mysql", + [3389] = "rdp", + [5000] = "http", + [5432] = "postgres", + [5900] = "vnc", + [6379] = "redis", + [7860] = "http", + [8000] = "http", + [8006] = "proxmox", + [8080] = "http", + [8096] = "jellyfin", + [8123] = "home-assistant", + [8443] = "https", + [9000] = "http", + [9090] = "http", + [9100] = "jetdirect", + [11434] = "ollama", + [32400] = "plex" + }; + + /// + /// The conventional name for a port, or tcp-1234 when nobody curated one — + /// which still says more than the bare number, and stays stable across runs. + /// + public static string NameFor(int port) => + _names.TryGetValue(port, out var name) ? name : $"tcp-{port}"; } diff --git a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs index 747528eb..bb10cbd8 100644 --- a/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs +++ b/RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs @@ -161,7 +161,9 @@ public async Task Merge(string incomingYaml, MergeMode mode) { DiscoveryIdResolver.ResolveNames( resourceCollection.Resources, incomingResources, - incomingRoot.Connections); + incomingRoot.Connections, + resourceCollection.Connections, + true); List merged = ResourceCollectionMerger.Merge( resourceCollection.Resources, diff --git a/RackPeek.Domain/RackPeek.Domain.csproj b/RackPeek.Domain/RackPeek.Domain.csproj index 4b798b85..fa36412c 100644 --- a/RackPeek.Domain/RackPeek.Domain.csproj +++ b/RackPeek.Domain/RackPeek.Domain.csproj @@ -6,6 +6,13 @@ enable + + + + + diff --git a/RackPeek.Domain/Resources/Resource.cs b/RackPeek.Domain/Resources/Resource.cs index 74b417c3..0d957af8 100644 --- a/RackPeek.Domain/Resources/Resource.cs +++ b/RackPeek.Domain/Resources/Resource.cs @@ -59,6 +59,34 @@ public abstract class Resource { /// public string? DiscoveryId { get; set; } + /// + /// Whether a person chose this name. Set the moment anyone renames the resource, + /// and never unset. + /// + /// A discovered resource starts out named by whatever the collector could see, + /// which is often a placeholder derived from its own id. Later runs — or a + /// better collector — may learn the machine's real name, and should be able to + /// improve on a placeholder. They must never touch a name a person typed. + /// + /// + /// Absent means "not stated". A resource with no was + /// entered by hand and is therefore user-named whatever this says; see + /// . + /// + /// + public bool? UserNamed { get; set; } + + /// + /// Whether this resource's name is a person's choice and so off limits to + /// discovery. True when the flag says so, and true for anything with no + /// discovery id at all — nothing but a person could have written it. + /// + /// + /// A method rather than a property because everything public on a resource is + /// serialised, and this is derived from what is stored rather than part of it. + /// + public bool IsUserNamed() => UserNamed ?? string.IsNullOrWhiteSpace(DiscoveryId); + public string[] Tags { get; set; } = []; public Dictionary Labels { get; set; } = new(); public string? Notes { get; set; } diff --git a/RackPeek.Domain/Resources/Services/Service.cs b/RackPeek.Domain/Resources/Services/Service.cs index ce3401cd..fac7b5bf 100644 --- a/RackPeek.Domain/Resources/Services/Service.cs +++ b/RackPeek.Domain/Resources/Services/Service.cs @@ -1,30 +1,24 @@ -using System.Text; - namespace RackPeek.Domain.Resources.Services; public class Service : Resource { public const string KindLabel = "Service"; public Network? Network { get; set; } - public string NetworkString() { - if (Network == null) return string.Empty; - - if (!string.IsNullOrEmpty(Network.Url)) return Network.Url; - - var stringBuilder = new StringBuilder(); - if (!string.IsNullOrEmpty(Network.Ip)) { - stringBuilder.Append("Ip: "); - stringBuilder.Append(Network.Ip); - if (Network.Port.HasValue) { - stringBuilder.Append(':'); - stringBuilder.Append(Network.Port.Value); - } - - stringBuilder.Append(' '); - } - - return stringBuilder.ToString(); - } + /// + /// Where this service answers, for showing to a person. Display text, never a + /// link — is the link. + /// + public string NetworkString() => + !string.IsNullOrEmpty(Network?.Url) + ? Network.Url + : ServiceEndpoint.Describe(Network); + + /// + /// A link a browser can follow, or null when this port serves something a browser + /// cannot open. + /// + public string? BrowsableUrl(string? fallbackIp = null) => + ServiceEndpoint.BrowsableUrl(Network, fallbackIp); } public class Network { diff --git a/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs new file mode 100644 index 00000000..698c46c3 --- /dev/null +++ b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs @@ -0,0 +1,87 @@ +namespace RackPeek.Domain.Resources.Services; + +/// +/// Where a service answers, and whether a browser can do anything with it. +/// +public static class ServiceEndpoint { + /// + /// Ports a browser opens over TLS. Proxmox serves its management UI on 8006 and + /// redirects plain HTTP, so guessing http there costs the user a round trip. + /// + private static readonly HashSet _https = [443, 8006, 8443, 9443]; + + /// Ports a browser opens in the clear. + private static readonly HashSet _http = [ + 80, 631, 3000, 5000, 7860, 8000, 8080, 8096, 8123, 9000, 9090, 11434, 32400 + ]; + + /// + /// The address a service answers on — 10.0.50.105:3000 — for showing to a + /// person. Empty when there is no address to show. This is display text and never + /// a link: see for that. + /// + public static string Describe(Network? network) { + if (string.IsNullOrWhiteSpace(network?.Ip)) + return string.Empty; + + return network.Port.HasValue + ? $"{network.Ip}:{network.Port.Value}" + : network.Ip; + } + + /// + /// A link a browser can actually follow, or null when it cannot. + /// + /// A URL somebody typed always wins. Failing that the scheme has to be + /// inferred, and a port number is a convention rather than a promise — so this + /// answers only where the convention is a web one. SSH, SMB, MQTT, DNS and + /// anything uncurated get no link at all, which is more useful than an + /// http:// that cannot load: a dead link invites a click and wastes it. + /// + /// + /// protocol is only consulted when it names a scheme. Discovery writes + /// the transport there — TCP — which says nothing about what rides on + /// top of it. + /// + /// + public static string? BrowsableUrl(Network? network, string? fallbackIp = null) { + if (network == null) + return null; + + if (!string.IsNullOrWhiteSpace(network.Url)) + return network.Url; + + var ip = !string.IsNullOrWhiteSpace(network.Ip) ? network.Ip : fallbackIp; + + if (string.IsNullOrWhiteSpace(ip) || network.Port is not { } port) + return null; + + var scheme = SchemeFor(port, network.Protocol); + + if (scheme == null) + return null; + + try { + return new UriBuilder(scheme, ip) { Port = port }.Uri.ToString(); + } + catch (UriFormatException) { + // Whatever is in the address field, a person put it there by hand or a + // collector read it off a device, and neither is obliged to produce something + // a URL can be built from. A missing link costs a click; letting this escape + // would take down every page that renders the resource. + return null; + } + } + + private static string? SchemeFor(int port, string? protocol) { + var stated = protocol?.Trim().ToLowerInvariant(); + + if (stated is "http" or "https") + return stated; + + if (_https.Contains(port)) + return "https"; + + return _http.Contains(port) ? "http" : null; + } +} diff --git a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs index 17b636ba..43ab10aa 100644 --- a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs +++ b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs @@ -27,6 +27,10 @@ public async Task ExecuteAsync(string originalName, string newName) { throw new NotFoundException($"Resource '{originalName}' not found."); original.Name = newName; + + // A person has now chosen this name, so discovery must stop improving on it. + original.UserNamed = true; + await repo.UpdateAsync(original); IReadOnlyList allResources = await repo.GetAllOfTypeAsync(); diff --git a/RackPeek.Mcp/Tools/DiscoveryTools.cs b/RackPeek.Mcp/Tools/DiscoveryTools.cs index 4db8bbf7..83556517 100644 --- a/RackPeek.Mcp/Tools/DiscoveryTools.cs +++ b/RackPeek.Mcp/Tools/DiscoveryTools.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -79,9 +80,17 @@ ex is HttpRequestException or IOException or TimeoutException ? host.MachineId ?? host.Hostname : engine?.Id ?? client.Endpoint; + // No fallback to this machine's address: it is not the remote engine's, and + // stamping it on would give every service a confidently wrong one. var serviceIp = client.IsLocal ? host.Ip - : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip; + : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken); + + if (string.IsNullOrWhiteSpace(serviceIp)) + throw new ValidationException( + $"Could not determine an IPv4 address for {client.Endpoint}. Services are " + + "recorded at their host's address and the inventory holds IPv4 only; " + + "dial the engine by address instead, e.g. tcp://192.0.2.10:2375."); List found = DockerServiceMapper.ToResources(containers, seed, effectiveHost, serviceIp); @@ -125,7 +134,7 @@ public Task DiscoverProxmox( List resources; try { - resources = await ReadProxmoxAsync(client, cancellationToken); + resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken); } catch (HttpRequestException ex) { var hint = !insecure && ex.InnerException is System.Security.Authentication.AuthenticationException @@ -156,40 +165,6 @@ private async Task ReadHostAsync(CancellationToken cancellationToke }); } - /// Same read orchestration as `rpk discover proxmox`. - private static async Task> ReadProxmoxAsync( - IProxmoxClient client, - CancellationToken cancellationToken) { - var scope = await client.GetIdentityScopeAsync(cancellationToken); - IReadOnlyList listed = await client.GetNodesAsync(cancellationToken); - - var nodes = new List(); - var guests = new List(); - - foreach (ProxmoxNode listedNode in listed) { - ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken); - nodes.Add(node); - - foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) { - IReadOnlyList listedGuests = - await client.GetGuestsAsync(node.Name, endpoint, cancellationToken); - - ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g => - client.GetGuestConfigAsync(node.Name, endpoint, g.VmId, cancellationToken))); - - for (var i = 0; i < listedGuests.Count; i++) - guests.Add(listedGuests[i] with { - Os = configs[i].Os, - Ip = configs[i].Ip, - Disks = configs[i].DiskBytes, - PassthroughAddresses = configs[i].PassthroughAddresses - }); - } - } - - return ProxmoxResourceMapper.ToResources(scope, nodes, guests); - } - private async Task EmitAsync(List resources, int skipped, bool apply) { var yaml = DiscoveryDocument.ToYaml(resources); diff --git a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json index 78a1349a..05c463e7 100644 --- a/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json @@ -68,6 +68,10 @@ "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk is the format version, so the way the id is derived can change without old ids being mistaken for new ones.", "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$" }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, "tags": { "type": "array", "items": { diff --git a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json index 78a1349a..05c463e7 100644 --- a/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json +++ b/RackPeek.Web/wwwroot/schemas/v4/schema.v4.json @@ -68,6 +68,10 @@ "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk is the format version, so the way the id is derived can change without old ids being mistaken for new ones.", "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$" }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, "tags": { "type": "array", "items": { diff --git a/Shared.Rcl/CliBootstrap.cs b/Shared.Rcl/CliBootstrap.cs index fa120baf..cb8de342 100644 --- a/Shared.Rcl/CliBootstrap.cs +++ b/Shared.Rcl/CliBootstrap.cs @@ -806,6 +806,11 @@ public static void BuildApp(CommandApp app) { .WithExample("discover", "proxmox", "--host", "https://pve.lan:8006", "--insecure") .WithExample("discover", "proxmox", "--host", "pve.lan", "--push"); + discover.AddCommand("opnsense") + .WithDescription("Read an OPNsense firewall's neighbour table and emit every machine on it.") + .WithExample("discover", "opnsense", "--host", "https://firewall.lan", "--insecure") + .WithExample("discover", "opnsense", "--host", "firewall.lan", "--push"); + discover.AddCommand("network") .WithDescription("Sweep a subnet and emit every answering host as a System resource.") .WithExample("discover", "network") diff --git a/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs index 0005537d..e3587ca1 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs @@ -86,10 +86,22 @@ ex is HttpRequestException or IOException or TimeoutException : engine?.Id ?? client.Endpoint; // Published ports live on the engine host, so a remote service's address is the - // endpoint the user dialled — the local probe's address is only the last resort. + // endpoint the user dialled. There is deliberately no fallback: this machine's + // own address is not the remote engine's, and stamping it on would put a + // confidently wrong address on every service — one that then flows into the + // ansible, ssh and hosts exports. var serviceIp = client.IsLocal ? host.Ip - : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip; + : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken); + + if (string.IsNullOrWhiteSpace(serviceIp)) { + AnsiConsole.MarkupLine( + $"[red]Could not determine an IPv4 address for {Markup.Escape(client.Endpoint)}.[/] " + + "Services are recorded at their host's address, and the inventory holds IPv4 only. " + + "Dial the engine by address instead, e.g. --docker-host tcp://192.0.2.10:2375"); + + return 1; + } List services = DockerServiceMapper.ToResources(containers, seed, hostName, serviceIp); diff --git a/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs index 374a069d..ec164089 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs @@ -28,6 +28,10 @@ public sealed class DiscoverNetworkSettings : DiscoverSettings { [Description("How many hosts to probe at once.")] public int Parallel { get; init; } = 128; + [CommandOption("--no-identify")] + [Description("Skip asking living hosts what they are — sweep for liveness only.")] + public bool NoIdentify { get; init; } + /// The parsed --cidr, or null when it was omitted or does not parse. public NetworkCidr? ParsedCidr => NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null; @@ -128,7 +132,8 @@ protected override async Task ExecuteAsync( Cidr = cidr, Ports = settings.ResolvedPorts, PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout), - Concurrency = settings.Parallel + Concurrency = settings.Parallel, + IdentifyServices = !settings.NoIdentify }; var targets = NetworkScanner.EnumerateTargets(cidr).Count(); diff --git a/Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs new file mode 100644 index 00000000..f81ed7b6 --- /dev/null +++ b/Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs @@ -0,0 +1,100 @@ +using System.ComponentModel; +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Shared.Rcl.Commands.Discovery; + +public sealed class DiscoverOpnsenseSettings : DiscoverSettings { + [CommandOption("--host ")] + [Description("OPNsense host, e.g. https://firewall.lan. A bare host name gets https.")] + public string? Host { get; init; } + + [CommandOption("--key ")] + [Description("API key. Defaults to RPK_OPN_KEY.")] + public string? Key { get; init; } + + [CommandOption("--secret ")] + [Description("API secret. Defaults to RPK_OPN_SECRET.")] + public string? Secret { get; init; } + + [CommandOption("--insecure")] + [Description("Accept a self-signed certificate, which OPNsense ships with by default.")] + public bool Insecure { get; init; } + + [CommandOption("--include-public")] + [Description("Also record neighbours on public addresses, such as the ISP equipment on the WAN leg.")] + public bool IncludePublic { get; init; } + + public string? ResolvedKey => + DiscoveryPublisher.Resolve(Key, OpnsenseApiClient.KeyEnvironmentVariable); + + public string? ResolvedSecret => + DiscoveryPublisher.Resolve(Secret, OpnsenseApiClient.SecretEnvironmentVariable); + + public override ValidationResult Validate() { + if (string.IsNullOrWhiteSpace(Host)) + return ValidationResult.Error("Pass --host, e.g. --host https://firewall.lan"); + + if (string.IsNullOrWhiteSpace(ResolvedKey)) + return ValidationResult.Error( + $"No API key. Pass --key or set {OpnsenseApiClient.KeyEnvironmentVariable}."); + + if (string.IsNullOrWhiteSpace(ResolvedSecret)) + return ValidationResult.Error( + $"No API secret. Pass --secret or set {OpnsenseApiClient.SecretEnvironmentVariable}."); + + return base.Validate(); + } +} + +/// +/// Reads an OPNsense firewall's neighbour table and emits every machine on it. +/// +/// The collector for everything a sweep can see but not identify. ARP is +/// link-local, so sweeping from one host yields a MAC only for that host's own +/// segment and an address for everything else — and an address alone cannot +/// survive a DHCP re-lease or be matched to anything. The firewall routes every +/// subnet, so its table has the MAC for all of them. +/// +/// +public sealed class DiscoverOpnsenseCommand : AsyncCommand { + protected override async Task ExecuteAsync( + CommandContext context, + DiscoverOpnsenseSettings settings, + CancellationToken cancellationToken) { + using var client = new OpnsenseApiClient( + settings.Host!, + settings.ResolvedKey!, + settings.ResolvedSecret!, + settings.Insecure); + + List resources; + + try { + resources = await OpnsenseDiscovery.ReadAsync(client, settings.IncludePublic, cancellationToken); + } + catch (Exception ex) when ( + ex is HttpRequestException or IOException or TimeoutException + || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) { + AnsiConsole.MarkupLine( + $"[red]Could not read {Markup.Escape(client.Endpoint)}.[/] {Markup.Escape(ex.Message)}"); + + // A self-signed certificate is the other common cause, and its message is + // opaque enough to be worth naming. + if (!settings.Insecure && ex is HttpRequestException { StatusCode: null }) + AnsiConsole.MarkupLine( + "[grey]If the firewall uses its own certificate, add --insecure.[/]"); + + return 1; + } + + if (resources.Count == 0) + AnsiConsole.MarkupLine( + "[grey]The firewall's ARP table is empty. It only holds neighbours it has " + + "spoken to recently, so this is normal on a quiet network.[/]"); + + return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken); + } +} diff --git a/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs index 5103835e..e5c29a48 100644 --- a/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs +++ b/Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs @@ -73,7 +73,7 @@ protected override async Task ExecuteAsync( List resources; try { - resources = await ReadAsync(client, cancellationToken); + resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken); } catch (HttpRequestException ex) { AnsiConsole.MarkupLine( @@ -98,45 +98,4 @@ protected override async Task ExecuteAsync( return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken); } - - private static async Task> ReadAsync( - IProxmoxClient client, - CancellationToken cancellationToken) { - var scope = await client.GetIdentityScopeAsync(cancellationToken); - IReadOnlyList listed = await client.GetNodesAsync(cancellationToken); - - var nodes = new List(); - var guests = new List(); - - foreach (ProxmoxNode listedNode in listed) { - // Node detail needs a broader permission than listing guests does, so it is - // enrichment rather than a requirement — a read-only token still gets a tree. - ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken); - nodes.Add(node); - - var nodeName = node.Name; - - foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) { - IReadOnlyList listedGuests = - await client.GetGuestsAsync(nodeName, endpoint, cancellationToken); - - // The list call knows nothing about the OS, and for a container it does - // not know the address either. Both live in the guest's own config — one - // call per guest, so they run concurrently rather than one at a time. - ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g => - client.GetGuestConfigAsync(nodeName, endpoint, g.VmId, cancellationToken))); - - for (var i = 0; i < listedGuests.Count; i++) - guests.Add(listedGuests[i] with { - Os = configs[i].Os, - Ip = configs[i].Ip, - Disks = configs[i].DiskBytes, - PassthroughAddresses = configs[i].PassthroughAddresses, - Macs = configs[i].Macs ?? [] - }); - } - } - - return ProxmoxResourceMapper.ToResources(scope, nodes, guests); - } } diff --git a/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor b/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor index fc84725f..34476716 100644 --- a/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor +++ b/Shared.Rcl/Components/HardwareDependencyTreeComponent.razor @@ -55,6 +55,7 @@ else case Service service: var endpoint = service.NetworkString(); + var browsable = service.BrowsableUrl(); @@ -69,13 +70,34 @@ else @if (!string.IsNullOrWhiteSpace(endpoint)) { - - - @endpoint - + + @if (!string.IsNullOrWhiteSpace(browsable)) + + { + + + + @endpoint + + + + } + + else + + { + + @endpoint + + } } @@ -107,10 +129,7 @@ else { var endpoint = service.NetworkString(); - if (string.IsNullOrWhiteSpace(endpoint)) - return null; - - return endpoint; + return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint; } } diff --git a/Shared.Rcl/Services/ServiceCardComponent.razor b/Shared.Rcl/Services/ServiceCardComponent.razor index 502fa60e..871abd0b 100644 --- a/Shared.Rcl/Services/ServiceCardComponent.razor +++ b/Shared.Rcl/Services/ServiceCardComponent.razor @@ -461,27 +461,10 @@ Nav.NavigateTo($"resources/services/{Uri.EscapeDataString(newName)}"); } - private string? GetBrowsableHref() - { - var ip = Service.Network?.Ip ?? EffectiveIp; - var port = Service.Network?.Port; - - if (string.IsNullOrWhiteSpace(ip) || port is null) - return null; - - var proto = Service.Network?.Protocol?.Trim().ToLowerInvariant(); - - var scheme = proto switch - { - "https" => "https", - "http" => "http", - _ => "http" - }; - - // Build a correct absolute URL - var ub = new UriBuilder(scheme, ip) { Port = port.Value }; - return ub.Uri.ToString(); - } + // A port is a convention, not a promise: ssh on 22 is not browsable and an http:// + // link to it only wastes a click. The domain owns that judgement so this page, the + // dependency trees and anything else agree on it. + private string? GetBrowsableHref() => Service.BrowsableUrl(EffectiveIp); } @code diff --git a/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor b/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor index c1144dc2..d70e72cc 100644 --- a/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor +++ b/Shared.Rcl/Systems/SystemDependencyTreeComponent.razor @@ -18,6 +18,7 @@ else { case Service service: var endpoint = service.NetworkString(); + var browsable = service.BrowsableUrl(); @@ -32,13 +33,34 @@ else @if (!string.IsNullOrWhiteSpace(endpoint)) { - - - @endpoint - + + @if (!string.IsNullOrWhiteSpace(browsable)) + + { + + + + @endpoint + + + + } + + else + + { + + @endpoint + + } } @@ -83,10 +105,7 @@ else { var endpoint = service.NetworkString(); - if (string.IsNullOrWhiteSpace(endpoint)) - return null; - - return endpoint; + return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint; } } \ No newline at end of file diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md index 915ff824..8aaf5b66 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md @@ -242,6 +242,7 @@ - [system](/docs/cli-commands#rpk-discover-system) - Inspect this machine and emit it as a System resource - [docker](/docs/cli-commands#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this - [proxmox](/docs/cli-commands#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems + - [opnsense](/docs/cli-commands#rpk-discover-opnsense) - Read an OPNsense firewall's neighbour table and emit every machine on it - [network](/docs/cli-commands#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource - [ansible](/docs/cli-commands#rpk-ansible) - Generate and manage Ansible inventory - [inventory](/docs/cli-commands#rpk-ansible-inventory) - Generate an Ansible inventory diff --git a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index 39622303..ed1a7f27 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md @@ -3966,11 +3966,14 @@ OPTIONS: -h, --help Prints help information COMMANDS: - system Inspect this machine and emit it as a System resource - docker Read the Docker API and emit each published container as a - Service on this host's System - proxmox Read a Proxmox cluster and emit its nodes and guests as Systems - network Sweep a subnet and emit every answering host as a System resource + system Inspect this machine and emit it as a System resource + docker Read the Docker API and emit each published container as a + Service on this host's System + proxmox Read a Proxmox cluster and emit its nodes and guests as Systems + opnsense Read an OPNsense firewall's neighbour table and emit every + machine on it + network Sweep a subnet and emit every answering host as a System + resource ``` ## `rpk discover system` @@ -4061,6 +4064,38 @@ OPTIONS: Proxmox ships with by default ``` +## `rpk discover opnsense` +``` +DESCRIPTION: +Read an OPNsense firewall's neighbour table and emit every machine on it + +USAGE: + rpk discover opnsense [OPTIONS] + +EXAMPLES: + rpk discover opnsense --host https://firewall.lan --insecure + rpk discover opnsense --host firewall.lan --push + +OPTIONS: + -h, --help Prints help information + --push Upload the result to a RackPeek server instead of + printing it + --server RackPeek server to upload to. Defaults to the + RPK_SERVER environment variable + --api-key API key for the server. Defaults to the RPK_API_KEY + environment variable + --dry-run Ask the server what would change, without changing + anything. Implies --push + --host OPNsense host, e.g. https://firewall.lan. A bare + host name gets https + --key API key. Defaults to RPK_OPN_KEY + --secret API secret. Defaults to RPK_OPN_SECRET + --insecure Accept a self-signed certificate, which OPNsense + ships with by default + --include-public Also record neighbours on public addresses, such as + the ISP equipment on the WAN leg +``` + ## `rpk discover network` ``` DESCRIPTION: @@ -4090,6 +4125,8 @@ OPTIONS: e.g. 22,80,443. Defaults to a curated homelab list --timeout Milliseconds to wait on each port probe --parallel How many hosts to probe at once + --no-identify Skip asking living hosts what they are — sweep for + liveness only ``` ## `rpk ansible` diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index a4f20723..c0fb12fc 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -8,7 +8,8 @@ don't have to type in what the machine already knows about itself. | `rpk discover system` | the machine it runs on | one **System** resource | | `rpk discover docker` | the Docker Engine API | one **Service** per published container, plus the **System** they run on | | `rpk discover proxmox` | a Proxmox VE cluster | a **Server** and **System** per node, a **System** per guest, already wired together | -| `rpk discover network` | a subnet, from outside | one **System** per host that answers ping or a well-known TCP port | +| `rpk discover opnsense` | an OPNsense firewall's neighbour table | one **System** per machine it has seen, on every subnet it routes | +| `rpk discover network` | a subnet, from outside | one **System** per host that answers, plus a **Service** for each web application it recognises | Both print YAML to standard output by default and change nothing, so it is always safe to run one and look at the result first. @@ -60,13 +61,41 @@ stored locally, from any machine you run the command on. What that buys you: * **Renaming is safe.** Call it `storage-01` in the web UI and the next discovery run - updates `storage-01`. It will never rename a resource you named. + updates `storage-01`. It will never rename a resource you named — see + [Names get better until you choose one](#names-get-better-until-you-choose-one). * **Existing resources are adopted.** If you already documented `nas01` by hand, the first discovery run attaches to it — keeping your notes and gaining an id — rather than creating a duplicate. * **Two machines cannot collide.** A second machine that happens to share a hostname is given a suffixed name instead of overwriting the first. +### Names get better until you choose one + +A collector names a card from whatever it could see, and when it could see nothing the +name falls back to a slug of the card's own id — `host-1a2b3c4d` says only that something +is there. A later run, or a collector that can see more, often *does* know the machine's +name: a firewall knows what it handed out over DHCP, a hypervisor knows what its guest is +called. So a placeholder gets replaced by a real name when one turns up, and the +inventory improves as you point more collectors at it. + +The moment you rename a resource yourself, that stops. The rename records a `userNamed` +flag, and nothing in discovery touches the name again: + +```yaml +- kind: System + name: the-blue-one + userNamed: true + discoveryId: rpk1:net:9f2c1a7e40b3d582 +``` + +Two rules keep this from turning into churn. The upgrade only ever goes from a +placeholder to a real name, never the other way: a sweep that runs after the firewall +knows *less*, and must not undo it. And one real name never replaces another — two +collectors that each knew a different name for one box would otherwise rename it back and +forth on every run, so the first real name wins and stays. A resource with no +`discoveryId` at all is user-named whatever the flag says; nothing but a person could +have written it. + > **Cloned VM templates share `/etc/machine-id`.** If you clone a Proxmox or VMware > template without resetting it, every clone reports the same identity. RackPeek rejects > a payload containing duplicate ids rather than silently merging the machines. Run @@ -227,10 +256,10 @@ and most installations keep it. **A node becomes two resources**, because it is two things: -* a **Server** named after the node (`kepler`) — the machine, carrying its processor +* a **Server** named after the node (`pve-node-01`) — the machine, carrying its processor (model, cores and threads per socket), memory, physical disks with Proxmox's own nvme/ssd/hdd classification, and its GPUs; -* a **System** of type `hypervisor` (`kepler-pve`) — the Proxmox install running on that +* a **System** of type `hypervisor` (`pve-node-01-pve`) — the Proxmox install running on that machine, carrying the PVE version. Guests then run on the hypervisor, giving the full Hardware → System → System tree that @@ -238,7 +267,7 @@ the graph views are built around. ```yaml - kind: Server - name: kepler + name: pve-node-01 cpus: - model: AMD Ryzen 5 5600G cores: 6 @@ -252,14 +281,14 @@ the graph views are built around. - model: GeForce RTX 3090 - model: GeForce RTX 3090 - kind: System - name: kepler-pve + name: pve-node-01-pve type: hypervisor os: Proxmox VE 8.2.2 - runsOn: [kepler] + runsOn: [pve-node-01] - kind: System name: docker-01 type: vm - runsOn: [kepler-pve] + runsOn: [pve-node-01-pve] ``` Each QEMU guest becomes a `vm` and each LXC guest a `container`, with its allocated @@ -291,7 +320,7 @@ visible from either end: type: vm labels: gpu: GeForce RTX 3090, GeForce RTX 3090 - runsOn: [kepler-pve] + runsOn: [pve-node-01-pve] ``` A label rather than a field, because RackPeek has no first-class way to say "this device @@ -319,11 +348,111 @@ with no cluster uses its node name as the scope instead. --- +### Where a guest's address comes from + +Proxmox only records an address in a guest's config when someone set one statically, so +on a DHCP estate the config knows nothing. The guest itself does, and will say so: a VM +through its **qemu-guest-agent**, a container through its running interfaces. Discovery +asks every *running* guest, which is one extra call per guest and nothing at all for one +that is switched off. + +A guest that runs containers has several interfaces — Docker's `docker0` and its +per-network bridges, Home Assistant's `hassio`, any VPN tunnel — and recording +`172.17.0.1` as the machine's address would be worse than recording nothing, because +every container host on the estate reports the same one. So the NIC MACs Proxmox assigned +are used as the discriminator: an interface carrying one is a NIC the hypervisor gave the +guest, anything else is something the guest invented. Where the MACs cannot be read, +nothing is claimed. + +No agent, a stopped guest, or a token without `VM.Monitor` all mean the same thing — +the address stays unknown, exactly as before. + +**Why it matters beyond the address itself.** A guest's address is what lets +`rpk discover network` recognise it. ARP is link-local, so a sweep of any subnet but its +own gets no MAC and can only identify a host by address; once the hypervisor has reported +that same address, the sweep's find is matched to the guest the hypervisor already +described in full rather than becoming a second, emptier card beside it. + +--- + +## `rpk discover opnsense` + +The collector for everything a sweep can see but not identify. + +ARP is link-local. Sweeping from one machine gets you a MAC for that machine's own +segment and nothing but an address for every other subnet — and an address alone cannot +survive a DHCP re-lease or be matched against anything else you have documented. The +firewall routes every subnet, so its neighbour table has the MAC for all of them, plus +the name it handed out and which leg it was seen on. + +```bash +# Look at what the firewall knows +rpk discover opnsense --host https://firewall.lan --insecure + +# Merge it into your server +rpk discover opnsense --host firewall.lan --push +``` + +| Option | Meaning | +|---|---| +| `--host ` | The firewall. A bare name gets `https`. | +| `--key ` | API key. Defaults to `RPK_OPN_KEY`. | +| `--secret ` | API secret. Defaults to `RPK_OPN_SECRET`. | +| `--insecure` | Accept the self-signed certificate OPNsense ships with. | +| `--include-public` | Also record neighbours on public addresses (see below). | + +Plus the same `--push` / `--server` / `--api-key` / `--dry-run` options as every other +collector. + +Create the credentials in the firewall under **System → Access → Users**, on a user that +holds the **Diagnostics: ARP Table** privilege. Read-only is enough; nothing here writes. + +### What it records, and what it leaves out + +One **System** per machine, carrying its address, its MAC, and the vendor that MAC +belongs to. + +What it does *not* record is which of the firewall's legs the machine answered on. That +describes the firewall's wiring rather than the machine, and it changes the moment +anything is re-cabled or a VLAN is renamed — where the address and the MAC are facts +about the machine itself. + +Left out on purpose: + +* **The firewall's own addresses.** Every routed subnet contributes one and they are all + the same box — which is a Firewall, not the handful of Systems this would invent. +* **Entries that have aged out.** They say where something used to be. +* **Broadcast and multicast addresses**, which no machine owns. +* **Neighbours on public addresses**, such as the ISP equipment on the WAN leg. They are + not your infrastructure, and recording one would put a public address into a file you + may well commit. Pass `--include-public` if you are documenting a fleet that lives on + them. + +A machine answering on two of the firewall's legs is one card, not two: its identity is +its MAC. + +### Why it lines up with everything else + +A card from the firewall is seeded exactly as `rpk discover network` seeds its own — +keyed on the MAC — because both describe the same thing by the same evidence: a machine +observed on the network rather than asked about itself. So a host the firewall knows and +a host a sweep found are **one card**, whichever collector ran first, with no special +case anywhere to say so. Run both and the firewall fills in the identity a sweep of a +routed subnet could never get. + +Order does not matter for names either. A card a sweep could only call `host-` +takes the name the firewall handed out over DHCP as soon as you run this collector — see +[Names get better until you choose one](#names-get-better-until-you-choose-one). A name +you chose yourself is never touched. + +--- + ## `rpk discover network` The collector for machines nothing else can describe: no agent, no API — just an -address that answers. It sweeps a subnet and emits one **System** per responding host, -with its IP, its reverse-DNS name, and its MAC address as a label. +address that answers. It sweeps a subnet and emits one **System** per responding host — +with its IP, a name, its MAC address, and the vendor that MAC belongs to — plus a +**Service** for each web application that names itself. ```bash # Sweep this machine's own subnet and look at the result @@ -342,6 +471,105 @@ proxmox, and friends); `--ports 22,80,443` narrows or widens it. The ports are o liveness check: the sweep records that the host exists, not what it serves — pair it with `rpk discover docker` or hand-written Service cards for that. +### Where a scanned host's name comes from + +A homelab rarely has PTR records for everything, and a page of `host-1a2b3c4d` cards is +not documentation. So once a host is known to be alive, the sweep asks it what it is, +taking the first answer from: + +1. **A reverse-DNS (PTR) record** — the network's own answer, so it always wins. +2. **A TLS certificate's Common Name**, read from 443, 8006 or 8443. The strongest + remaining evidence, because appliances ship a certificate naming themselves: a + Proxmox node presents `CN=pve-node-01.example.com`, OPNsense presents its hostname. + The certificate is read, never trusted — self-signed is the norm here. +3. **An SSH greeting** on 22, reduced to the software (`SSH-2.0-dropbear` → `dropbear`). + That names what the host runs rather than the host, which still separates an + embedded appliance from a general-purpose box. +4. **An HTTP page title or `Server` header**, on the usual web ports and on anything else + found open — how a Home Assistant or a Forgejo announces itself. Titles are trimmed to + the phrase before their first separator, so "Forgejo: Beyond coding. We Forge." names a + machine `forgejo`, and the titles of error pages are ignored entirely. + +Whatever answered is recorded in an `identified-by` label (`tls:8006 pve-node-01.example.com`) +so you can see where a name came from and judge it. Nothing is sent to the host beyond a +bare `GET /`, and a host that stays silent simply keeps its generated name. + +Identification costs a handful of short connections per *living* host — never per +address — and `--no-identify` turns it off for a pure liveness sweep. + +### Open ports + +The liveness sweep stops at the first answer, because it only needs to know the host +exists. Once a host has answered, it is checked against a wider list — cameras (554), +MQTT brokers (1883), Home Assistant (8123), Portainer (9000), Plex, Postgres and so on. + +### Every open port becomes a Service + +Something listening on a port is a service, and a service is a resource in its own right +rather than a note on the host. Each open port therefore gets its own **Service** card, +named for the host and what that port serves: + +```yaml +- kind: System + ip: 192.0.2.204 + name: nebula + labels: + identified-by: http:8123 Home Assistant +- kind: Service + name: nebula-ssh + network: { ip: 192.0.2.204, port: 22, protocol: TCP } + runsOn: [nebula] +- kind: Service + name: home-assistant + network: { ip: 192.0.2.204, port: 8123, protocol: TCP } + runsOn: [nebula] +``` + +This replaces the old `open-ports` label, which was a comma-separated string nothing +could link to, filter on, or hang a note from. + +What the service said about itself wins over what its port number implies, because a +port is a convention and an answer is evidence: `8123` is Home Assistant by convention, +but a page that says "Forgejo" is the machine telling you. Where nothing answered, the +name falls back to the host plus the port's usual service — `nebula-ssh`, `nebula-https`, +`nebula-smb` — and an unrecognised port keeps its number as `nebula-tcp-9987`, which is +an honest record rather than a guess. + +One case is deliberately special: an appliance whose management page just says its own +name back. A firewall already called `opnsense` gains an `opnsense-https`, not a second +card called `opnsense`. + +Each port keeps its own stable id, so a rescan updates these cards rather than +duplicating them. A port is also the best possible target for the banner probes above, +which is how `9000` became "Portainer". Note that this is what answered out of the ports +probed, not a full port scan; `--ports` widens the set. + +This is also why a page title does not name the *machine*: picking whichever application +answered first would be arbitrary, and a certificate is the only answer that is a claim +about the machine itself. + +### NIC vendor from the MAC + +Where the sweep has a MAC, the card also gets a `nic-vendor` label naming the organisation +that OUI belongs to — `Espressif`, `Ubiquiti`, `Raspberry Pi`, `Proxmox`. For a silent +device with no PTR record and no web UI, this is often the only thing that distinguishes +it from an address. + +It is named for what it actually is. The OUI identifies whoever owns the network +interface, which is not the same claim as who made the machine: a Proxmox guest's virtual +NIC reads `Proxmox` while the box underneath it is a Dell. + +Two further details worth knowing. A hypervisor's own prefix wins over the +locally-administered bit, so a KVM guest reads as `QEMU/KVM` rather than anonymous. And +an address a device made up for itself — modern phones and laptops randomise per network +for privacy — is reported as `Randomised (locally administered)`, because the OUI half of +such an address names nobody and a lookup would otherwise attribute it to whichever +company happens to own the matching block. + +The table is a curated subset of the IEEE registry covering the gear that turns up on a +homelab, not all 40,000 assignments; an unknown prefix yields no label rather than a +guess. Run `./generate-oui-table.py` against the registry to extend it. + Sweeps are capped at a /16 (65,534 addresses). `--timeout` and `--parallel` tune how patient and how aggressive the sweep is; the defaults finish a quiet /24 in seconds. @@ -349,8 +577,8 @@ A network of several VLANs is several sweeps — each merges into the same inven and the ids keep re-runs honest: ```bash -rpk discover network --cidr 10.0.20.0/24 --push # the LAN -rpk discover network --cidr 10.0.50.0/24 --push # the server VLAN +rpk discover network --cidr 192.168.10.0/24 --push # the LAN +rpk discover network --cidr 192.168.50.0/24 --push # the server VLAN ``` ### Identity @@ -383,8 +611,9 @@ the same card**, whichever ran first: - Agent first: a later sweep recognises the box and just refreshes its address — never touching the identity or anything you or the agent wrote. -The card keeps whatever name it already had (names are always user-owned), so a -scan-first card keeps its generated `host-…` name until you rename it once. A MAC that +The card keeps whatever name it already had, except that a generated `host-…` name gives +way to a real one when a collector turns up knowing it, and a name you chose never does. +A MAC that two stored cards both claim unifies nothing — ambiguity always falls back to separate cards — and agent-grade identities never unify with each other on a MAC alone (cloned VMs can share one; that is what machine-ids are for). The machine running the sweep diff --git a/Tests.Discovery/Fixtures/opnsense-arp.json b/Tests.Discovery/Fixtures/opnsense-arp.json new file mode 100644 index 00000000..139d6f10 --- /dev/null +++ b/Tests.Discovery/Fixtures/opnsense-arp.json @@ -0,0 +1,86 @@ +[ + { + "mac": "00:50:c2:00:1a:01", + "ip": "198.51.100.7", + "intf": "igb0", + "expired": false, + "expires": -1, + "permanent": false, + "type": "ethernet", + "manufacturer": "Cisco Systems", + "hostname": "", + "intf_description": "WAN" + }, + { + "mac": "00:1b:21:00:1a:02", + "ip": "192.0.2.1", + "intf": "igb1", + "expired": false, + "expires": -1, + "permanent": true, + "type": "ethernet", + "manufacturer": "Intel Corporate", + "hostname": "", + "intf_description": "LAB" + }, + { + "mac": "1c:6a:1b:00:1a:03", + "ip": "192.168.10.150", + "intf": "igb1_vlan20", + "expired": false, + "expires": 1132, + "permanent": false, + "type": "ethernet", + "manufacturer": "Ubiquiti Inc", + "hostname": "ap-outdoor", + "intf_description": "home" + }, + { + "mac": "bc:24:11:00:1a:04", + "ip": "192.168.50.105", + "intf": "igb1_vlan50", + "expired": false, + "expires": 980, + "permanent": false, + "type": "ethernet", + "manufacturer": "Proxmox Server Solutions GmbH", + "hostname": "forgejo", + "intf_description": "HomeServices" + }, + { + "mac": "aa:bb:cc:00:1a:05", + "ip": "192.168.10.99", + "intf": "igb1_vlan20", + "expired": true, + "expires": -1, + "permanent": false, + "type": "ethernet", + "manufacturer": "", + "hostname": "gone-away", + "intf_description": "home" + }, + { + "mac": "ff:ff:ff:ff:ff:ff", + "ip": "192.168.10.255", + "intf": "igb1_vlan20", + "expired": false, + "expires": -1, + "permanent": false, + "type": "ethernet", + "manufacturer": "", + "hostname": "", + "intf_description": "home" + }, + { + "mac": "1c:6a:1b:00:1a:03", + "ip": "192.168.30.150", + "intf": "igb1_vlan30", + "expired": false, + "expires": 900, + "permanent": false, + "type": "ethernet", + "manufacturer": "Ubiquiti Inc", + "hostname": "ap-outdoor", + "intf_description": "IOT" + } +] diff --git a/Tests.Discovery/Fixtures/pve-agent-interfaces.json b/Tests.Discovery/Fixtures/pve-agent-interfaces.json new file mode 100644 index 00000000..d3a69277 --- /dev/null +++ b/Tests.Discovery/Fixtures/pve-agent-interfaces.json @@ -0,0 +1,36 @@ +{ + "data": { + "result": [ + { + "name": "lo", + "hardware-address": "00:00:00:00:00:00", + "ip-addresses": [ + { "ip-address": "127.0.0.1", "ip-address-type": "ipv4", "prefix": 8 }, + { "ip-address": "::1", "ip-address-type": "ipv6", "prefix": 128 } + ] + }, + { + "name": "ens18", + "hardware-address": "bc:24:11:00:1a:01", + "ip-addresses": [ + { "ip-address": "192.0.2.105", "ip-address-type": "ipv4", "prefix": 24 }, + { "ip-address": "fe80::be24:11ff:fe00:1a01", "ip-address-type": "ipv6", "prefix": 64 } + ] + }, + { + "name": "docker0", + "hardware-address": "02:42:9a:11:22:33", + "ip-addresses": [ + { "ip-address": "172.17.0.1", "ip-address-type": "ipv4", "prefix": 16 } + ] + }, + { + "name": "br-0d764d475870", + "hardware-address": "02:42:7e:44:55:66", + "ip-addresses": [ + { "ip-address": "172.18.0.1", "ip-address-type": "ipv4", "prefix": 16 } + ] + } + ] + } +} diff --git a/Tests.Discovery/Fixtures/pve-lxc-interfaces.json b/Tests.Discovery/Fixtures/pve-lxc-interfaces.json new file mode 100644 index 00000000..d0458057 --- /dev/null +++ b/Tests.Discovery/Fixtures/pve-lxc-interfaces.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "name": "lo", + "hwaddr": "00:00:00:00:00:00", + "inet": "127.0.0.1/8", + "inet6": "::1/128" + }, + { + "name": "eth0", + "hwaddr": "bc:24:11:00:1a:09", + "inet": "192.0.2.150/24", + "inet6": "fe80::be24:11ff:fe00:1a09/64" + } + ] +} diff --git a/Tests.Discovery/IpBridgeSymmetryTests.cs b/Tests.Discovery/IpBridgeSymmetryTests.cs new file mode 100644 index 00000000..425f09f8 --- /dev/null +++ b/Tests.Discovery/IpBridgeSymmetryTests.cs @@ -0,0 +1,171 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Servers; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// The address bridge has to read the same whichever collector ran first. +/// +/// ARP is link-local, so a sweep of a routed subnet gets an address and no MAC, +/// and the card it writes can only be called host-<hash>. A hypervisor +/// knows that guest by name, by specification, and by the address it holds — so +/// the address is the one thing tying the two together. Whether the sweep or the +/// hypervisor got there first is an accident of what the person typed, and must +/// not decide whether the inventory ends up with one card or two. +/// +/// +/// A live run of nine subnets found this the hard way: sweeping before running +/// the Proxmox and firewall collectors produced fourteen machines twice over, +/// where the same commands in the other order produced one card each. +/// +/// +public class IpBridgeSymmetryTests { + private const string _ip = "10.0.50.105"; + private const string _mac = "bc:24:11:00:4a:01"; + + /// What a sweep of a routed subnet can write: an address, and nothing else. + private static SystemResource ScanCard(string? ip = _ip) => new() { + Kind = SystemResource.KindLabel, + Name = "host-2ed3bfd7", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"ip:{ip}"), + Ip = ip + }; + + /// What the hypervisor knows about the same box. + private static SystemResource GuestCard(string name = "forgejo", string? ip = _ip) => new() { + Kind = SystemResource.KindLabel, + Name = name, + DiscoveryId = DiscoveryId.Create(ProxmoxResourceMapper.Scheme, "vmid-105"), + Type = "vm", + Os = "Linux", + Ip = ip, + Labels = { ["macs"] = _mac } + }; + + private static void Resolve(List existing, List incoming) => + DiscoveryIdResolver.ResolveNames(existing, incoming, null, null, true); + + [Fact] + public void The_hypervisor_claims_the_card_a_sweep_left_behind() { + // Sweep ran first. This is the direction that was silently broken. + List existing = [ScanCard()]; + List incoming = [GuestCard()]; + + Resolve(existing, incoming); + + // One card, not two: the guest lands on the stored one. + Assert.Equal(existing[0].Name, incoming[0].Name); + } + + [Fact] + public void The_sweep_lands_on_the_card_the_hypervisor_left_behind() { + // The direction that already worked, kept honest. + List existing = [GuestCard()]; + List incoming = [ScanCard()]; + + Resolve(existing, incoming); + + Assert.Equal("forgejo", incoming[0].Name); + } + + [Fact] + public void The_agent_identity_wins_whichever_way_round_it_arrives() { + // Arriving second, the hypervisor's id survives so the merge can upgrade the + // stored card from a stand-in to a real identity. + List incoming = [GuestCard()]; + Resolve([ScanCard()], incoming); + Assert.StartsWith("rpk1:pve:", incoming[0].DiscoveryId); + + // Arriving second, the sweep's id is dropped so the merge cannot downgrade it. + incoming = [ScanCard()]; + Resolve([GuestCard()], incoming); + Assert.Null(incoming[0].DiscoveryId); + } + + [Fact] + public void The_placeholder_name_gives_way_to_the_real_one() { + // The point of unifying: the machine stops being a hash. This is the upgrade + // that could never fire while the cards stayed separate. + List existing = [ScanCard()]; + + Resolve(existing, [GuestCard()]); + + Assert.Equal("forgejo", existing[0].Name); + } + + [Fact] + public void Two_stand_ins_at_one_address_still_unify_nothing() { + // Neither side knows anything the other does not, so an address proves nothing. + List existing = [ScanCard()]; + SystemResource other = ScanCard(); + other.Name = "host-9999aaaa"; + other.DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "ip:other"); + + Resolve(existing, [other]); + + Assert.Equal("host-9999aaaa", other.Name); + } + + [Fact] + public void Two_agent_grade_cards_at_one_address_still_unify_nothing() { + // A guest and the machine-id of the OS inside it are both agent-grade. They are + // two cards on purpose; an address must not collapse them. + var fromAgent = new SystemResource { + Kind = SystemResource.KindLabel, + Name = "forgejo-inside", + DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"), + Ip = _ip + }; + + Resolve([GuestCard()], [fromAgent]); + + Assert.Equal("forgejo-inside", fromAgent.Name); + } + + [Fact] + public void A_sweep_that_saw_a_mac_is_left_to_the_mac_bridge() { + // A MAC is better evidence than an address, whichever side is holding it. A scan + // card with one either unified on the MAC already or genuinely disagrees. + SystemResource scan = ScanCard(); + scan.Labels["mac"] = "bc:24:11:00:4a:99"; + + Resolve([scan], [GuestCard()]); + + Assert.Equal("host-2ed3bfd7", scan.Name); + } + + [Fact] + public void A_different_kind_of_thing_at_the_same_address_is_not_the_same_thing() { + // The box documented as hardware and the OS a sweep saw on it are separate cards + // on purpose — the merge replaces on a type change, so unifying would delete one. + var server = new Server { + Kind = Server.KindLabel, + Name = "kepler", + DiscoveryId = DiscoveryId.Create(ProxmoxResourceMapper.Scheme, "node-kepler") + }; + + List existing = [ScanCard()]; + + Resolve(existing, [server]); + + Assert.Equal("kepler", server.Name); + Assert.Equal("host-2ed3bfd7", existing[0].Name); + } + + [Fact] + public void An_address_two_stored_cards_claim_unifies_nothing() { + // Overlapping subnets, or a stale card. Ambiguity is not evidence. + SystemResource first = ScanCard(); + SystemResource second = ScanCard(); + second.Name = "host-bbbbcccc"; + second.DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "ip:dup"); + + List incoming = [GuestCard()]; + + Resolve([first, second], incoming); + + Assert.Equal("forgejo", incoming[0].Name); + } +} diff --git a/Tests.Discovery/MacVendorLookupTests.cs b/Tests.Discovery/MacVendorLookupTests.cs new file mode 100644 index 00000000..768f2709 --- /dev/null +++ b/Tests.Discovery/MacVendorLookupTests.cs @@ -0,0 +1,96 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// The vendor a MAC implies. Every expectation here is a real IEEE assignment, and +/// several are addresses observed on a live homelab — a lookup that silently drifted +/// would be worse than no lookup, because a confident wrong vendor is unfalsifiable +/// to whoever reads the card. +/// +public class MacVendorLookupTests { + [Theory] + [InlineData("bc:24:11:00:1a:01", "Proxmox")] + [InlineData("a0:36:9f:00:1a:02", "Intel")] + [InlineData("1c:6a:1b:00:1a:03", "Ubiquiti")] + [InlineData("48:b0:2d:00:1a:04", "NVIDIA")] + [InlineData("80:f3:da:00:1a:06", "Espressif")] + [InlineData("b8:27:eb:11:22:33", "Raspberry Pi")] + [InlineData("00:0c:29:aa:bb:cc", "VMware")] + public void Assigned_prefixes_resolve_to_their_owner(string mac, string expected) => + Assert.Equal(expected, MacVendorLookup.Lookup(mac)); + + [Theory] + [InlineData("BC:24:11:14:59:DF")] + [InlineData("bc-24-11-14-59-df")] + [InlineData("bc2411145 9df")] + [InlineData("bc24.1114.59df")] + public void Separators_and_case_do_not_matter(string mac) => + Assert.Equal("Proxmox", MacVendorLookup.Lookup(mac)); + + [Fact] + // KVM mints guest addresses from the locally-administered range rather than buying + // an OUI. Reporting "randomised" here would hide the most useful fact about the + // host — that it is a virtual machine. + public void A_hypervisors_own_prefix_beats_the_locally_administered_bit() => + Assert.Equal("QEMU/KVM", MacVendorLookup.Lookup("52:54:00:12:34:56")); + + [Theory] + [InlineData("92:16:01:00:1a:07")] + [InlineData("6a:34:ce:00:1a:08")] + public void Self_assigned_addresses_are_reported_as_randomised(string mac) { + // A phone or laptop randomising per network. The OUI half names no one, so + // saying so beats reporting whichever company owns the matching block. + Assert.Equal("Randomised (locally administered)", MacVendorLookup.Lookup(mac)); + Assert.True(MacVendorLookup.IsLocallyAdministered(mac)); + } + + [Fact] + public void A_universally_administered_address_is_not_flagged_as_randomised() => + Assert.False(MacVendorLookup.IsLocallyAdministered("bc:24:11:00:1a:01")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-mac")] + [InlineData("bc:24")] + public void Unreadable_input_yields_no_vendor(string? mac) { + Assert.Null(MacVendorLookup.Lookup(mac)); + Assert.False(MacVendorLookup.IsLocallyAdministered(mac)); + } + + [Fact] + // 00:00:00 is assigned to Xerox and deliberately not in the curated table. Null + // means "not known", which is honest; a nearest-match would not be. + public void An_unlisted_but_assigned_prefix_is_null_rather_than_a_guess() => + Assert.Null(MacVendorLookup.Lookup("00:00:00:11:22:33")); + + [Fact] + public void Every_packed_record_resolves_to_a_real_vendor_name() { + // Guards the generated table's index encoding: an off-by-one in the index char + // would map prefixes to the wrong vendor, or off the end of the array. Also + // proves the records are sorted, which the binary search depends on. + var records = MacVendorTable.Records; + var count = records.Length / MacVendorTable.RecordLength; + + Assert.True(count > 1000, $"The table holds only {count} records — did generation fail?"); + + string? previous = null; + + for (var i = 0; i < count; i++) { + var prefix = records.Substring(i * MacVendorTable.RecordLength, 6); + + if (previous != null) + Assert.True( + string.CompareOrdinal(previous, prefix) < 0, + $"Records are not sorted ascending: {previous} precedes {prefix}."); + + previous = prefix; + + Assert.False( + string.IsNullOrWhiteSpace(MacVendorLookup.Lookup(prefix + "000000")), + $"{prefix} resolved to nothing."); + } + } +} diff --git a/Tests.Discovery/NetworkIdentityTests.cs b/Tests.Discovery/NetworkIdentityTests.cs new file mode 100644 index 00000000..2e137db0 --- /dev/null +++ b/Tests.Discovery/NetworkIdentityTests.cs @@ -0,0 +1,217 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// How a swept host gets a name and a vendor. Sweeping a homelab that has no PTR +/// records used to produce a page of "host-<hash>" cards; these tests own the +/// rules that turn those into recognisable machines. +/// +public class NetworkIdentityTests { + private static NetworkHostFact Host( + string ip, + string? mac = null, + string? hostname = null, + ServiceIdentity? identity = null, + string? vendor = null, + IReadOnlyList? services = null) => + new(ip, mac, hostname, true, []) { + Identity = identity, + Vendor = vendor, + Services = services ?? [] + }; + + private static SystemResource Single(params NetworkHostFact[] hosts) => + Assert.IsType(Assert.Single(NetworkScanMapper.ToResources(hosts))); + + [Fact] + public void A_certificate_name_becomes_the_card_name() { + SystemResource card = Single(Host( + "192.0.2.13", + identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006))); + + // The label is the host part, as it is for a PTR name. + Assert.Equal("pve-node-01", card.Name); + } + + [Fact] + public void A_ptr_record_still_wins_over_a_service_banner() { + // The network's own answer beats whatever a certificate happens to say — a + // stale or copied certificate must never rename a host DNS already knows. + SystemResource card = Single(Host( + "192.0.2.13", + hostname: "pve-01.lan", + identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006))); + + Assert.Equal("pve-01", card.Name); + } + + [Fact] + public void Without_any_name_the_card_still_falls_back_to_the_hash() { + SystemResource card = Single(Host("192.0.2.111")); + + Assert.StartsWith("host-", card.Name); + } + + [Fact] + public void What_the_host_said_is_recorded_even_when_it_named_the_card() { + SystemResource card = Single(Host( + "192.0.2.204", + identity: new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123))); + + // Whoever reads the card can see the name came from an HTTP title on 8123 and + // judge it accordingly, rather than trusting a name of unknown provenance. + Assert.Equal("http:8123 Home Assistant", card.Labels["identified-by"]); + } + + [Fact] + public void An_ssh_greeting_annotates_but_never_names() { + // Every Linux box on a subnet runs the same daemon. Naming from the greeting + // produced five cards called "openssh" on a real sweep — no more use than five + // called "host-", and misleading about what was actually identified. + SystemResource card = Single(Host( + "192.0.2.209", + identity: new ServiceIdentity("OpenSSH", IdentitySource.SshBanner, 22))); + + Assert.StartsWith("host-", card.Name); + Assert.Equal("ssh:22 OpenSSH", card.Labels["identified-by"]); + } + + [Fact] + public void An_application_that_named_itself_over_http_becomes_a_service_on_its_host() { + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)]) + ]); + + SystemResource host = Assert.Single(cards.OfType()); + Service service = Assert.Single(cards.OfType()); + + Assert.Equal("home-assistant", service.Name); + Assert.Equal([host.Name], service.RunsOn); + Assert.Equal("192.0.2.204", service.Network?.Ip); + Assert.Equal(8123, service.Network?.Port); + } + + [Fact] + public void A_page_title_names_the_service_and_leaves_the_host_a_hash() { + // A host may run several applications, so naming the machine after whichever + // answered first is arbitrary. Only a certificate names the machine itself. + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)]) + ]); + + Assert.StartsWith("host-", Assert.Single(cards.OfType()).Name); + } + + [Fact] + public void Several_applications_on_one_host_each_get_their_own_service() { + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.105", services: [ + new ServiceIdentity("Portainer", IdentitySource.Http, 9000), + new ServiceIdentity("Grafana", IdentitySource.Http, 3000) + ]) + ]); + + var services = cards.OfType().ToList(); + + Assert.Equal(2, services.Count); + Assert.Equal([9000, 3000], services.Select(s => s.Network!.Port)); + // Each port is its own thing, so each keeps its own stable id. + Assert.Equal(2, services.Select(s => s.DiscoveryId).Distinct().Count()); + } + + [Fact] + public void An_appliances_own_management_page_is_named_for_the_port_it_serves() { + // A firewall whose page says "OPNsense" on a card already called opnsense would + // otherwise gain a second card named opnsense-, saying nothing new. The + // port is what distinguishes the service from the box it runs on. + List cards = NetworkScanMapper.ToResources([ + Host( + "192.0.2.101", + identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443), + services: [new ServiceIdentity("OPNsense", IdentitySource.Http, 80)]) + ]); + + Assert.Equal("opnsense", Assert.Single(cards.OfType()).Name); + Assert.Equal("opnsense-http", Assert.Single(cards.OfType()).Name); + } + + [Fact] + public void A_services_id_survives_a_rescan_and_differs_per_port() { + NetworkHostFact host = Host( + "192.0.2.105", + "bc:24:11:00:1a:01", + services: [ + new ServiceIdentity("Portainer", IdentitySource.Http, 9000), + new ServiceIdentity("Grafana", IdentitySource.Http, 3000) + ]); + + var first = NetworkScanMapper.ToResources([host]).OfType().Select(s => s.DiscoveryId).ToList(); + var second = NetworkScanMapper.ToResources([host]).OfType().Select(s => s.DiscoveryId).ToList(); + + Assert.Equal(first, second); + Assert.Equal(2, first.Distinct().Count()); + } + + [Fact] + public void A_vendor_is_labelled_when_the_mac_is_known() { + SystemResource card = Single(Host("192.0.2.64", "80:f3:da:00:1a:06", vendor: "Espressif")); + + Assert.Equal("Espressif", card.Labels["nic-vendor"]); + Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]); + } + + [Fact] + public void No_vendor_label_is_invented_when_none_is_known() { + SystemResource card = Single(Host("192.0.2.111", "00:00:00:11:22:33")); + + Assert.False(card.Labels.ContainsKey("nic-vendor")); + } + + [Fact] + public void The_card_stays_sparse_whatever_identification_found() { + // The sparseness contract: a scan sees an address, never an OS or a core count. + // Anything written here would overwrite the real values on the next rescan of a + // card an agent collector has since filled in. + SystemResource card = Single(Host( + "192.0.2.13", + "bc:24:11:00:1a:01", + identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006), + vendor: "Proxmox")); + + Assert.Null(card.Type); + Assert.Null(card.Os); + Assert.Null(card.Cores); + Assert.Null(card.Ram); + Assert.Equal("192.0.2.13", card.Ip); + } + + [Fact] + public void Identity_does_not_move_a_hosts_discovery_id() { + // Identity is seeded on the MAC; a name learned from a service must not change + // it, or every card would be recreated the day someone fixes a certificate. + NetworkHostFact withoutName = Host("192.0.2.13", "bc:24:11:00:1a:01"); + NetworkHostFact withName = Host( + "192.0.2.13", + "bc:24:11:00:1a:01", + identity: new ServiceIdentity("pve-node-01", IdentitySource.TlsCertificate, 8006)); + + Assert.Equal( + Single(withoutName).DiscoveryId, + Single(withName).DiscoveryId); + } + + [Fact] + public void Two_hosts_naming_themselves_the_same_still_get_distinct_names() { + // Every OPNsense VLAN gateway presents the same certificate CN. + List cards = NetworkScanMapper.ToResources([ + Host("192.0.2.101", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443)), + Host("192.0.2.141", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443)) + ]); + + Assert.Equal(2, cards.Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } +} diff --git a/Tests.Discovery/NetworkScannerTests.cs b/Tests.Discovery/NetworkScannerTests.cs index b34b2946..c576cf7d 100644 --- a/Tests.Discovery/NetworkScannerTests.cs +++ b/Tests.Discovery/NetworkScannerTests.cs @@ -9,14 +9,175 @@ namespace Tests.Discovery; /// The probe is the IO seam — everything above it is what these tests own. /// public class NetworkScannerTests { + // Identification is off unless a test asks for it, so the liveness tests keep + // measuring only liveness. private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) => new() { Cidr = Cidr.Parse(cidr), Ports = ports.Length > 0 ? ports : [22, 80], PingTimeout = TimeSpan.FromMilliseconds(5), - PortTimeout = TimeSpan.FromMilliseconds(5) + PortTimeout = TimeSpan.FromMilliseconds(5), + IdentifyServices = false }; + private static NetworkScanOptions IdentifyingOptions(string cidr = "10.0.0.0/30") => + Options(cidr) with { + IdentifyServices = true, + IdentifyTimeout = TimeSpan.FromMilliseconds(5) + }; + + [Fact] + public async Task The_port_list_the_liveness_check_cut_short_is_finished_for_the_living() { + var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + probe, + IdentifyingOptions() with { Ports = [22, 80] }); + + // Liveness stopped at 22; the interrogation pass goes back for the rest, so the + // card records what the host actually serves rather than the first thing tried. + Assert.Equal([22, 80], Assert.Single(hosts).OpenPorts); + } + + [Fact] + public async Task A_host_that_answered_ping_still_gets_its_ports_inventoried() { + // Liveness skips port probing entirely once ping answers, which used to leave + // every pingable host with no port evidence at all. + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 80)] + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + probe, + IdentifyingOptions() with { Ports = [22, 80] }); + + Assert.Equal([80], Assert.Single(hosts).OpenPorts); + } + + [Fact] + public async Task A_dead_host_is_never_interrogated() { + var probe = new ScriptedProbe(); + + await NetworkScanner.ScanAsync(probe, IdentifyingOptions() with { Ports = [22, 80] }); + + Assert.Empty(probe.IdentityProbes); + } + + [Fact] + public async Task Only_living_hosts_are_asked_what_they_are() { + // 443 has to be open for it to be asked: only ports the sweep found listening + // are interrogated, so a closed port costs no handshake. + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 443)], + TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + Assert.Equal("router.lan", Assert.Single(hosts).Identity?.Name); + // 10.0.0.2 answered nothing, so it was never asked. + Assert.DoesNotContain(probe.IdentityProbes, p => p.Ip == "10.0.0.2"); + } + + [Fact] + public async Task A_certificate_names_the_host_even_when_other_ports_also_answer() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 443), ("10.0.0.1", 22)], + TlsSubjects = { [("10.0.0.1", 443)] = "CN=pve-node-01.example.com" }, + Banners = { [("10.0.0.1", 22)] = "SSH-2.0-OpenSSH_9.6" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + NetworkHostFact host = Assert.Single(hosts); + // Every open port is asked — an SSH greeting is still worth recording — but a + // certificate is the only answer that is a claim about the machine itself. + Assert.Equal("pve-node-01.example.com", host.Identity?.Name); + Assert.Equal(IdentitySource.TlsCertificate, host.Identity?.Source); + } + + [Fact] + public async Task A_page_title_outranks_an_ssh_greeting() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + OpenPorts = [("10.0.0.1", 22), ("10.0.0.1", 80)], + Banners = { [("10.0.0.1", 22)] = "SSH-2.0-dropbear" }, + HttpHeads = { [("10.0.0.1", 80)] = "HTTP/1.0 200 OK\r\n\r\nHome Assistant" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + // Every Linux box answers SSH with the same daemon name; the title says which + // box this actually is, so SSH is asked last. + Assert.Equal("Home Assistant", Assert.Single(hosts).Identity?.Name); + } + + [Fact] + public async Task An_open_port_the_sweep_found_is_also_asked() { + // The liveness sweep already paid to learn 8123 was open. A service on a port + // nobody curated is exactly the one worth asking. + var probe = new ScriptedProbe { + OpenPorts = [("10.0.0.2", 8123)], + HttpHeads = { [("10.0.0.2", 8123)] = "HTTP/1.0 200 OK\r\n\r\nHome Assistant" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync( + probe, + IdentifyingOptions() with { Ports = [8123] }); + + NetworkHostFact host = Assert.Single(hosts); + Assert.Equal("Home Assistant", host.Identity?.Name); + Assert.Equal(8123, host.Identity?.Port); + } + + [Fact] + public async Task A_host_that_says_nothing_is_still_reported() { + var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions()); + + NetworkHostFact host = Assert.Single(hosts); + Assert.Null(host.Identity); + Assert.Equal("10.0.0.1", host.Ip); + } + + [Fact] + public async Task Turning_identification_off_asks_nothing() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" } + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Null(Assert.Single(hosts).Identity); + Assert.Empty(probe.IdentityProbes); + } + + [Fact] + public async Task A_hosts_vendor_comes_from_its_arp_mac() { + var probe = new ScriptedProbe { + PingReplies = ["10.0.0.1"], + Arp = "? (10.0.0.1) at bc:24:11:00:1a:01 on en0 ifscope [ethernet]" + }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Equal("Proxmox", Assert.Single(hosts).Vendor); + } + + [Fact] + public async Task A_host_with_no_arp_entry_has_no_vendor() { + var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] }; + + IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options()); + + Assert.Null(Assert.Single(hosts).Vendor); + } + [Fact] public async Task A_host_that_answers_nothing_is_not_reported() { var probe = new ScriptedProbe(); @@ -118,8 +279,13 @@ private sealed class ScriptedProbe : INetworkProbe { public Dictionary Names { get; } = []; public TimeSpan PingDelay { get; init; } = TimeSpan.Zero; + public Dictionary<(string Ip, int Port), string> TlsSubjects { get; } = []; + public Dictionary<(string Ip, int Port), string> Banners { get; } = []; + public Dictionary<(string Ip, int Port), string> HttpHeads { get; } = []; + public List<(string Ip, int Port)> PortProbes { get; } = []; public List DnsLookups { get; } = []; + public List<(string Ip, int Port, string Kind)> IdentityProbes { get; } = []; public bool IsSupported => true; public int MaxInFlight { get; private set; } public bool ArpReadAfterSweep { get; private set; } @@ -178,6 +344,43 @@ public Task TryConnectAsync( return Task.FromResult(Names.GetValueOrDefault(ip)); } + public Task ReadTlsSubjectAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + lock (_lock) { + IdentityProbes.Add((ip, port, "tls")); + } + + return Task.FromResult(TlsSubjects.GetValueOrDefault((ip, port))); + } + + public Task ReadTcpBannerAsync( + string ip, + int port, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + lock (_lock) { + IdentityProbes.Add((ip, port, "banner")); + } + + return Task.FromResult(Banners.GetValueOrDefault((ip, port))); + } + + public Task ReadHttpHeadAsync( + string ip, + int port, + bool tls, + TimeSpan timeout, + CancellationToken cancellationToken = default) { + lock (_lock) { + IdentityProbes.Add((ip, port, "http")); + } + + return Task.FromResult(HttpHeads.GetValueOrDefault((ip, port))); + } + public Cidr? LocalSubnet() => null; } } diff --git a/Tests.Discovery/OpenPortServiceTests.cs b/Tests.Discovery/OpenPortServiceTests.cs new file mode 100644 index 00000000..0f6b4013 --- /dev/null +++ b/Tests.Discovery/OpenPortServiceTests.cs @@ -0,0 +1,115 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// An open port is a service, not a note on the host. +/// +/// It used to land in an "open-ports" label — a comma-separated string that +/// nothing could link to, filter on, or hang a note from. RackPeek already has a +/// resource for "a thing listening on an address and a port", so a sweep that +/// finds 22 open on nebula should produce a Service called nebula-ssh running on +/// nebula, exactly as if someone had written it in by hand. +/// +/// +public class OpenPortServiceTests { + private static List Scan(string ip, string? mac, params int[] ports) => + NetworkScanMapper.ToResources([ + new NetworkHostFact(ip, mac, "nebula", true, ports) + ]); + + private static List Services(params int[] ports) => + Scan("192.0.2.20", "bc:24:11:00:2a:01", ports).OfType().ToList(); + + [Fact] + public void An_open_port_is_no_longer_a_label() { + SystemResource host = Scan("192.0.2.20", "bc:24:11:00:2a:01", 22, 80) + .OfType() + .Single(); + + Assert.False(host.Labels.ContainsKey("open-ports")); + } + + [Fact] + public void Each_open_port_becomes_a_service_on_the_host() { + List services = Services(22, 80); + + Assert.Equal(2, services.Count); + Assert.All(services, s => Assert.Equal(["nebula"], s.RunsOn)); + } + + [Fact] + public void A_service_is_named_for_the_host_and_what_the_port_serves() { + Service ssh = Assert.Single(Services(22)); + + Assert.Equal("nebula-ssh", ssh.Name); + } + + [Theory] + [InlineData(443, "nebula-https")] + [InlineData(445, "nebula-smb")] + [InlineData(1883, "nebula-mqtt")] + [InlineData(8006, "nebula-proxmox")] + [InlineData(32400, "nebula-plex")] + public void Well_known_ports_are_named_by_what_they_serve(int port, string expected) => + Assert.Equal(expected, Assert.Single(Services(port)).Name); + + [Fact] + // Better an honest tcp-9987 than a guess: the number is the only fact available. + public void An_unrecognised_port_keeps_its_number() => + Assert.Equal("nebula-tcp-9987", Assert.Single(Services(9987)).Name); + + [Fact] + public void A_service_that_named_itself_beats_what_its_port_implies() { + // A port is a convention and an answer is evidence. Home Assistant on 8123 is + // the convention; a page that says "Forgejo" is the machine telling you. + List resources = NetworkScanMapper.ToResources([ + new NetworkHostFact("192.0.2.21", "bc:24:11:00:2a:02", "nebula", true, [8123]) { + Services = [new ServiceIdentity("Forgejo", IdentitySource.Http, 8123)] + } + ]); + + Assert.Equal("forgejo", Assert.Single(resources.OfType()).Name); + } + + [Fact] + public void A_service_records_where_it_is_listening() { + Service ssh = Assert.Single(Services(22)); + + Assert.Equal("192.0.2.20", ssh.Network?.Ip); + Assert.Equal(22, ssh.Network?.Port); + Assert.Equal("TCP", ssh.Network?.Protocol); + } + + [Fact] + public void A_services_identity_is_its_hosts_identity_and_the_port() { + // Stable across rescans, and distinct per port, so a rescan updates the same two + // cards rather than inventing a pair every time. + var first = Services(22, 80).Select(s => s.DiscoveryId).ToList(); + var second = Services(22, 80).Select(s => s.DiscoveryId).ToList(); + + Assert.Equal(first, second); + Assert.Equal(2, first.Distinct().Count()); + } + + [Fact] + public void Two_hosts_running_the_same_thing_get_distinct_cards() { + // Naming after the host is what keeps these apart — "ssh" alone would collide on + // every machine in the rack. + List resources = NetworkScanMapper.ToResources([ + new NetworkHostFact("192.0.2.30", "bc:24:11:00:2a:03", "nebula", true, [22]), + new NetworkHostFact("192.0.2.31", "bc:24:11:00:2a:04", "orion", true, [22]) + ]); + + Assert.Equal( + ["nebula-ssh", "orion-ssh"], + resources.OfType().Select(s => s.Name).Order()); + } + + [Fact] + public void A_host_with_nothing_listening_yields_no_services() => + Assert.Empty(Scan("192.0.2.40", "bc:24:11:00:2a:05").OfType()); +} diff --git a/Tests.Discovery/OpnsenseDiscoveryTests.cs b/Tests.Discovery/OpnsenseDiscoveryTests.cs new file mode 100644 index 00000000..2173e5ec --- /dev/null +++ b/Tests.Discovery/OpnsenseDiscoveryTests.cs @@ -0,0 +1,177 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// A firewall's neighbour table in, System resources out. +/// +/// This is the collector for everything a sweep can see but not identify. ARP is +/// link-local, so sweeping from one host yields a MAC for that host's own segment +/// and an address for everything else — and an address alone cannot survive a +/// DHCP re-lease or be matched to anything. The firewall routes every subnet, so +/// its table has the MAC, the name it handed out, and which leg it was seen on. +/// +/// +public class OpnsenseDiscoveryTests { + private static List Neighbours() => + OpnsenseResponseParser.ParseArp(Fixture.Read("opnsense-arp.json")); + + private static List Discover(bool includePublic = false) => + OpnsenseDiscovery.ToResources(Neighbours(), includePublic); + + [Fact] + public void The_table_yields_one_neighbour_per_machine() { + List neighbours = Neighbours(); + + // Of seven rows: one is the firewall's own address, one has aged out, one is the + // broadcast address, and one is a second sighting of a host that answers on two + // of the firewall's legs. + Assert.Equal(3, neighbours.Count); + } + + [Fact] + // Every routed subnet contributes one, and they are all the same box — which is a + // Firewall, not the handful of Systems this would otherwise invent. + public void An_address_the_firewall_holds_itself_is_not_a_neighbour() => + Assert.DoesNotContain(Neighbours(), n => n.Mac == "00:1b:21:00:1a:02"); + + [Fact] + // It says where something used to be, which is not evidence that it is there now. + public void An_entry_that_has_aged_out_is_not_a_neighbour() => + Assert.DoesNotContain(Neighbours(), n => n.Hostname == "gone-away"); + + [Fact] + public void The_broadcast_address_is_not_a_machine() => + Assert.DoesNotContain(Neighbours(), n => n.Mac.StartsWith("ff:", StringComparison.Ordinal)); + + [Fact] + // Its identity is the MAC, so two sightings must not become two machines. + public void A_host_seen_on_two_legs_is_still_one_card() => + Assert.Single(Discover().OfType(), s => s.Labels["mac"] == "1c:6a:1b:00:1a:03"); + + [Fact] + // It is not the user's infrastructure, and recording it would put a public address + // into a file people commit. + public void The_isps_equipment_on_the_wan_leg_is_left_out_by_default() => + Assert.DoesNotContain(Discover().OfType(), s => s.Ip == "198.51.100.7"); + + [Fact] + // A fleet documented on public addresses is a real case; it just is not the default. + public void Public_neighbours_can_be_asked_for() => + Assert.Contains(Discover(true).OfType(), s => s.Ip == "198.51.100.7"); + + [Theory] + [InlineData("10.0.0.5")] + [InlineData("172.16.4.9")] + [InlineData("172.31.255.254")] + [InlineData("192.168.1.1")] + [InlineData("100.64.0.1")] // carrier-grade NAT + public void Private_and_carrier_ranges_count_as_ones_own_network(string ip) => + Assert.True(OpnsenseDiscovery.IsPrivate(ip)); + + [Theory] + [InlineData("198.51.100.7")] + [InlineData("8.8.8.8")] + [InlineData("172.15.0.1")] // just below the private block + [InlineData("172.32.0.1")] // just above it + [InlineData("100.63.0.1")] // just below the carrier block + [InlineData("not-an-address")] + public void Everything_else_does_not(string ip) => + Assert.False(OpnsenseDiscovery.IsPrivate(ip)); + + [Fact] + // The whole point: a sweep of another subnet can only call this host-. + public void The_name_the_firewall_handed_out_becomes_the_card_name() => + Assert.Contains(Discover().OfType(), s => s.Name == "forgejo"); + + [Fact] + public void A_neighbour_with_no_name_still_gets_a_stable_one() { + List cards = Discover(true); + + SystemResource wan = cards.OfType().Single(s => s.Ip == "198.51.100.7"); + Assert.StartsWith("host-", wan.Name); + } + + [Fact] + public void A_card_carries_the_mac_and_who_made_the_nic() { + SystemResource card = Discover().OfType().Single(s => s.Name == "forgejo"); + + Assert.Equal("bc:24:11:00:1a:04", card.Labels["mac"]); + Assert.Equal("Proxmox", card.Labels["nic-vendor"]); + Assert.Equal("192.168.50.105", card.Ip); + } + + [Fact] + public void Which_leg_of_the_firewall_it_answered_on_is_not_recorded() { + // It describes the firewall's wiring, not the machine, and it changes the moment + // anything is re-cabled or a VLAN is renamed. + SystemResource card = Discover().OfType().Single(s => s.Name == "forgejo"); + + Assert.False(card.Labels.ContainsKey("segment")); + } + + [Fact] + public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() { + // Ours is a curated subset, so it answers "Proxmox" where the firewall says + // "Proxmox Server Solutions GmbH" — but the firewall carries the whole registry + // and answers for prefixes ours has never heard of. + List cards = Discover(true); + + SystemResource wan = cards.OfType().Single(s => s.Ip == "198.51.100.7"); + Assert.Equal("Cisco Systems", wan.Labels["nic-vendor"]); + } + + [Fact] + public void A_card_is_seeded_exactly_as_a_sweep_would_seed_it() { + // The contract that makes the two collectors interchangeable: same evidence, same + // identity, so a host the firewall knows and a host a sweep found are one card + // whichever ran first — with no special case anywhere to say so. + SystemResource fromFirewall = Discover().OfType().Single(s => s.Name == "forgejo"); + + SystemResource fromSweep = NetworkScanMapper.ToResources([ + new NetworkHostFact("192.168.50.105", "bc:24:11:00:1a:04", null, true, []) + ]).OfType().Single(); + + Assert.Equal(fromSweep.DiscoveryId, fromFirewall.DiscoveryId); + } + + [Fact] + public void The_cards_stay_sparse() { + // The firewall knows where a machine is and what its NIC is, never what runs on + // it. A guess here would overwrite the real values on the next run of a collector + // that does know. + SystemResource card = Discover().OfType().Single(s => s.Name == "forgejo"); + + Assert.Null(card.Type); + Assert.Null(card.Os); + Assert.Null(card.Cores); + Assert.Null(card.Ram); + } + + [Fact] + public void The_paginated_shape_reads_the_same_as_the_flat_one() { + // The search wrapper returns {rows: [...]} while the direct call returns a bare + // array; a caller should not have to know which endpoint answered. + var rows = $$"""{"rows": {{Fixture.Read("opnsense-arp.json")}}, "total": 7}"""; + + Assert.Equal( + Neighbours().Select(n => n.Mac), + OpnsenseResponseParser.ParseArp(rows).Select(n => n.Mac)); + } + + [Theory] + [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":"1"}]""")] + [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":1}]""")] + [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":true}]""")] + public void A_flag_is_read_however_the_firmware_spells_it(string json) => + // OPNsense writes these as real booleans in some versions and as "1" in others. + Assert.Empty(OpnsenseResponseParser.ParseArp(json)); + + [Fact] + public void An_empty_table_is_not_an_error() { + Assert.Empty(OpnsenseResponseParser.ParseArp("[]")); + Assert.Empty(OpnsenseResponseParser.ParseArp("""{"rows":[],"total":0}""")); + } +} diff --git a/Tests.Discovery/PlaceholderNameUpgradeTests.cs b/Tests.Discovery/PlaceholderNameUpgradeTests.cs new file mode 100644 index 00000000..423257f9 --- /dev/null +++ b/Tests.Discovery/PlaceholderNameUpgradeTests.cs @@ -0,0 +1,321 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Connections; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// A placeholder name is a stand-in, and a later collector that knows the machine's +/// real name should be allowed to replace it. +/// +/// A sweep of a routed subnet can see that something answers and nothing else, so +/// it writes host-1a2b3c4d. Run the firewall collector and that same machine has +/// a DHCP name; ask the hypervisor and it has a guest name. Without this the +/// inventory would keep the hash for ever and the better name would be discarded +/// on every run. +/// +/// +/// The line it must not cross is a name a person typed. That is what +/// records, and once set it is never unset. +/// +/// +public class PlaceholderNameUpgradeTests { + private const string _mac = "bc:24:11:00:3a:01"; + + private static string PlaceholderFor(string discoveryId) => + $"host-{DiscoveryId.ShortSuffix(discoveryId)}"; + + /// A sweep's card: an address, a MAC, and a name that is just its own hash. + private static SystemResource Stored(string? name = null, bool? userNamed = null) { + var id = DiscoveryId.Create(DiscoveryId.NetworkScheme, _mac); + + return new SystemResource { + Kind = SystemResource.KindLabel, + Name = name ?? PlaceholderFor(id), + DiscoveryId = id, + UserNamed = userNamed, + Ip = "192.0.2.50", + Labels = { ["mac"] = _mac } + }; + } + + /// The firewall's view of the same box: same identity, but it knows the name. + private static SystemResource Incoming(string name = "forgejo") => new() { + Kind = SystemResource.KindLabel, + Name = name, + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, _mac), + Ip = "192.0.2.50", + Labels = { ["mac"] = _mac } + }; + + private static List Resolve( + Resource stored, + Resource incoming, + IReadOnlyList? storedConnections = null, + params Resource[] alsoStored) { + List existing = [stored, .. alsoStored]; + + DiscoveryIdResolver.ResolveNames( + existing, + [incoming], + null, + storedConnections, + true); + + return existing; + } + + [Fact] + public void A_real_name_replaces_a_placeholder_nobody_chose() { + SystemResource stored = Stored(); + + Resolve(stored, Incoming()); + + Assert.Equal("forgejo", stored.Name); + } + + [Fact] + public void A_name_a_person_typed_is_never_touched() { + SystemResource stored = Stored("the-blue-one", true); + + Resolve(stored, Incoming()); + + Assert.Equal("the-blue-one", stored.Name); + } + + [Fact] + public void A_placeholder_a_person_chose_to_keep_is_still_theirs() { + // Renaming a card back to its hash is a strange thing to do, but it is a choice, + // and the flag is what records that rather than the shape of the name. + SystemResource stored = Stored(userNamed: true); + var before = stored.Name; + + Resolve(stored, Incoming()); + + Assert.Equal(before, stored.Name); + } + + [Fact] + public void A_placeholder_never_replaces_a_real_name() { + // The reverse direction: the sweep runs after the firewall and knows less. The + // upgrade is one-way or the card would flip names on alternate runs. + SystemResource stored = Stored("forgejo"); + var placeholder = PlaceholderFor(stored.DiscoveryId!); + + Resolve(stored, Incoming(placeholder)); + + Assert.Equal("forgejo", stored.Name); + } + + [Fact] + public void One_real_name_does_not_replace_another() { + // Two collectors that each know a different name for one box would otherwise + // rename it back and forth every run. First real name wins and stays. + SystemResource stored = Stored("forgejo"); + + Resolve(stored, Incoming("git-server")); + + Assert.Equal("forgejo", stored.Name); + } + + [Fact] + public void A_hand_written_resource_counts_as_user_named_without_the_flag() { + // Nothing but a person could have written a resource with no discovery id, so + // the absent flag must not be read as permission. + var stored = new SystemResource { + Kind = SystemResource.KindLabel, + Name = "forgejo", + Ip = "192.0.2.50" + }; + + Assert.True(stored.IsUserNamed()); + Assert.False(Stored().IsUserNamed()); + } + + [Fact] + public void Everything_pointing_at_the_old_name_follows_it() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var service = new Service { + Kind = Service.KindLabel, + Name = "forgejo-https", + RunsOn = [placeholder] + }; + + Resolve(stored, Incoming(), null, service); + + Assert.Equal(["forgejo"], service.RunsOn); + } + + [Fact] + public void Stored_connections_follow_it_too() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var connection = new Connection { + A = new PortReference { Resource = placeholder }, + B = new PortReference { Resource = "core-switch", PortIndex = 12 } + }; + + Resolve(stored, Incoming(), [connection]); + + Assert.Equal("forgejo", connection.A.Resource); + } + + [Fact] + public void A_re_push_of_exported_yaml_follows_the_rename_too() { + // The payload still calls the machine by the name it was stored under, because + // that is what was exported. Its links have to land on the upgraded card. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var arriving = new Service { + Kind = Service.KindLabel, + Name = "forgejo-https", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:443"), + RunsOn = [placeholder] + }; + + DiscoveryIdResolver.ResolveNames([stored], [Incoming(), arriving], null, null, true); + + Assert.Equal("forgejo", stored.Name); + Assert.Equal(["forgejo"], arriving.RunsOn); + } + + [Fact] + public void A_service_named_after_the_host_follows_it() { + // The sweep names what it finds on a port after the host it found it on, so the + // leftover reads host-1a2b3c4d-ssh running on forgejo until this carries it over. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + RunsOn = [placeholder] + }; + + Resolve(stored, Incoming(), null, ssh); + + Assert.Equal("forgejo-ssh", ssh.Name); + Assert.Equal(["forgejo"], ssh.RunsOn); + } + + [Fact] + public void A_service_a_person_named_keeps_its_name() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + UserNamed = true, + RunsOn = [placeholder] + }; + + Resolve(stored, Incoming(), null, ssh); + + Assert.Equal($"{placeholder}-ssh", ssh.Name); + } + + [Fact] + public void A_service_that_only_looks_like_the_host_is_not_renamed() { + // Named for the old host but running somewhere else entirely: a coincidence, + // and coincidences are not evidence of anything. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var stray = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "elsewhere:22"), + RunsOn = ["some-other-box"] + }; + + Resolve(stored, Incoming(), null, stray); + + Assert.Equal($"{placeholder}-ssh", stray.Name); + } + + [Fact] + public void A_service_whose_new_name_is_taken_keeps_the_old_one() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + RunsOn = [placeholder] + }; + + var occupier = new Service { + Kind = Service.KindLabel, + Name = "forgejo-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "other:22"), + RunsOn = ["some-other-box"] + }; + + Resolve(stored, Incoming(), null, ssh, occupier); + + Assert.Equal($"{placeholder}-ssh", ssh.Name); + } + + [Fact] + public void A_connection_to_a_renamed_service_follows_it() { + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var ssh = new Service { + Kind = Service.KindLabel, + Name = $"{placeholder}-ssh", + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"), + RunsOn = [placeholder] + }; + + var connection = new Connection { + A = new PortReference { Resource = $"{placeholder}-ssh" }, + B = new PortReference { Resource = "core-switch", PortIndex = 3 } + }; + + DiscoveryIdResolver.ResolveNames( + [stored, ssh], [Incoming()], null, [connection], true); + + Assert.Equal("forgejo-ssh", connection.A.Resource); + } + + [Fact] + public void A_name_already_in_use_is_left_alone() { + // Renaming onto an occupied name would collide two unrelated resources in the + // merge, which keys on name. Keeping the placeholder is the safe outcome. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + var other = new SystemResource { + Kind = SystemResource.KindLabel, + Name = "forgejo", + Ip = "192.0.2.99" + }; + + Resolve(stored, Incoming(), null, other); + + Assert.Equal(placeholder, stored.Name); + } + + [Fact] + public void A_dry_run_leaves_the_stored_name_where_it_was() { + // The upgrade rewrites the inventory, so a caller that is only reporting what + // would happen must not ask for it — the default is off for exactly this reason. + SystemResource stored = Stored(); + var placeholder = stored.Name; + + DiscoveryIdResolver.ResolveNames([stored], [Incoming()]); + + Assert.Equal(placeholder, stored.Name); + } +} diff --git a/Tests.Discovery/ProxmoxGuestAddressTests.cs b/Tests.Discovery/ProxmoxGuestAddressTests.cs new file mode 100644 index 00000000..78e7bcdb --- /dev/null +++ b/Tests.Discovery/ProxmoxGuestAddressTests.cs @@ -0,0 +1,207 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// Learning where a guest actually is. Proxmox only records an address in a guest's +/// config when someone set one statically, so on a DHCP estate every guest arrived +/// without one — and an address is what lets a guest line up with the host a network +/// sweep found. The guest itself knows, and will say so through its agent. +/// +/// The hard part is that the agent reports every interface inside the machine, +/// including the bridges Docker and Home Assistant create. Picking the wrong one +/// would record 172.17.0.1 as the machine's address, which every container host +/// on the estate would also report. +/// +/// +public class ProxmoxGuestAddressTests { + private const string _nicMac = "bc:24:11:00:1a:01"; + + private static List AgentAddresses() => + ProxmoxResponseParser.ParseAgentInterfaces(Fixture.Read("pve-agent-interfaces.json")); + + [Fact] + public void An_agent_reports_every_routable_ipv4_it_can_see() { + List addresses = AgentAddresses(); + + // Loopback and the IPv6 entries are dropped; the NIC and both docker bridges stay, + // because deciding between them is the caller's job, not the parser's. + Assert.Equal(["192.0.2.105", "172.17.0.1", "172.18.0.1"], addresses.Select(a => a.Ip)); + } + + [Fact] + public void The_guests_own_nic_wins_over_the_bridges_it_created() => + Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), [_nicMac])); + + [Fact] + // Proxmox configs upper-case them; agents vary. Both go through the same normaliser + // the ARP reader uses, so a scan and this collector always agree. + public void The_mac_is_matched_however_either_side_spells_it() => + Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["BC-24-11-00-1A-01"])); + + [Fact] + // Every address on offer belongs to something the guest invented. Recording one + // would be worse than recording nothing. + public void Nothing_is_claimed_when_no_interface_carries_a_configured_mac() => + Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["bc:24:11:ff:ff:ff"])); + + [Fact] + // A token that cannot read the guest config leaves no discriminator, so there is no + // way to tell a NIC from a bridge. + public void Nothing_is_claimed_when_the_configured_macs_are_unknown() => + Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), [])); + + [Fact] + public void An_agent_that_answers_nothing_yields_nothing() { + Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":null}""")); + Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":{"result":[]}}""")); + } + + [Fact] + public void A_container_reports_its_address_with_the_prefix_stripped() { + List addresses = + ProxmoxResponseParser.ParseContainerInterfaces(Fixture.Read("pve-lxc-interfaces.json")); + + ProxmoxGuestAddress only = Assert.Single(addresses); + Assert.Equal("192.0.2.150", only.Ip); + Assert.Equal("eth0", only.Interface); + } + + [Fact] + public void A_guest_that_failed_dhcp_is_not_recorded_at_its_self_assigned_address() { + // 169.254 means "I could not get an address", which is not an address worth + // writing into an inventory. + var json = """ + {"data":{"result":[{"name":"ens18","hardware-address":"bc:24:11:00:1a:01", + "ip-addresses":[{"ip-address":"169.254.12.7","ip-address-type":"ipv4","prefix":16}]}]}} + """; + + Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces(json)); + } + + [Fact] + public async Task A_statically_configured_address_still_wins_over_the_agent() { + // The config is what the administrator asked for; the agent is what the guest + // happens to report. Where both exist they agree, and where they do not the + // configured one is the intent. + var client = new ScriptedProxmoxClient { + Guests = [Guest(100, "static-guest")], + Configs = { [100] = new ProxmoxGuestConfig("Linux", "192.0.2.9", [], [], [_nicMac]) }, + Addresses = { [100] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] } + }; + + List resources = await ProxmoxDiscovery.ReadAsync(client); + + Assert.Equal("192.0.2.9", GuestCard(resources, "static-guest").Ip); + } + + [Fact] + public async Task A_dhcp_guest_takes_the_address_its_agent_reports() { + var client = new ScriptedProxmoxClient { + Guests = [Guest(101, "dhcp-guest")], + Configs = { [101] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }, + Addresses = { [101] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] } + }; + + List resources = await ProxmoxDiscovery.ReadAsync(client); + + Assert.Equal("192.0.2.105", GuestCard(resources, "dhcp-guest").Ip); + } + + [Fact] + public async Task A_stopped_guest_is_never_asked_where_it_is() { + // It has no address to report, and asking costs a round trip per guest on an + // estate where most guests may be off. + var client = new ScriptedProxmoxClient { + Guests = [Guest(102, "stopped-guest", "stopped")], + Configs = { [102] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) } + }; + + await ProxmoxDiscovery.ReadAsync(client); + + Assert.Empty(client.AddressCalls); + } + + [Fact] + public async Task The_guests_macs_reach_the_card() { + // The MCP tool used to run its own copy of this orchestration which dropped the + // MACs, silently costing every guest its chance of unifying with a scan. + var client = new ScriptedProxmoxClient { + Guests = [Guest(103, "mac-guest")], + Configs = { [103] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) } + }; + + List resources = await ProxmoxDiscovery.ReadAsync(client); + + Assert.Equal(_nicMac, GuestCard(resources, "mac-guest").Labels["macs"]); + } + + private static SystemResource GuestCard(List resources, string name) => + resources.OfType().Single(r => r.Name == name); + + private static ProxmoxGuest Guest(int vmId, string name, string status = "running") => + new() { + VmId = vmId, + Node = "pve01", + Name = name, + Type = "vm", + Status = status + }; + + private sealed class ScriptedProxmoxClient : IProxmoxClient { + public List Guests { get; init; } = []; + public Dictionary Configs { get; } = []; + public Dictionary> Addresses { get; } = []; + public List AddressCalls { get; } = []; + + public string Endpoint => "https://pve.example.com:8006"; + + public Task GetIdentityScopeAsync(CancellationToken cancellationToken = default) => + Task.FromResult("example-cluster"); + + public Task> GetNodesAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([new ProxmoxNode { Name = "pve01" }]); + + public Task EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default) => + Task.FromResult(node); + + public Task> GetGuestsAsync( + string node, + string endpoint, + CancellationToken cancellationToken = default) => + Task.FromResult>( + endpoint == ProxmoxApiClient.QemuEndpoint ? Guests : []); + + public Task> GetDisksAsync( + string node, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task> GetGpusAsync( + string node, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task GetGuestConfigAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default) => + Task.FromResult(Configs.TryGetValue(vmId, out ProxmoxGuestConfig? config) + ? config + : new ProxmoxGuestConfig(null, null, [], [])); + + public Task> GetGuestAddressesAsync( + string node, + string endpoint, + int vmId, + CancellationToken cancellationToken = default) { + AddressCalls.Add(vmId); + + return Task.FromResult>( + Addresses.TryGetValue(vmId, out List? found) ? found : []); + } + } +} diff --git a/Tests.Discovery/ProxmoxQuotedNumberTests.cs b/Tests.Discovery/ProxmoxQuotedNumberTests.cs new file mode 100644 index 00000000..0d0c66ae --- /dev/null +++ b/Tests.Discovery/ProxmoxQuotedNumberTests.cs @@ -0,0 +1,63 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// Proxmox's perl backend quotes numeric fields inconsistently — "vmid":"100" +/// and "maxdisk":"512110190592" both turn up across versions. The +/// TryGetInt32 family does not return false for a string, it throws, so a +/// single quoted number used to take the whole run down with a stack trace: the CLI +/// printed "Unexpected error occurred" and MCP forwarded the BCL's +/// "requires an element of type 'Number'" as if it were the user's fault. +/// +public class ProxmoxQuotedNumberTests { + [Fact] + public void A_guest_whose_numbers_are_quoted_is_read_rather_than_throwing() { + var json = """ + {"data":[{"vmid":"100","name":"quoted-guest","cpus":"4", + "maxmem":"4294967296","maxdisk":"512110190592","status":"running"}]} + """; + + ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")); + + Assert.Equal(100, guest.VmId); + Assert.Equal("quoted-guest", guest.Name); + Assert.Equal(4, guest.Cores); + Assert.Equal(4294967296, guest.MemoryBytes); + Assert.Equal(512110190592, guest.DiskBytes); + } + + [Fact] + public void Plain_numbers_still_read_the_same_way() { + var json = """ + {"data":[{"vmid":101,"name":"plain-guest","cpus":2, + "maxmem":2147483648,"maxdisk":34359738368,"status":"running"}]} + """; + + ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")); + + Assert.Equal(101, guest.VmId); + Assert.Equal(2, guest.Cores); + Assert.Equal(2147483648, guest.MemoryBytes); + } + + [Fact] + public void A_field_that_is_neither_a_number_nor_a_numeric_string_is_simply_absent() { + // Guessing at "N/A" would be worse than leaving the field empty, and it must + // still not throw. + var json = """{"data":[{"vmid":102,"name":"odd-guest","cpus":"N/A","maxmem":null}]}"""; + + ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")); + + Assert.Equal(0, guest.Cores); + Assert.Equal(0, guest.MemoryBytes); + } + + [Fact] + public void A_guest_whose_vmid_is_quoted_is_still_identified() { + // vmid is the guest's identity; dropping it would silently lose the guest. + var json = """{"data":[{"vmid":"103","name":"id-guest"}]}"""; + + Assert.Equal(103, Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")).VmId); + } +} diff --git a/Tests.Discovery/RemoteDockerDiscoveryTests.cs b/Tests.Discovery/RemoteDockerDiscoveryTests.cs index bd4857b4..177b0924 100644 --- a/Tests.Discovery/RemoteDockerDiscoveryTests.cs +++ b/Tests.Discovery/RemoteDockerDiscoveryTests.cs @@ -179,4 +179,33 @@ private static SystemResource StoredHost2(string name) { return host; } + + // -- endpoints this cannot reach --------------------------------------------------- + + [Theory] + // `docker context` sets this for a remote host, so it is a perfectly normal value to + // find in DOCKER_HOST. + [InlineData("ssh://user@nas")] + // The Windows default. + [InlineData("npipe:////./pipe/docker_engine")] + [InlineData("gibberish://nowhere")] + public void An_endpoint_scheme_that_cannot_be_dialled_is_refused_with_advice(string endpoint) { + // HttpClient accepts these URIs happily and only throws NotSupportedException on + // the first request — which the caller does not catch, so discovery used to die + // with a stack trace instead of saying what was wrong. + UriFormatException error = Assert.Throws(() => new DockerApiClient(endpoint)); + + Assert.Contains("ssh -L", error.Message); + } + + [Theory] + [InlineData("tcp://192.0.2.10:2375")] + [InlineData("http://192.0.2.10:2375")] + [InlineData("unix:///var/run/docker.sock")] + [InlineData("unix:///run/user/1000/podman/podman.sock")] + public void The_endpoints_that_do_work_are_untouched(string endpoint) { + using var client = new DockerApiClient(endpoint); + + Assert.Equal(endpoint, client.Endpoint); + } } diff --git a/Tests.Discovery/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs new file mode 100644 index 00000000..5008c51d --- /dev/null +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -0,0 +1,274 @@ +using RackPeek.Domain.Discovery; +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.Services; +using RackPeek.Domain.Resources.SystemResources; + +namespace Tests.Discovery; + +/// +/// Giving a service the host it is plainly running on, when the collector could not. +/// A docker collector talking to a remote engine has to guess its host's name and the +/// link then dangles; the service's own address is evidence the guess is not. The +/// rules that keep this from doing harm are what these tests own. +/// +public class RunsOnByIpTests { + private static SystemResource System(string name, string? ip, string? discoveryId = null) => + new() { + Kind = SystemResource.KindLabel, + Name = name, + Ip = ip, + DiscoveryId = discoveryId + }; + + private static Service Service(string name, string? ip, string? runsOn = null, string? discoveryId = "rpk1:docker:1") => + new() { + Kind = RackPeek.Domain.Resources.Services.Service.KindLabel, + Name = name, + DiscoveryId = discoveryId, + Network = ip == null ? null : new Network { Ip = ip }, + RunsOn = runsOn == null ? [] : [runsOn] + }; + + [Fact] + public void A_service_whose_host_name_matches_nothing_is_anchored_to_the_system_at_its_address() { + // The remote-docker case: the engine called itself NAS01.lan, the inventory + // calls that machine nas01, and nothing linked the two. + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", "192.0.2.50", "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["nas01"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_service_with_no_host_at_all_is_anchored_too() { + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", "192.0.2.50")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["nas01"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_host_arriving_in_the_same_payload_counts() { + // discover docker emits the host alongside its services. + List incoming = [ + System("nas01", "192.0.2.50", "rpk1:sys:a"), + Service("immich", "192.0.2.50", "somewhere-else") + ]; + + DiscoveryIdResolver.ResolveNames([], incoming); + + Assert.Equal(["nas01"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_working_host_link_is_never_overwritten() { + // The service says it runs on a resource that exists. That is the collector's + // own answer and it beats an inference drawn from an address. + List existing = [ + System("nas01", "192.0.2.50"), + System("some-vm", "192.0.2.99") + ]; + List incoming = [Service("immich", "192.0.2.50", "some-vm")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["some-vm"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_stored_host_beats_a_scanned_stand_in_at_the_same_address() { + // Once a hypervisor reports its guests' addresses, a sweep of that subnet finds + // the same machines again and contributes a sparse card per address. The service + // belongs on the guest the hypervisor described, not on the sweep's stand-in. + List existing = [System("app-vm", "192.0.2.50")]; + List incoming = [ + System("host-1a2b3c4d", "192.0.2.50", "rpk1:net:c"), + Service("immich", "192.0.2.50", "SOMEWHERE.lan") + ]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["app-vm"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void An_address_two_systems_claim_anchors_nothing() { + // Overlapping subnets across sites, or a stale card nobody cleaned up. An + // ambiguous address is no evidence, and a wrong parent is worse than none. + List existing = [ + System("site-a-host", "192.0.2.50"), + System("site-b-host", "192.0.2.50") + ]; + List incoming = [Service("immich", "192.0.2.50", "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["NAS01.lan"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void A_service_with_no_address_is_left_alone() { + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", null, "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["NAS01.lan"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void An_address_no_system_claims_anchors_nothing() { + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [Service("immich", "198.51.100.99", "NAS01.lan")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal(["NAS01.lan"], incoming.OfType().Single().RunsOn); + } + + [Fact] + public void Systems_are_not_anchored_by_address() { + // Two systems sharing an address are the same machine or a conflict — never a + // parent and a child. Only services are hosted. + List existing = [System("nas01", "192.0.2.50")]; + List incoming = [System("scanned-host", "192.0.2.50", "rpk1:net:b")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Empty(incoming.OfType().Single().RunsOn); + } + + // --------------------------------------------------------------- + // Unifying a sweep's find with the guest a hypervisor already described + // --------------------------------------------------------------- + + private static SystemResource Scanned(string name, string ip, string? mac = null) { + var card = new SystemResource { + Kind = SystemResource.KindLabel, + Name = name, + Ip = ip, + DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, mac ?? $"ip:{ip}") + }; + + if (mac != null) + card.Labels["mac"] = mac; + + return card; + } + + private static SystemResource Guest(string name, string ip, string id = "abc123") => + new() { + Kind = SystemResource.KindLabel, + Name = name, + Ip = ip, + DiscoveryId = $"rpk1:pve:{id}" + }; + + [Fact] + public void A_sweep_find_becomes_the_guest_the_hypervisor_already_described() { + // ARP is link-local, so a guest on another subnet gives the sweep no MAC and its + // identity falls back to the address. Now that the hypervisor reports that same + // address, it is the only thing tying the two records together. + List existing = [Guest("app-vm", "192.0.2.105")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + SystemResource card = incoming.OfType().Single(); + Assert.Equal("app-vm", card.Name); + // The sweep's weaker identity is dropped so the merge cannot downgrade the + // hypervisor's. + Assert.Null(card.DiscoveryId); + } + + [Fact] + public void A_sweep_find_that_saw_a_mac_is_left_to_the_mac_rule() { + // A MAC is better evidence than an address. If it did not unify above, the two + // records disagree, and an address must not override that. + List existing = [Guest("app-vm", "192.0.2.105")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105", "bc:24:11:00:1a:01")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); + } + + [Fact] + public void Two_stored_systems_at_one_address_unify_nothing() { + // Overlapping subnets across sites, or a stale card. Ambiguity is not evidence. + List existing = [ + Guest("site-a-vm", "192.0.2.105", "aaa111"), + Guest("site-b-vm", "192.0.2.105", "bbb222") + ]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); + } + + [Fact] + public void Two_sweep_finds_with_nothing_between_them_are_the_same_card_already() { + // Neither card is more than "something replied at this address", and an address + // is exactly what seeds their identity when no MAC was seen — so they carry the + // same id and the address rule never gets a say. The stored name wins, as it + // does for any re-run. + List existing = [Scanned("host-99999999", "192.0.2.105")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + Assert.Equal(existing[0].DiscoveryId, incoming[0].DiscoveryId); + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-99999999", incoming.OfType().Single().Name); + } + + [Fact] + public void A_sweep_find_folds_into_one_that_saw_the_mac() { + // Not two stand-ins: the stored card names the NIC answering at that address — + // a firewall's neighbour table does this for every subnet it routes — where the + // incoming one only knows something replied. The MAC is the better identity, so + // the address-only card folds into it rather than becoming a second machine. + List existing = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + SystemResource card = incoming.OfType().Single(); + Assert.Equal("host-99999999", card.Name); + + // The weaker address-seeded identity is dropped so the merge cannot downgrade + // the MAC-seeded one it is landing on. + Assert.Null(card.DiscoveryId); + } + + [Fact] + public void The_mac_wins_arriving_second_too() { + // Sweep the routed subnet first, ask the firewall after: same two facts, same + // one card. Here the MAC-seeded identity is the one that survives. + List existing = [Scanned("host-1a2b3c4d", "192.0.2.105")]; + List incoming = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + SystemResource card = incoming.OfType().Single(); + Assert.Equal("host-1a2b3c4d", card.Name); + Assert.NotNull(card.DiscoveryId); + } + + [Fact] + public void Two_sweep_finds_that_each_saw_a_mac_never_unify() { + // Both name a NIC, and they name different ones. The MAC bridge has already had + // its say; sharing an address now is a conflict or an overlapping subnet. + List existing = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")]; + List incoming = [Scanned("host-1a2b3c4d", "192.0.2.105", "bc:24:11:00:1a:0a")]; + + DiscoveryIdResolver.ResolveNames(existing, incoming); + + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); + } +} diff --git a/Tests.Discovery/ServiceEndpointTests.cs b/Tests.Discovery/ServiceEndpointTests.cs new file mode 100644 index 00000000..94b78f05 --- /dev/null +++ b/Tests.Discovery/ServiceEndpointTests.cs @@ -0,0 +1,115 @@ +using RackPeek.Domain.Resources.Services; + +namespace Tests.Discovery; + +/// +/// Where a service answers, and whether a browser can do anything with it. +/// +/// Both of these were wrong in the UI. The endpoint was rendered as +/// Ip: 10.0.50.105:3000 and then used as a link target, so clicking it did +/// nothing. And the scheme fell back to http for every port, so an SSH +/// service showed http://10.0.50.105:22/ — a link that looks real, invites +/// a click and cannot load. +/// +/// +public class ServiceEndpointTests { + private static Network Net(int? port, string? protocol = "TCP", string? ip = "10.0.50.105", string? url = null) => + new() { Ip = ip, Port = port, Protocol = protocol, Url = url }; + + private static Service Svc(Network? network) => + new() { Kind = Service.KindLabel, Name = "svc", Network = network }; + + [Fact] + public void An_endpoint_reads_as_an_address_and_nothing_else() => + // It goes on screen next to the service name; a label belongs in the markup. + Assert.Equal("10.0.50.105:3000", Svc(Net(3000)).NetworkString()); + + [Fact] + public void An_endpoint_with_no_port_is_just_the_address() => + Assert.Equal("10.0.50.105", Svc(Net(null)).NetworkString()); + + [Fact] + public void A_service_with_no_network_has_no_endpoint() => + Assert.Equal(string.Empty, Svc(null).NetworkString()); + + [Theory] + [InlineData(22)] // ssh + [InlineData(445)] // smb + [InlineData(1883)] // mqtt + [InlineData(53)] // dns + [InlineData(3306)] // mysql + [InlineData(9987)] // nothing curated + public void A_port_a_browser_cannot_open_gets_no_link(int port) => + Assert.Null(Svc(Net(port)).BrowsableUrl()); + + [Theory] + [InlineData(80, "http://10.0.50.105/")] + [InlineData(3000, "http://10.0.50.105:3000/")] + [InlineData(8123, "http://10.0.50.105:8123/")] + [InlineData(9000, "http://10.0.50.105:9000/")] + public void A_web_port_gets_a_link(int port, string expected) => + Assert.Equal(expected, Svc(Net(port)).BrowsableUrl()); + + [Theory] + [InlineData(443, "https://10.0.50.105/")] + [InlineData(8006, "https://10.0.50.105:8006/")] + [InlineData(8443, "https://10.0.50.105:8443/")] + public void A_tls_port_gets_an_https_link(int port, string expected) => + // Proxmox on 8006 redirects plain HTTP, so guessing http costs a round trip. + Assert.Equal(expected, Svc(Net(port)).BrowsableUrl()); + + [Fact] + public void The_transport_is_not_mistaken_for_a_scheme() { + // Discovery writes "TCP" into protocol, which says nothing about what rides on + // top of it. Reading that as a scheme is what produced http:// on port 22. + Assert.Null(Svc(Net(22, "TCP")).BrowsableUrl()); + Assert.Equal("http://10.0.50.105:3000/", Svc(Net(3000, "TCP")).BrowsableUrl()); + } + + [Theory] + [InlineData("https", "https://10.0.50.105:9999/")] + [InlineData("HTTP", "http://10.0.50.105:9999/")] + public void A_protocol_that_names_a_scheme_is_believed(string protocol, string expected) => + // On an uncurated port this is the only thing that can answer. + Assert.Equal(expected, Svc(Net(9999, protocol)).BrowsableUrl()); + + [Fact] + public void A_url_someone_typed_always_wins() { + Service service = Svc(Net(22, "TCP", url: "https://git.example.com/")); + + Assert.Equal("https://git.example.com/", service.BrowsableUrl()); + Assert.Equal("https://git.example.com/", service.NetworkString()); + } + + [Fact] + public void A_service_with_no_address_of_its_own_can_borrow_its_hosts() { + // The card resolves the host's address when the service carries none. + Service service = Svc(Net(8080, ip: null)); + + Assert.Null(service.BrowsableUrl()); + Assert.Equal("http://10.0.20.7:8080/", service.BrowsableUrl("10.0.20.7")); + } + + [Fact] + public void A_service_with_no_port_is_not_guessed_at() => + Assert.Null(Svc(Net(null)).BrowsableUrl()); + + [Theory] + [InlineData("host name with spaces")] + [InlineData("...")] + [InlineData("a/b")] + [InlineData("under_score.local")] + [InlineData("fe80::1")] + [InlineData("[::1]")] + public void An_address_no_url_can_be_built_from_yields_no_link_rather_than_throwing(string ip) { + // The address field holds whatever a person typed or a device reported, and + // neither is obliged to produce something a URL can be built from. This is + // rendered inside the hardware and system trees, so an exception here would + // blank the whole page rather than spoil one link. + Service service = Svc(Net(8080, ip: ip)); + + Exception? thrown = Record.Exception(() => service.BrowsableUrl()); + + Assert.Null(thrown); + } +} diff --git a/Tests.Discovery/ServiceIdentityParserTests.cs b/Tests.Discovery/ServiceIdentityParserTests.cs new file mode 100644 index 00000000..860fd70b --- /dev/null +++ b/Tests.Discovery/ServiceIdentityParserTests.cs @@ -0,0 +1,138 @@ +using RackPeek.Domain.Discovery; + +namespace Tests.Discovery; + +/// +/// Reading a host's name out of what its services volunteer. The fixtures follow the +/// shape of what real homelab gear emits — a Proxmox node, an OPNsense firewall, a +/// network printer — because the value of this parser is entirely in what it makes of +/// awkward real-world output rather than well-formed examples. The names and +/// addresses throughout are invented. +/// +public class ServiceIdentityParserTests { + [Theory] + // Proxmox ships a per-node certificate naming the node. This single line is what + // turns "host-1a2b3c4d" into "pve-node-01". + [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-01.example.com", "pve-node-01.example.com")] + [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-02.example", "pve-node-02.example")] + [InlineData("CN=OPNsense.localdomain, O=OPNsense self-signed", "OPNsense.localdomain")] + [InlineData("CN=printer-lobby.local", "printer-lobby.local")] + [InlineData("CN = spaced.example.lan, O = Org", "spaced.example.lan")] + public void A_certificate_common_name_is_read_from_its_subject(string subject, string expected) => + Assert.Equal(expected, ServiceIdentityParser.ParseTlsSubject(subject)); + + [Theory] + [InlineData("CN=*.example.com, O=Org")] // names a domain, not this machine + [InlineData("CN=localhost")] // the installer's placeholder + [InlineData("CN=-6268337161588699610")] // an appliance serial: no letters, names nothing + [InlineData("O=Org Only, OU=No Common Name")] + [InlineData("")] + [InlineData(null)] + public void A_subject_that_names_nothing_useful_is_rejected(string? subject) => + Assert.Null(ServiceIdentityParser.ParseTlsSubject(subject)); + + [Theory] + [InlineData("SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5", "OpenSSH")] + [InlineData("SSH-2.0-dropbear", "dropbear")] + [InlineData("SSH-2.0-dropbear_2022.83", "dropbear")] + [InlineData("SSH-1.99-Cisco-1.25", "Cisco")] + // The version is deliberately dropped: it goes stale on the next patch, and a + // resource named after a point release is worse than one named after the daemon. + public void An_ssh_greeting_yields_the_software_without_its_version(string banner, string expected) => + Assert.Equal(expected, ServiceIdentityParser.ParseSshBanner(banner)); + + [Theory] + [InlineData("220 mail.example.com ESMTP Postfix")] // not SSH + [InlineData("HTTP/1.1 200 OK")] + [InlineData("")] + [InlineData(null)] + public void Something_that_is_not_an_ssh_greeting_yields_nothing(string? banner) => + Assert.Null(ServiceIdentityParser.ParseSshBanner(banner)); + + [Fact] + public void A_page_title_is_preferred_over_the_server_header() { + // "Home Assistant" identifies the box; "nginx" identifies half the internet. + var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0\r\n\r\nHome Assistant"; + + Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void The_server_header_is_used_when_there_is_no_title() { + var head = "HTTP/1.0 200 OK\r\nServer: llama.cpp\r\nContent-Type: text/html\r\n\r\n"; + + Assert.Equal("llama.cpp", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void A_server_headers_version_is_trimmed() { + var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0 (Ubuntu)\r\n\r\n"; + + Assert.Equal("nginx", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Theory] + // Observed on a live sweep: the tagline became the card's name. + [InlineData("Forgejo: Beyond coding. We Forge.", "Forgejo")] + [InlineData("Grafana | Dashboards", "Grafana")] + [InlineData("Jellyfin – Media", "Jellyfin")] + [InlineData("Home Assistant", "Home Assistant")] + public void A_title_is_trimmed_to_the_phrase_before_its_separator(string body, string expected) => + Assert.Equal(expected, ServiceIdentityParser.ParseHttpIdentity("HTTP/1.0 200 OK\r\n\r\n" + body)); + + [Fact] + public void A_title_whose_lead_is_too_short_to_stand_alone_is_kept_whole() { + // A stray separator near the start is not a product name. + var head = "HTTP/1.0 200 OK\r\n\r\nmy: little server"; + + Assert.Equal("my: little server", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void Whitespace_in_a_title_is_collapsed() { + var head = "HTTP/1.0 200 OK\r\n\r\n\n Home Assistant\n"; + + Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Theory] + [InlineData("HTTP/1.0 401 Unauthorized\r\nWWW-Authenticate: Basic\r\n\r\n")] + [InlineData("HTTP/1.0 200 OK\r\n\r\n ")] + [InlineData("HTTP/1.0 200 OK\r\n\r\n404")] // digits name nothing + [InlineData("")] + [InlineData(null)] + public void An_http_response_that_names_nothing_yields_nothing(string? head) => + Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head)); + + [Fact] + public void An_error_pages_title_is_not_a_name() { + // Observed on a live sweep: a host answering 400 produced a card called + // "http-status-400-bad-request". + var head = "HTTP/1.1 400 Bad Request\r\nServer: Apache\r\n\r\n" + + "HTTP Status 400 – Bad Request"; + + // The Server header is still accurate on an error response, so it is used. + Assert.Equal("Apache", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void An_error_response_with_no_server_header_names_nothing() { + var head = "HTTP/1.1 500 Internal Server Error\r\n\r\nSomething broke"; + + Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void A_redirect_still_counts_as_a_working_page() { + var head = "HTTP/1.1 302 Found\r\nLocation: /ui\r\n\r\nHome Assistant"; + + Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head)); + } + + [Fact] + public void An_absurdly_long_title_is_rejected_rather_than_becoming_a_name() { + var head = $"HTTP/1.0 200 OK\r\n\r\n{new string('x', 300)}"; + + Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head)); + } +} diff --git a/Tests.Mcp/DiscoveryToolTests.cs b/Tests.Mcp/DiscoveryToolTests.cs index f368566c..caf8ad02 100644 --- a/Tests.Mcp/DiscoveryToolTests.cs +++ b/Tests.Mcp/DiscoveryToolTests.cs @@ -193,4 +193,28 @@ public async Task An_unreachable_proxmox_host_is_a_clean_error_naming_the_endpoi Assert.Contains("Could not read", error); Assert.Contains("http://127.0.0.1:1", error); } + + [Fact] + public async Task An_engine_with_no_ipv4_address_is_refused_rather_than_given_this_machines() { + // Services are recorded at their host's address. When the engine answers but has + // no IPv4 — the inventory holds IPv4 only — the address is genuinely unknown, and + // the code used to substitute the address of whatever machine ran the command. + // Every service then carried a confident, wrong address that flowed on into the + // ansible, ssh and hosts exports. + if (!System.Net.Sockets.Socket.OSSupportsIPv6) + return; // no loopback to bind; nothing to prove here on this host + + await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync(true); + using var api = new McpFixture(); + await using McpClient client = await api.ConnectAsync(); + + Exception error = await Assert.ThrowsAnyAsync(() => + client.CallOkAsync("discover_docker", new Dictionary { + ["dockerHost"] = $"tcp://{engine.Host}" + })); + + Assert.Contains("IPv4", error.Message); + // Nothing was recorded at a borrowed address. + Assert.DoesNotContain("jellyfin", api.StoredYaml); + } } diff --git a/Tests.Mcp/FakeHttpServer.cs b/Tests.Mcp/FakeHttpServer.cs index 5d28bfb2..8608a58e 100644 --- a/Tests.Mcp/FakeHttpServer.cs +++ b/Tests.Mcp/FakeHttpServer.cs @@ -21,10 +21,13 @@ internal sealed class FakeHttpServer : IAsyncDisposable { public string Host => new Uri(BaseUrl).Authority; - public static async Task StartAsync(Action map) { + public static async Task StartAsync(Action map, bool ipv6 = false) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); - builder.WebHost.UseUrls("http://127.0.0.1:0"); + + // An IPv6-only endpoint is the one case where an engine answers but has no IPv4 + // address to record its services at. + builder.WebHost.UseUrls(ipv6 ? "http://[::1]:0" : "http://127.0.0.1:0"); WebApplication app = builder.Build(); map(app); @@ -34,13 +37,13 @@ public static async Task StartAsync(Action map) } /// A fake Docker Engine API with the shared captured fixtures. - public static Task StartDockerEngineAsync() => + public static Task StartDockerEngineAsync(bool ipv6 = false) => StartAsync(app => { app.MapGet("/containers/json", () => Results.Content( TestData.Fixture("docker-containers.json"), "application/json")); app.MapGet("/info", () => Results.Content( TestData.Fixture("docker-info.json"), "application/json")); - }); + }, ipv6); /// /// A fake Proxmox VE API. Both fixture nodes answer with the same guest lists, diff --git a/Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs b/Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs new file mode 100644 index 00000000..87aa2f32 --- /dev/null +++ b/Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs @@ -0,0 +1,33 @@ +using Tests.EndToEnd.Infra; +using Xunit.Abstractions; + +namespace Tests.EndToEnd.DiscoveryTests; + +/// +/// `rpk discover docker` against endpoints it cannot reach. Every case here fails +/// before any container is listed, so these tests never talk to a daemon. +/// +[Collection("Yaml CLI tests")] +public class DiscoverDockerEndpointTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper) + : IClassFixture { + private async Task ExecuteAsync(params string[] args) => + await YamlCliTestHost.RunAsync(args, fs.Root, outputHelper, "config.yaml"); + + [Theory] + [InlineData("ssh://user@nas")] + [InlineData("npipe:////./pipe/docker_engine")] + public async Task an_endpoint_that_cannot_be_dialled_says_so_instead_of_crashing(string endpoint) { + // `docker context` sets ssh:// for a remote host and npipe:// is the Windows + // default, so both are ordinary values to find in DOCKER_HOST. They used to + // reach HttpClient, which accepts the URI and throws NotSupportedException on + // the first request — past the command's catch list, so the user got + // "Unexpected error occurred" and a stack trace. + var output = await ExecuteAsync("discover", "docker", "--docker-host", endpoint); + + Assert.DoesNotContain("Unexpected error", output); + Assert.DoesNotContain("at RackPeek.", output); + + // And the message says what to do about it. + Assert.Contains("ssh -L", output); + } +} diff --git a/generate-oui-table.py b/generate-oui-table.py new file mode 100755 index 00000000..7658605c --- /dev/null +++ b/generate-oui-table.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Regenerates RackPeek.Domain/Discovery/MacVendorTable.g.cs from the IEEE OUI registry. + + curl -sL -o /tmp/oui.csv https://standards-oui.ieee.org/oui/oui.csv + ./generate-oui-table.py /tmp/oui.csv + +The registry holds ~40,000 assignments; shipping all of them would bloat the +single-file binaries for little gain, so this keeps a curated subset: the vendors +whose hardware actually turns up on a homelab network. Add a pattern to VENDORS +below and re-run to extend it. + +Records are packed as fixed-width "PPPPPPv" (6 hex prefix chars + one vendor index +char) in a sorted string so the lookup is a binary search with no allocation and no +dictionary to build at startup. +""" +import csv +import re +import sys + +# Display name -> pattern matched against the registry's Organization Name. +# Anchored where a loose substring would drag in unrelated companies. +VENDORS = { + "Apple": r"^Apple, Inc\.?$|^Apple Inc", + "Intel": r"^Intel Corporate$|^Intel Corporation$", + "Ubiquiti": r"Ubiquiti", + "Raspberry Pi": r"Raspberry Pi", + "Espressif": r"Espressif|^Shanghai High-Flying|Ai-Thinker", + "Proxmox": r"Proxmox", + "VMware": r"VMware", + "QEMU/KVM": r"^QEMU", + "VirtualBox": r"PCS Systemtechnik|VirtualBox", + "Microsoft": r"^Microsoft Corporation$", + "Parallels": r"^Parallels", + "Synology": r"Synology", + "QNAP": r"QNAP", + "Netgear": r"NETGEAR|Netgear", + "TP-Link": r"TP-LINK|TP-Link|Tp-Link", + "D-Link": r"D-Link|D\-LINK", + "MikroTik": r"MikroTik|Mikrotik|Routerboard", + "Cisco": r"^Cisco Systems|^Cisco$", + "Aruba": r"Aruba", + "Zyxel": r"ZyXEL|Zyxel", + "Realtek": r"Realtek", + "Broadcom": r"^Broadcom", + "NVIDIA": r"NVIDIA|Nvidia", + "AMD": r"^Advanced Micro Devices", + "Dell": r"^Dell Inc|^Dell Computer|^Dell EMC", + "HPE/HP": r"Hewlett Packard|^HP Inc", + "Supermicro": r"Super Micro|Supermicro", + "ASUS": r"^ASUSTek|^ASUS", + "ASRock": r"ASRock", + "Gigabyte": r"GIGA-BYTE|Gigabyte", + "MSI": r"Micro-Star", + "Lenovo": r"^Lenovo", + "Samsung": r"^Samsung Electro|^Samsung Electronics", + "LG": r"^LG Electronics", + "Sony": r"^Sony ", + "Google": r"^Google, Inc|^Google LLC", + "Amazon": r"^Amazon Technologies", + "Sonos": r"^Sonos", + "Signify (Hue)": r"Signify|Philips Lighting", + "Philips": r"^Philips", + "Xiaomi": r"Xiaomi|XIAOMI", + "IKEA": r"IKEA|Inter IKEA", + "Aqara": r"Lumi United|Aqara", + "Reolink": r"Reolink", + "Hikvision": r"Hikvision|HIKVISION", + "Dahua": r"Dahua", + "Axis": r"^Axis Communication", + "AVM (Fritz!Box)": r"^AVM ", + "Juniper": r"^Juniper", + "Fortinet": r"^Fortinet", + "Netgate": r"Netgate", + "Seagate": r"^Seagate", + "Western Digital": r"Western Digital", + "Buffalo": r"^BUFFALO|^Buffalo", + "Asustor": r"ASUSTOR|Asustor", + "TerraMaster": r"TerraMaster|Terra-?Master", + "Sophos": r"^Sophos", + "Arris/CommScope": r"^ARRIS|CommScope", + "Technicolor": r"Technicolor", + "Sagemcom": r"Sagemcom", + "Roku": r"^Roku", + "Nintendo": r"^Nintendo", + "Texas Instruments": r"^Texas Instruments", + "Tuya": r"^Tuya|Hangzhou Tuya", + "Shelly": r"Allterco|Shelly", + "Zotac": r"ZOTAC", + "Beelink": r"Beelink|AZW", + "Minisforum": r"Minisforum|MINIX", + "Ring": r"^Ring LLC|Ring Inc", + "Arlo": r"^Arlo", + "Wyze": r"^Wyze", + "Sonoff": r"ITEAD|SONOFF", + "Mellanox": r"Mellanox", + "Chelsio": r"Chelsio", + "Aquantia": r"Aquantia", + "Edimax": r"Edimax|EDIMAX", + "Tenda": r"^Tenda|Shenzhen Tenda", + "Huawei": r"^HUAWEI|^Huawei", + "Sercomm": r"^Sercomm", + "Silicon Labs": r"Silicon Laborator", + "Nordic Semiconductor": r"Nordic Semiconductor", + "Pine64": r"^Pine ?64|PINE64", + "Hardkernel (ODROID)": r"Hardkernel", + "Radxa": r"^Radxa|Rockchip", + "FriendlyELEC": r"FriendlyARM|FriendlyELEC", + "Khadas": r"Khadas|Shenzhen Wesion", +} + +# Prefixes that identify software but are not IEEE assignments, because hypervisors +# mint addresses out of the locally-administered range rather than buying an OUI. +# Without these a KVM guest would be reported as merely "randomised". +EXTRAS = { + "525400": "QEMU/KVM", + "00163E": "Xen", +} + +# Vendor index is stored as one printable char, so the table cannot exceed this. +_FIRST_INDEX_CHAR = 33 # '!' +_MAX_VENDORS = 126 - _FIRST_INDEX_CHAR + + +def main(csv_path: str) -> int: + names = sorted(set(VENDORS) | set(EXTRAS.values())) + + if len(names) > _MAX_VENDORS: + sys.exit(f"{len(names)} vendors exceeds the {_MAX_VENDORS} a single index char can address.") + + index_of = {name: i for i, name in enumerate(names)} + compiled = [(re.compile(pat), name) for name, pat in VENDORS.items()] + + seen: dict[str, str] = {} + + with open(csv_path, encoding="utf-8", errors="replace") as handle: + for row in csv.DictReader(handle): + prefix = (row.get("Assignment") or "").strip().upper() + org = (row.get("Organization Name") or "").strip() + + if len(prefix) != 6 or prefix in seen: + continue + + for pattern, name in compiled: + if pattern.search(org): + seen[prefix] = name + break + + seen.update(EXTRAS) + + records = "".join( + prefix + chr(_FIRST_INDEX_CHAR + index_of[name]) + for prefix, name in sorted(seen.items()) + ) + + # 140 is a whole number of records, so each source line holds exactly 20 of them. + chunks = [records[i:i + 140] for i in range(0, len(records), 140)] + + # The index alphabet starts at '!' and runs past '"' and '\', both of which have to + # be escaped in the C# literal. Escaping changes only the source text; the string + # the runtime builds still holds the original characters, so offsets stay correct. + def escape(chunk: str) -> str: + return chunk.replace("\\", "\\\\").replace('"', '\\"') + + literal = "\n".join(f' "{escape(chunk)}" +' for chunk in chunks) + literal = literal.rstrip(" +") + ";" + + vendor_literal = ",\n".join(f' "{name}"' for name in names) + + out = f'''// +// Generated by generate-oui-table.py from the IEEE OUI registry +// (https://standards-oui.ieee.org/oui/oui.csv). Do not edit by hand — add a +// vendor pattern to the generator and re-run it instead. +// +// {len(seen)} assignments across {len(names)} vendors, packed as fixed-width +// "PPPPPPv" records (6 hex prefix chars + one vendor index char), sorted so +// {'MacVendorLookup'} can binary-search them without building a dictionary. +// + +namespace RackPeek.Domain.Discovery; + +internal static class MacVendorTable {{ + internal const int RecordLength = 7; + + internal const char FirstIndexChar = '{chr(_FIRST_INDEX_CHAR)}'; + + internal static readonly string[] Vendors = [ +{vendor_literal} + ]; + + internal static readonly string Records = +{literal} +}} +''' + + target = "RackPeek.Domain/Discovery/MacVendorTable.g.cs" + + with open(target, "w", encoding="utf-8") as handle: + handle.write(out) + + print(f"Wrote {target}: {len(seen)} assignments, {len(names)} vendors, {len(records)} chars.") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + + raise SystemExit(main(sys.argv[1])) diff --git a/schemas/v4/schema.v4.json b/schemas/v4/schema.v4.json index 78a1349a..05c463e7 100644 --- a/schemas/v4/schema.v4.json +++ b/schemas/v4/schema.v4.json @@ -68,6 +68,10 @@ "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk is the format version, so the way the id is derived can change without old ids being mistaken for new ones.", "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$" }, + "userNamed": { + "type": "boolean", + "description": "True when a person chose this resource's name, which discovery then never changes." + }, "tags": { "type": "array", "items": {