From f0199d21f98acefc5d255b3b6af2dda8e212ba74 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 22:15:03 +0100 Subject: [PATCH 1/9] Identify swept hosts by service banner, MAC vendor and open ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep of a multi-VLAN homelab produced 26 anonymous "host-" cards out of 27: ARP is link-local so remote subnets yield no MAC, and few home networks have PTR records. The sweep found everything and recognised nothing. Three sources close most of that gap without a new dependency. Service banners. Once a host is known alive it is asked what it is: a TLS certificate's common name (443/8006/8443), an HTTP page title or Server header, then an SSH greeting. This is the identification half of `nmap -sV` in about 150 lines of BCL sockets. Only open ports are asked — a handshake with a closed port buys nothing but a timeout. The three answers are different claims, so they are treated differently. An X.509 common name is a host name by construction, so a certificate names the machine. A page title names an application, and a host may run several, so each becomes a Service resource hanging off the card rather than renaming it. An SSH greeting names only the daemon — every Linux box on a subnet answers "OpenSSH" — so it annotates and never names. An appliance's own management page is not a service on itself, so a title matching the host's own name is skipped. Open ports. Liveness stops at the first answer, which left pingable hosts with no port evidence at all. A living host is now checked against a wider list (554 cameras, 1883 brokers, 8123, 9000, 3000, 32400 and friends) and the result lands in an open-ports label. These are observations, not conclusions: "554 is open" is a fact, "this is a camera" is an inference the reader is better placed to draw, and a port number is a convention rather than a guarantee — so nothing is named from one. They are, however, the best possible targets for the banner probes. MAC vendor. Where a MAC is known the card gains the organisation IEEE assigned that OUI to, from a curated 10,868-entry subset of the registry generated by generate-oui-table.py. A hypervisor's own prefix beats the locally-administered bit so a KVM guest reads as QEMU/KVM rather than anonymous, while an address a phone invented for itself is reported as randomised instead of attributed to whoever owns the block. Services are also anchored by address: one whose runsOn resolves to nothing takes the system at its own IP, which fixes the dangling link a docker collector leaves when it can only guess its host's name. Narrow by design — only an unresolvable link, only when exactly one system claims that address, and never for systems, where a shared address means the same machine rather than a parent. Cards stay sparse and identity stays put: nothing learned here writes an OS or a core count, and a name from a service never moves a discoveryId. --no-identify restores a pure liveness sweep. All names, addresses and MAC suffixes in tests and docs are invented; the OUI prefixes are public IEEE registry data. Co-Authored-By: Claude Fable 5 --- .../Discovery/DiscoveryIdResolver.cs | 67 ++ RackPeek.Domain/Discovery/INetworkProbe.cs | 21 + RackPeek.Domain/Discovery/MacVendorLookup.cs | 106 +++ RackPeek.Domain/Discovery/MacVendorTable.g.cs | 653 ++++++++++++++++++ RackPeek.Domain/Discovery/NetworkProbe.cs | 132 ++++ RackPeek.Domain/Discovery/NetworkScanFacts.cs | 39 +- .../Discovery/NetworkScanMapper.cs | 82 ++- RackPeek.Domain/Discovery/NetworkScanner.cs | 173 ++++- .../Discovery/ServiceIdentityParser.cs | 211 ++++++ RackPeek.Domain/Discovery/WellKnownPorts.cs | 27 + RackPeek.Domain/RackPeek.Domain.csproj | 7 + .../Discovery/DiscoverNetworkCommand.cs | 7 +- Shared.Rcl/wwwroot/raw_docs/cli-commands.md | 2 + .../wwwroot/raw_docs/discovery-guide.md | 114 ++- Tests.Discovery/MacVendorLookupTests.cs | 96 +++ Tests.Discovery/NetworkIdentityTests.cs | 216 ++++++ Tests.Discovery/NetworkScannerTests.cs | 205 +++++- Tests.Discovery/RunsOnByIpTests.cs | 128 ++++ Tests.Discovery/ServiceIdentityParserTests.cs | 138 ++++ generate-oui-table.py | 209 ++++++ 20 files changed, 2615 insertions(+), 18 deletions(-) create mode 100644 RackPeek.Domain/Discovery/MacVendorLookup.cs create mode 100644 RackPeek.Domain/Discovery/MacVendorTable.g.cs create mode 100644 RackPeek.Domain/Discovery/ServiceIdentityParser.cs create mode 100644 Tests.Discovery/MacVendorLookupTests.cs create mode 100644 Tests.Discovery/NetworkIdentityTests.cs create mode 100644 Tests.Discovery/RunsOnByIpTests.cs create mode 100644 Tests.Discovery/ServiceIdentityParserTests.cs create mode 100755 generate-oui-table.py diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs index 0cae8102..dcb4eeec 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; @@ -65,6 +67,71 @@ public static void ResolveNames( } PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName); + AnchorRunsOnByIp(existing, incoming, existingByName); + } + + /// + /// 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. + var systemsByIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (SystemResource system in existing.OfType().Concat(incoming.OfType())) { + if (string.IsNullOrWhiteSpace(system.Ip)) + continue; + + if (!systemsByIp.TryGetValue(system.Ip, out List? names)) + systemsByIp[system.Ip] = names = []; + + if (!names.Contains(system.Name, StringComparer.OrdinalIgnoreCase)) + names.Add(system.Name); + } + + 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; + + if (!systemsByIp.TryGetValue(ip, out List? candidates) || candidates.Count != 1) + continue; + + service.RunsOn = [candidates[0]]; + } } /// 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/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..2280631a 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,87 @@ public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) { if (host.Mac != null) system.Labels["mac"] = host.Mac; + if (host.Vendor != null) + system.Labels["vendor"] = host.Vendor; + + // The ports are observations, not conclusions: "554 is open" is a fact, while + // "this is a camera" is an inference the reader is far better placed to make + // than the scanner. Recording them keeps the evidence without inventing a + // service that may not be what the port number conventionally implies. + if (host.OpenPorts.Count > 0) + system.Labels["open-ports"] = string.Join(",", host.OpenPorts); + + // 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); + + // An application that named itself over HTTP is a fact about what the host + // runs, not about what the host is, so it becomes a Service hanging off the + // card rather than renaming it. + foreach (ServiceIdentity found in host.Services) { + // An appliance's own management UI is not a service running on it: a + // firewall whose page says "OPNsense" on a card already called opnsense + // would otherwise get a second card named opnsense-<hash>, which says + // nothing the first one did not. + if (DiscoveryNaming.Slug(DiscoveryNaming.HostLabel(found.Name)) + .Equals(system.Name, StringComparison.OrdinalIgnoreCase)) + continue; + + var serviceId = DiscoveryId.Create( + DiscoveryId.NetworkScheme, + $"{host.Mac ?? $"ip:{host.Ip}"}:{found.Port}"); + + resources.Add(new Service { + Kind = Service.KindLabel, + Name = DiscoveryNaming.Unique( + DiscoveryNaming.Suggest( + DiscoveryNaming.HostLabel(found.Name), + "service", + serviceId), + serviceId, + taken), + DiscoveryId = serviceId, + Network = new Network { + Ip = host.Ip, + Port = found.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/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..35d36f30 100644 --- a/RackPeek.Domain/Discovery/WellKnownPorts.cs +++ b/RackPeek.Domain/Discovery/WellKnownPorts.cs @@ -20,4 +20,31 @@ 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 + ]; } 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/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/wwwroot/raw_docs/cli-commands.md b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md index 39622303..86db5915 100644 --- a/Shared.Rcl/wwwroot/raw_docs/cli-commands.md +++ b/Shared.Rcl/wwwroot/raw_docs/cli-commands.md @@ -4090,6 +4090,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..84ac0c26 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -8,7 +8,7 @@ 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 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. @@ -227,10 +227,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 +238,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 +252,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 +291,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 @@ -322,8 +322,9 @@ with no cluster uses its node name as the scope instead. ## `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 +343,95 @@ 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 — +and whatever is open lands in an `open-ports` label. + +These are recorded as observations, not conclusions. "554 is open" is a fact; "this is a +camera" is an inference, and the person reading the card is far better placed to draw it +than the scanner is. A port number is a convention rather than a guarantee, so RackPeek +will not name a service from one — but a port **is** the best possible target for the +banner probes above, which is how `9000` became "Portainer" and `8123` became +"Home Assistant". + +The list is what answered out of the ports probed, not a full port scan. `--ports` +widens the liveness set if you want more. + +### Applications become Services + +When a port answers HTTP with something that names itself, that is a fact about what the +host **runs**, not about what the host **is** — so it becomes a Service hanging off the +host's card rather than renaming it: + +```yaml +- kind: System + ip: 192.0.2.204 + name: host-1a2b3c4d + labels: + open-ports: 1883,8123 + identified-by: http:8123 Home Assistant +- kind: Service + name: home-assistant + network: { ip: 192.0.2.204, port: 8123, protocol: TCP } + runsOn: [host-1a2b3c4d] +``` + +A host may run several, so each identified port gets its own Service with its own stable +id — a rescan updates them rather than duplicating them. This is 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. + +An appliance's own management page is not a service running on it, so a title matching +the host's own name is skipped — a firewall already called `opnsense` does not also need +a service called `opnsense`. + +### Vendor from the MAC + +Where the sweep has a MAC, the card also gets a `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. + +Two 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 +439,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 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..623fd966 --- /dev/null +++ b/Tests.Discovery/NetworkIdentityTests.cs @@ -0,0 +1,216 @@ +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_not_a_service_on_itself() { + // A firewall whose page says "OPNsense" on a card already called opnsense would + // otherwise gain a second card named opnsense- saying nothing new. + 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.Empty(cards.OfType()); + } + + [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["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("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/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs new file mode 100644 index 00000000..2f757e57 --- /dev/null +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -0,0 +1,128 @@ +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 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); + } +} 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/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])) From fca60524e2968eba3a1c991dfcd992d3c7b02c6c Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Sun, 27 Sep 2026 22:47:36 +0100 Subject: [PATCH 2/9] Learn where Proxmox guests are, and stop a sweep duplicating them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox only records a guest's address when someone set one statically, so on a DHCP estate every guest arrived with no address at all. That cost more than the field: a network sweep of another subnet has no ARP entry to work from, so it identifies a host by address alone — and with the guests carrying no address there was nothing to match, leaving a second, emptier card beside every guest the hypervisor had already described in full. Guests are now asked where they are. A VM answers through its qemu-guest-agent, a container through its running interfaces; a stopped guest is never asked, since it has nothing to report and the call is one round trip per guest. Choosing among the answers is the hard part. A guest that runs containers reports several interfaces — 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. The NIC MACs Proxmox assigned are the discriminator: an interface carrying one is a NIC the hypervisor gave the guest, anything else the guest invented. Where the MACs cannot be read, nothing is claimed. A statically configured address still wins, being what the administrator asked for. With addresses in hand a sweep's find can be matched to the guest it actually is, so the resolver gains an address bridge beside the MAC one. Narrow on purpose: only a scan-grade card that produced no MAC of its own, only against an agent-grade card of the same kind, and only when exactly one stored system claims that address. Services anchor to the stored card in preference to a sweep's stand-in for the same reason. On a live two-node estate this turned nine scanned cards on the guest subnet into six guests updated in place and two genuinely unknown hosts, with the discovered web services landing on the real VMs rather than on stand-ins. The read orchestration moves into the domain on the way past. The CLI and the MCP tool each had a copy, and the copies had already drifted — the MCP one dropped the guests' MACs, silently costing every guest its chance of unifying with a scan. Now there is one, and it is tested. Co-Authored-By: Claude Fable 5 --- .../Discovery/DiscoveryIdResolver.cs | 124 +++++++++-- RackPeek.Domain/Discovery/IProxmoxClient.cs | 12 + RackPeek.Domain/Discovery/ProxmoxApiClient.cs | 30 +++ RackPeek.Domain/Discovery/ProxmoxDiscovery.cs | 107 +++++++++ RackPeek.Domain/Discovery/ProxmoxModels.cs | 103 +++++++++ RackPeek.Mcp/Tools/DiscoveryTools.cs | 36 +-- .../Discovery/DiscoverProxmoxCommand.cs | 43 +--- .../wwwroot/raw_docs/discovery-guide.md | 27 +++ .../Fixtures/pve-agent-interfaces.json | 36 +++ .../Fixtures/pve-lxc-interfaces.json | 16 ++ Tests.Discovery/ProxmoxGuestAddressTests.cs | 207 ++++++++++++++++++ Tests.Discovery/RunsOnByIpTests.cs | 99 +++++++++ 12 files changed, 747 insertions(+), 93 deletions(-) create mode 100644 RackPeek.Domain/Discovery/ProxmoxDiscovery.cs create mode 100644 Tests.Discovery/Fixtures/pve-agent-interfaces.json create mode 100644 Tests.Discovery/Fixtures/pve-lxc-interfaces.json create mode 100644 Tests.Discovery/ProxmoxGuestAddressTests.cs diff --git a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs index dcb4eeec..805f86c8 100644 --- a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -41,6 +41,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. @@ -52,7 +53,7 @@ public static void ResolveNames( var renames = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (Resource resource in incomingWithId) { - var resolved = ResolveName(resource, existingById, existingByName, existingByMac); + var resolved = ResolveName(resource, existingById, existingByName, existingByMac, existingByIp); if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)) continue; @@ -102,18 +103,9 @@ private static void AnchorRunsOnByIp( // 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. - var systemsByIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - foreach (SystemResource system in existing.OfType().Concat(incoming.OfType())) { - if (string.IsNullOrWhiteSpace(system.Ip)) - continue; - - if (!systemsByIp.TryGetValue(system.Ip, out List? names)) - systemsByIp[system.Ip] = names = []; - - if (!names.Contains(system.Name, StringComparer.OrdinalIgnoreCase)) - names.Add(system.Name); - } + // 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; @@ -127,11 +119,37 @@ private static void AnchorRunsOnByIp( if (anchored) continue; - if (!systemsByIp.TryGetValue(ip, out List? candidates) || candidates.Count != 1) + // 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; - service.RunsOn = [candidates[0]]; + 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; } /// @@ -170,7 +188,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; @@ -180,6 +199,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; @@ -258,6 +280,76 @@ 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 already describes in full. + /// + /// Narrow on purpose. It applies only to a scan-grade card that produced no + /// MAC of its own — one that has a MAC was either already unified above or + /// genuinely disagrees, and a MAC is better evidence than an address. The + /// stored card must be agent-grade and of the same kind, and must be the only + /// one claiming that address: 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 (DiscoveryId.Scheme(resource.DiscoveryId) != DiscoveryId.NetworkScheme) + return false; + + // A scan that saw a MAC has better evidence than an address, and the MAC rule + // above has already had its say. + if (MacsOf(resource).Any()) + return false; + + 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; + + // Another scan card at the same address says nothing: both are stand-ins. + if (DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme) + return false; + + // Same as the MAC bridge: the scan's weaker identity is dropped so the merge + // cannot downgrade the stored one. + 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/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/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 GetGuestConfigAsync( } } + public async Task> 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 FirstNodeAsync(CancellationToken cancellationToken) { IReadOnlyList 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; + +/// +/// Reads a whole Proxmox endpoint and maps it to resources. +/// +/// 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. +/// +/// +public static class ProxmoxDiscovery { + public static async Task> ReadAsync( + IProxmoxClient client, + CancellationToken cancellationToken = default) { + 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))); + + // 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[] addresses = await Task.WhenAll( + listedGuests.Select(g => IsRunning(g) + ? client.GetGuestAddressesAsync(nodeName, endpoint, g.VmId, cancellationToken) + : Task.FromResult>([]))); + + for (var i = 0; i < listedGuests.Count; i++) { + IReadOnlyList 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); + } + + /// + /// The address that belongs to the guest itself. + /// + /// A guest agent reports every interface inside the machine, and a guest that + /// runs containers has several: Docker's docker0 and its per-network + /// bridges, Home Assistant's hassio, 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. + /// + /// + /// 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. + /// + /// + public static string? SelectGuestIp( + IReadOnlyList addresses, + IReadOnlyList configuredMacs) { + if (addresses.Count == 0 || configuredMacs.Count == 0) + return null; + + var allowed = new HashSet( + 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..11909199 100644 --- a/RackPeek.Domain/Discovery/ProxmoxModels.cs +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -62,6 +62,12 @@ public sealed record ProxmoxGuest { public IReadOnlyList Tags { get; init; } = []; + /// + /// running, stopped 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. + /// + public string? Status { get; init; } + /// Filled in from the guest's config, which is the only place it is known. 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")) }; } @@ -464,9 +471,105 @@ private static IEnumerable Data(string json) { element.TryGetProperty(name, out JsonElement value) && value.TryGetInt64(out var result) ? result : null; + + /// + /// Addresses a QEMU guest reports through its guest agent + /// (agent/network-get-interfaces). 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 + /// . + /// + public static List ParseAgentInterfaces(string json) { + var addresses = new List(); + + 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; + } + + /// + /// Addresses a container reports through lxc/{vmid}/interfaces, which is a + /// flat list rather than the agent's nested shape and spells the address with its + /// prefix (10.0.0.5/24). + /// + public static List ParseContainerInterfaces(string json) { + var addresses = new List(); + + 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; + } + + /// + /// Whether an address is worth recording: a real IPv4 that is neither loopback + /// nor the 169.254 a guest assigns itself when DHCP fails. + /// + 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; } /// The parts of a guest's config worth recording. Everything is optional. +/// One address a guest reports for one of its own interfaces. +public sealed record ProxmoxGuestAddress(string? Interface, string? Mac, string Ip); + public sealed record ProxmoxGuestConfig( string? Os, string? Ip, diff --git a/RackPeek.Mcp/Tools/DiscoveryTools.cs b/RackPeek.Mcp/Tools/DiscoveryTools.cs index 4db8bbf7..ea0501d8 100644 --- a/RackPeek.Mcp/Tools/DiscoveryTools.cs +++ b/RackPeek.Mcp/Tools/DiscoveryTools.cs @@ -125,7 +125,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 +156,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/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/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 84ac0c26..01a5b5ad 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -319,6 +319,33 @@ 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 network` The collector for machines nothing else can describe: no agent, no API — just an 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/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/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs index 2f757e57..d799b050 100644 --- a/Tests.Discovery/RunsOnByIpTests.cs +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -79,6 +79,22 @@ public void A_working_host_link_is_never_overwritten() { 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 @@ -125,4 +141,87 @@ public void Systems_are_not_anchored_by_address() { 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 One_sweep_find_never_unifies_with_another() { + // A card the sweep itself produced is a stand-in for something nobody has + // described. Two stand-ins at one address say nothing about each other, so the + // address rule stays out of it — here the stored one was identified by MAC on + // its own segment, and the incoming one only by address. + 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); + + Assert.Equal("host-1a2b3c4d", incoming.OfType().Single().Name); + } } From d51415ae81ecf5c52eec1d01c7caf989603b980a Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 28 Sep 2026 08:24:22 +0100 Subject: [PATCH 3/9] Fix three discovery defects found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Proxmox number written as a string took the whole run down. Its perl backend quotes numeric fields inconsistently — "vmid":"100" and "maxdisk":"512110190592" both turn up across versions — and the TryGetInt32 family does not return false for a string, it throws. The kind is now checked first and a quoted number parsed, which is what the caller meant either way. Before this, one such field produced "Unexpected error occurred" plus a stack trace on the CLI, and MCP forwarded the BCL's "requires an element of type 'Number'" as though the user had done something wrong. An ssh:// or npipe:// DOCKER_HOST crashed. `docker context` sets the first for a remote host and the second is the Windows default, so both are ordinary values to find in the environment. HttpClient accepts either URI and only throws NotSupportedException on the first request, which was past the command's catch list. They are now refused where the client is built, through the UriFormatException both front ends already turn into "not a usable Docker endpoint" — one phrasing for a bad endpoint rather than two — and the message says how to forward the socket instead. A remote engine with no IPv4 address had its services recorded at this machine's address. Services are recorded at their host's address and the inventory holds IPv4 only, so when the endpoint resolves to IPv6 alone the address is genuinely unknown; substituting whatever machine ran the command gave every service a confident, wrong address that then flowed into the ansible, ssh and hosts exports. There is no fallback now, and the run stops with an explanation. Each fix has tests that fail without it: quoted numbers across the guest list, both unreachable endpoint schemes, and an engine answering on an IPv6-only socket. Co-Authored-By: Claude Fable 5 --- RackPeek.Domain/Discovery/DockerApiClient.cs | 19 ++++++ RackPeek.Domain/Discovery/ProxmoxModels.cs | 40 +++++++++--- RackPeek.Mcp/Tools/DiscoveryTools.cs | 11 +++- .../Discovery/DiscoverDockerCommand.cs | 16 ++++- Tests.Discovery/ProxmoxQuotedNumberTests.cs | 63 +++++++++++++++++++ Tests.Discovery/RemoteDockerDiscoveryTests.cs | 29 +++++++++ Tests.Mcp/DiscoveryToolTests.cs | 24 +++++++ Tests.Mcp/FakeHttpServer.cs | 11 ++-- .../DiscoverDockerEndpointTests.cs | 33 ++++++++++ 9 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 Tests.Discovery/ProxmoxQuotedNumberTests.cs create mode 100644 Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs 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/ProxmoxModels.cs b/RackPeek.Domain/Discovery/ProxmoxModels.cs index 11909199..52573225 100644 --- a/RackPeek.Domain/Discovery/ProxmoxModels.cs +++ b/RackPeek.Domain/Discovery/ProxmoxModels.cs @@ -462,15 +462,39 @@ private static IEnumerable 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; + /// + /// A number Proxmox may have written as a string. + /// + /// Its 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 + /// 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. + /// + /// + 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 + }; + } + + /// + 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 + }; + } /// /// Addresses a QEMU guest reports through its guest agent diff --git a/RackPeek.Mcp/Tools/DiscoveryTools.cs b/RackPeek.Mcp/Tools/DiscoveryTools.cs index ea0501d8..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); 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/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.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); + } +} From 6cd8f9847ad8d5404fb67a632c4195d50b276af1 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 28 Sep 2026 08:36:23 +0100 Subject: [PATCH 4/9] Add rpk discover opnsense: read the firewall's neighbour table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collector for everything a sweep can see but not identify. ARP is link-local. Sweeping from one machine yields 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 already documented. The firewall routes every subnet, so its neighbour table carries the MAC for all of them, the name it handed out, and which leg each machine answered on. Cards are seeded exactly as the network sweep 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 ran first, with no special case anywhere to say so. On a live estate this turned 21 resources into 13 updated in place and 8 machines no sweep had ever seen — devices that answer no port and no ping but sit in the firewall's table. What it leaves out, on purpose: the firewall's own addresses (every routed subnet contributes one and they are all the same box, which is a Firewall rather than the handful of Systems this would invent), entries that have aged out, broadcast and multicast, and neighbours on public addresses. That last one is the ISP's equipment on the WAN leg — not the user's infrastructure, and recording it would put a public address into a file people commit. --include-public asks for them. Tolerant of the endpoint rename in OPNsense 25.7, which moved these actions from camelCase to snake_case and broke integrations pinned to either spelling; both are tried. Both response shapes are read too, since the search wrapper wraps the same rows in an object. A key that lacks the Diagnostics: ARP Table privilege gets a redirect to the login page rather than a 401, so that is reported as the permission problem it is. Verified against a live two-firewall estate; the fixtures and tests use invented addresses and MAC suffixes throughout. Co-Authored-By: Claude Fable 5 --- .../Discovery/OpnsenseApiClient.cs | 128 +++++++++++++ .../Discovery/OpnsenseDiscovery.cs | 108 +++++++++++ RackPeek.Domain/Discovery/OpnsenseModels.cs | 120 +++++++++++++ Shared.Rcl/CliBootstrap.cs | 5 + .../Discovery/DiscoverOpnsenseCommand.cs | 100 +++++++++++ .../wwwroot/raw_docs/cli-commands-index.md | 1 + Shared.Rcl/wwwroot/raw_docs/cli-commands.md | 45 ++++- .../wwwroot/raw_docs/discovery-guide.md | 69 +++++++ Tests.Discovery/Fixtures/opnsense-arp.json | 86 +++++++++ Tests.Discovery/OpnsenseDiscoveryTests.cs | 169 ++++++++++++++++++ 10 files changed, 826 insertions(+), 5 deletions(-) create mode 100644 RackPeek.Domain/Discovery/OpnsenseApiClient.cs create mode 100644 RackPeek.Domain/Discovery/OpnsenseDiscovery.cs create mode 100644 RackPeek.Domain/Discovery/OpnsenseModels.cs create mode 100644 Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs create mode 100644 Tests.Discovery/Fixtures/opnsense-arp.json create mode 100644 Tests.Discovery/OpnsenseDiscoveryTests.cs 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; + +/// Reads an OPNsense firewall's API. The IO half of firewall discovery. +public interface IOpnsenseClient { + /// Where this client is pointed, for error messages. + string Endpoint { get; } + + /// + /// Every neighbour the firewall currently has an ARP entry for, across every + /// subnet it routes. + /// + Task> GetNeighboursAsync(CancellationToken cancellationToken = default); +} + +/// +/// 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. +/// +public sealed class OpnsenseApiClient : IOpnsenseClient, IDisposable { + public const string KeyEnvironmentVariable = "RPK_OPN_KEY"; + public const string SecretEnvironmentVariable = "RPK_OPN_SECRET"; + + /// + /// 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. + /// + private static readonly string[] _arpPaths = [ + "api/diagnostics/interface/get_arp", + "api/diagnostics/interface/getArp" + ]; + + private readonly HttpClient _httpClient; + + /// + /// OPNsense ships with a self-signed certificate and most installations keep it. + /// Opt-in all the same. + /// + 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> 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."); + } + + /// + /// 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. + /// + 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 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..eea457e2 --- /dev/null +++ b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs @@ -0,0 +1,108 @@ +using RackPeek.Domain.Resources; +using RackPeek.Domain.Resources.SystemResources; + +namespace RackPeek.Domain.Discovery; + +/// +/// Turns a firewall's neighbour table into System resources. +/// +/// The cards are seeded exactly as 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. +/// +/// +public static class OpnsenseDiscovery { + public static async Task> ReadAsync( + IOpnsenseClient client, + bool includePublic = false, + CancellationToken cancellationToken = default) => + ToResources(await client.GetNeighboursAsync(cancellationToken), includePublic); + + /// + /// Whether an address belongs to a network someone runs themselves: the RFC 1918 + /// ranges plus the carrier-grade block an ISP may hand out. + /// + /// 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. + /// + /// + 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 ToResources( + IReadOnlyList neighbours, + bool includePublic = false) { + var taken = new HashSet(StringComparer.OrdinalIgnoreCase); + var resources = new List(); + + 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; + + if (vendor != null) + system.Labels["vendor"] = vendor; + + // Which leg of the firewall saw it — the closest thing to a physical location + // the firewall can offer, and the thing that says which VLAN a host is on. + if (neighbour.Interface != null) + system.Labels["segment"] = neighbour.Interface; + + 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; + +/// +/// One neighbour the firewall has seen, from its ARP table. +/// +/// 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. +/// +/// +public sealed record OpnsenseNeighbour( + string Ip, + string Mac, + string? Hostname, + string? Manufacturer, + string? Interface); + +public static class OpnsenseResponseParser { + /// + /// Reads the ARP table. OPNsense answers either a flat array or, through the + /// search wrapper, an object with a rows array; both shapes are accepted so + /// the caller need not care which endpoint answered. + /// + public static List ParseArp(string json) { + var neighbours = new List(); + + 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(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; + } + + /// + /// A locally administered group address — the low bit of the first octet marks + /// multicast, which no host owns. + /// + 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; + + /// + /// OPNsense writes these as real booleans in some versions and as the strings + /// "1"/"true" in others. + /// + 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 + }; + } + + /// Blank and placeholder values arrive as empty strings or dashes. + private static string? Clean(string? value) { + var trimmed = value?.Trim(); + + return string.IsNullOrEmpty(trimmed) || trimmed is "-" or "(none)" ? null : trimmed; + } +} 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/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/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 86db5915..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: diff --git a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 01a5b5ad..12ef579d 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -8,6 +8,7 @@ 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 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 @@ -346,6 +347,74 @@ 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, the vendor that MAC belongs +to, and a `segment` label naming the firewall leg it answered on — which is the closest +thing to "which VLAN is this on" that the firewall can tell you. + +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. + +One wrinkle worth knowing: names are yours, so discovery never renames a resource that +already exists — including one a sweep named `host-` before the firewall could +offer something better. Running the firewall collector first, or on a fresh inventory, +gets you the good names. + +--- + ## `rpk discover network` The collector for machines nothing else can describe: no agent, no API — just an 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/OpnsenseDiscoveryTests.cs b/Tests.Discovery/OpnsenseDiscoveryTests.cs new file mode 100644 index 00000000..fe4baf77 --- /dev/null +++ b/Tests.Discovery/OpnsenseDiscoveryTests.cs @@ -0,0 +1,169 @@ +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_the_vendor_and_the_leg_it_was_seen_on() { + 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["vendor"]); + Assert.Equal("HomeServices", card.Labels["segment"]); + Assert.Equal("192.168.50.105", card.Ip); + } + + [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["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}""")); + } +} From a5143e366231467ef621a5c0f6af3aa7b4f2c76e Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:31 +0100 Subject: [PATCH 5/9] Make every open port a Service, and name labels for what they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open port was landing in an `open-ports` label: a comma-separated string 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 each open port now becomes a Service with its own stable id, running on the host that serves it — nebula-ssh, nebula-https, nebula-proxmox. What a service said about itself still beats what its port number implies, because a port is a convention and an answer is evidence. Where nothing answered the name falls back to the host plus the port's usual service, and an unrecognised port keeps its number as nebula-tcp-9987 rather than guessing. An appliance whose management page only says its own name back is named for the port instead, so a firewall called opnsense gains an opnsense-https rather than a second card called opnsense. Two labels change with it. `vendor` becomes `nic-vendor`, because 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. And `segment` goes: it described the firewall's wiring rather than the machine, and changed whenever anything was re-cabled. Co-Authored-By: Claude Opus 5 (1M context) --- .../Discovery/NetworkScanMapper.cs | 59 +++++---- .../Discovery/OpnsenseDiscovery.cs | 9 +- RackPeek.Domain/Discovery/WellKnownPorts.cs | 47 +++++++ Tests.Discovery/NetworkIdentityTests.cs | 11 +- Tests.Discovery/OpenPortServiceTests.cs | 115 ++++++++++++++++++ Tests.Discovery/OpnsenseDiscoveryTests.cs | 16 ++- 6 files changed, 217 insertions(+), 40 deletions(-) create mode 100644 Tests.Discovery/OpenPortServiceTests.cs diff --git a/RackPeek.Domain/Discovery/NetworkScanMapper.cs b/RackPeek.Domain/Discovery/NetworkScanMapper.cs index 2280631a..096afe6f 100644 --- a/RackPeek.Domain/Discovery/NetworkScanMapper.cs +++ b/RackPeek.Domain/Discovery/NetworkScanMapper.cs @@ -45,15 +45,11 @@ public static List ToResources(IReadOnlyList 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["vendor"] = host.Vendor; - - // The ports are observations, not conclusions: "554 is open" is a fact, while - // "this is a camera" is an inference the reader is far better placed to make - // than the scanner. Recording them keeps the evidence without inventing a - // service that may not be what the port number conventionally implies. - if (host.OpenPorts.Count > 0) - system.Labels["open-ports"] = string.Join(",", host.OpenPorts); + 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. @@ -66,35 +62,48 @@ public static List ToResources(IReadOnlyList hosts) { resources.Add(system); - // An application that named itself over HTTP is a fact about what the host - // runs, not about what the host is, so it becomes a Service hanging off the - // card rather than renaming it. - foreach (ServiceIdentity found in host.Services) { - // An appliance's own management UI is not a service running on it: a - // firewall whose page says "OPNsense" on a card already called opnsense - // would otherwise get a second card named opnsense-, which says - // nothing the first one did not. - if (DiscoveryNaming.Slug(DiscoveryNaming.HostLabel(found.Name)) - .Equals(system.Name, StringComparison.OrdinalIgnoreCase)) - continue; + // 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 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 ports = host.OpenPorts + .Concat(identified.Keys) + .Distinct(); + foreach (var port in ports) { var serviceId = DiscoveryId.Create( DiscoveryId.NetworkScheme, - $"{host.Mac ?? $"ip:{host.Ip}"}:{found.Port}"); + $"{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( - DiscoveryNaming.HostLabel(found.Name), - "service", - serviceId), + DiscoveryNaming.Suggest(serviceLabel, "service", serviceId), serviceId, taken), DiscoveryId = serviceId, Network = new Network { Ip = host.Ip, - Port = found.Port, + Port = port, Protocol = "TCP" }, RunsOn = [system.Name] diff --git a/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs index eea457e2..15d02d66 100644 --- a/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs +++ b/RackPeek.Domain/Discovery/OpnsenseDiscovery.cs @@ -82,13 +82,10 @@ public static List ToResources( // 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["vendor"] = vendor; - - // Which leg of the firewall saw it — the closest thing to a physical location - // the firewall can offer, and the thing that says which VLAN a host is on. - if (neighbour.Interface != null) - system.Labels["segment"] = neighbour.Interface; + system.Labels["nic-vendor"] = vendor; resources.Add(system); } diff --git a/RackPeek.Domain/Discovery/WellKnownPorts.cs b/RackPeek.Domain/Discovery/WellKnownPorts.cs index 35d36f30..84579d3b 100644 --- a/RackPeek.Domain/Discovery/WellKnownPorts.cs +++ b/RackPeek.Domain/Discovery/WellKnownPorts.cs @@ -47,4 +47,51 @@ public static class WellKnownPorts { 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/Tests.Discovery/NetworkIdentityTests.cs b/Tests.Discovery/NetworkIdentityTests.cs index 623fd966..2e137db0 100644 --- a/Tests.Discovery/NetworkIdentityTests.cs +++ b/Tests.Discovery/NetworkIdentityTests.cs @@ -124,9 +124,10 @@ public void Several_applications_on_one_host_each_get_their_own_service() { } [Fact] - public void An_appliances_own_management_page_is_not_a_service_on_itself() { + 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. + // 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", @@ -135,7 +136,7 @@ public void An_appliances_own_management_page_is_not_a_service_on_itself() { ]); Assert.Equal("opnsense", Assert.Single(cards.OfType()).Name); - Assert.Empty(cards.OfType()); + Assert.Equal("opnsense-http", Assert.Single(cards.OfType()).Name); } [Fact] @@ -159,7 +160,7 @@ public void A_services_id_survives_a_rescan_and_differs_per_port() { 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["vendor"]); + Assert.Equal("Espressif", card.Labels["nic-vendor"]); Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]); } @@ -167,7 +168,7 @@ public void A_vendor_is_labelled_when_the_mac_is_known() { 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("vendor")); + Assert.False(card.Labels.ContainsKey("nic-vendor")); } [Fact] 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 index fe4baf77..2173e5ec 100644 --- a/Tests.Discovery/OpnsenseDiscoveryTests.cs +++ b/Tests.Discovery/OpnsenseDiscoveryTests.cs @@ -95,15 +95,23 @@ public void A_neighbour_with_no_name_still_gets_a_stable_one() { } [Fact] - public void A_card_carries_the_mac_the_vendor_and_the_leg_it_was_seen_on() { + 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["vendor"]); - Assert.Equal("HomeServices", card.Labels["segment"]); + 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 @@ -112,7 +120,7 @@ public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() { List cards = Discover(true); SystemResource wan = cards.OfType().Single(s => s.Ip == "198.51.100.7"); - Assert.Equal("Cisco Systems", wan.Labels["vendor"]); + Assert.Equal("Cisco Systems", wan.Labels["nic-vendor"]); } [Fact] From 8900008c55f3676a2a36f2f04703086e6afedf60 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:47 +0100 Subject: [PATCH 6/9] Let names and identity improve as collectors learn more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Those placeholders now give way to real names, and everything pointing at the old name follows: stored runsOn, stored connections, services named after their host, and the incoming payload's own references. A new `userNamed` flag draws the line discovery must not cross. Renaming sets it, every rename path goes through one use case, and nothing in discovery touches the name again. A resource with no discoveryId is user-named whatever the flag says — nothing but a person could have written it. The upgrade is one-way, placeholder to real, and one real name never replaces another, or two collectors that each knew a different name would rename a box back and forth on every run. The address bridge is now symmetric. It fired only when the incoming card was the scan and the stored one was agent-grade, so sweeping before running the hypervisor left both cards behind where the other order produced one. Which collector ran first is an accident of what someone typed and must not decide what the inventory holds. Two scan cards can bridge as well, but only when exactly one of them saw a MAC: a firewall's neighbour table names the interface answering at an address where a sweep of a subnet it does not sit on only knows something replied, and those are not two stand-ins. Cards of equal standing never bridge. Measured on a nine-subnet lab, sweeping first: fourteen machines were in the inventory twice, and are now in it once. Co-Authored-By: Claude Opus 5 (1M context) --- RackPeek.Domain/Api/UpsertInventoryUseCase.cs | 12 +- .../Discovery/DiscoveryIdResolver.cs | 215 ++++++++++-- .../Yaml/YamlResourceCollection.cs | 4 +- RackPeek.Domain/Resources/Resource.cs | 28 ++ .../UseCases/RenameResourceUseCase.cs | 4 + .../wwwroot/schemas/v4/schema.v4.json | 4 + .../wwwroot/schemas/v4/schema.v4.json | 4 + Tests.Discovery/IpBridgeSymmetryTests.cs | 171 ++++++++++ .../PlaceholderNameUpgradeTests.cs | 321 ++++++++++++++++++ Tests.Discovery/RunsOnByIpTests.cs | 57 +++- schemas/v4/schema.v4.json | 4 + 11 files changed, 793 insertions(+), 31 deletions(-) create mode 100644 Tests.Discovery/IpBridgeSymmetryTests.cs create mode 100644 Tests.Discovery/PlaceholderNameUpgradeTests.cs 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 805f86c8..e6a8e77b 100644 --- a/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs +++ b/RackPeek.Domain/Discovery/DiscoveryIdResolver.cs @@ -21,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(); @@ -52,13 +62,37 @@ public static void ResolveNames( var renames = new Dictionary(StringComparer.OrdinalIgnoreCase); + var storedRenames = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Resource resource in incomingWithId) { + // 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(resource.Name, StringComparison.OrdinalIgnoreCase)) + if (resolved.Equals(offeredName, StringComparison.OrdinalIgnoreCase)) continue; - renames[resource.Name] = resolved; + // 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; + + continue; + } + + renames[offeredName] = resolved; resource.Name = resolved; } @@ -67,10 +101,111 @@ 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. @@ -285,14 +420,33 @@ private static bool TryUnifyByMac( /// 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 already describes in full. + /// guest the inventory describes in full. /// - /// Narrow on purpose. It applies only to a scan-grade card that produced no - /// MAC of its own — one that has a MAC was either already unified above or - /// genuinely disagrees, and a MAC is better evidence than an address. The - /// stored card must be agent-grade and of the same kind, and must be the only - /// one claiming that address: two cards on one address is a conflict or an - /// overlapping subnet, neither of which is evidence of anything. + /// 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( @@ -301,14 +455,6 @@ private static bool TryUnifyByIp( out string unifiedName) { unifiedName = string.Empty; - if (DiscoveryId.Scheme(resource.DiscoveryId) != DiscoveryId.NetworkScheme) - return false; - - // A scan that saw a MAC has better evidence than an address, and the MAC rule - // above has already had its say. - if (MacsOf(resource).Any()) - return false; - if (resource is not SystemResource { Ip: { } ip } || string.IsNullOrWhiteSpace(ip)) return false; @@ -316,13 +462,36 @@ private static bool TryUnifyByIp( || stored.GetType() != resource.GetType()) return false; - // Another scan card at the same address says nothing: both are stand-ins. - if (DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme) + 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 scan's weaker identity is dropped so the merge - // cannot downgrade the stored one. - resource.DiscoveryId = null; + // 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; 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/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/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.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/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/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/RunsOnByIpTests.cs b/Tests.Discovery/RunsOnByIpTests.cs index d799b050..5008c51d 100644 --- a/Tests.Discovery/RunsOnByIpTests.cs +++ b/Tests.Discovery/RunsOnByIpTests.cs @@ -212,16 +212,63 @@ public void Two_stored_systems_at_one_address_unify_nothing() { } [Fact] - public void One_sweep_find_never_unifies_with_another() { - // A card the sweep itself produced is a stand-in for something nobody has - // described. Two stand-ins at one address say nothing about each other, so the - // address rule stays out of it — here the stored one was identified by MAC on - // its own segment, and the incoming one only by address. + 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/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": { From bed8c3ab95b04df96388bfbd7dad095411e74428 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:59 +0100 Subject: [PATCH 7/9] Only offer a service link a browser can actually follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs, one cause: three places each decided for themselves what a service's URL was, and all three were wrong. The card derived every link as http:// regardless of the port, so SSH on 22 rendered as http://10.0.0.5:22/ — a link that looks real, invites a click and cannot load. It was reading Network.Protocol as though it named a scheme, but discovery writes the transport there (TCP), which says nothing about what rides on top of it. The dependency trees had it worse: they fed NetworkString() into an href, and that was display text — "Ip: 10.0.0.5:3000", prefix and trailing space included — so the link was dead on arrival. Both rules now live in one place. A scheme is only inferred where the convention is genuinely a web one; ssh, smb, mqtt, dns and anything uncurated get no link at all, because a dead link is worse than none. A URL someone typed by hand still always wins, and an explicit http or https in the protocol field is still believed, which is the only thing that can answer on an uncurated port. Co-Authored-By: Claude Opus 5 (1M context) --- RackPeek.Domain/Resources/Services/Service.cs | 36 +++---- .../Resources/Services/ServiceEndpoint.cs | 78 +++++++++++++++ .../HardwareDependencyTreeComponent.razor | 41 +++++--- .../Services/ServiceCardComponent.razor | 25 +---- .../SystemDependencyTreeComponent.razor | 41 +++++--- Tests.Discovery/ServiceEndpointTests.cs | 96 +++++++++++++++++++ 6 files changed, 253 insertions(+), 64 deletions(-) create mode 100644 RackPeek.Domain/Resources/Services/ServiceEndpoint.cs create mode 100644 Tests.Discovery/ServiceEndpointTests.cs 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..a59140ce --- /dev/null +++ b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs @@ -0,0 +1,78 @@ +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; + + return new UriBuilder(scheme, ip) { Port = port }.Uri.ToString(); + } + + 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/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/Tests.Discovery/ServiceEndpointTests.cs b/Tests.Discovery/ServiceEndpointTests.cs new file mode 100644 index 00000000..f0c08457 --- /dev/null +++ b/Tests.Discovery/ServiceEndpointTests.cs @@ -0,0 +1,96 @@ +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()); +} From fbd0b2b511b38841441990f612409125c80f0b77 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:03:59 +0100 Subject: [PATCH 8/9] Document the discovery changes, and ignore the local triage scratch file Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + .../wwwroot/raw_docs/discovery-guide.md | 121 ++++++++++++------ 2 files changed, 83 insertions(+), 39 deletions(-) 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/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md index 12ef579d..c0fb12fc 100644 --- a/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md +++ b/Shared.Rcl/wwwroot/raw_docs/discovery-guide.md @@ -61,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 @@ -381,9 +409,13 @@ holds the **Diagnostics: ARP Table** privilege. Read-only is enough; nothing her ### What it records, and what it leaves out -One **System** per machine, carrying its address, its MAC, the vendor that MAC belongs -to, and a `segment` label naming the firewall leg it answered on — which is the closest -thing to "which VLAN is this on" that the firewall can tell you. +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: @@ -408,10 +440,10 @@ a host a sweep found are **one card**, whichever collector ran first, with no sp case anywhere to say so. Run both and the firewall fills in the identity a sweep of a routed subnet could never get. -One wrinkle worth knowing: names are yours, so discovery never renames a resource that -already exists — including one a sweep named `host-` before the firewall could -offer something better. Running the firewall collector first, or on a fresh inventory, -gets you the good names. +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. --- @@ -469,55 +501,65 @@ address — and `--no-identify` turns it off for a pure liveness sweep. 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 — -and whatever is open lands in an `open-ports` label. - -These are recorded as observations, not conclusions. "554 is open" is a fact; "this is a -camera" is an inference, and the person reading the card is far better placed to draw it -than the scanner is. A port number is a convention rather than a guarantee, so RackPeek -will not name a service from one — but a port **is** the best possible target for the -banner probes above, which is how `9000` became "Portainer" and `8123` became -"Home Assistant". +MQTT brokers (1883), Home Assistant (8123), Portainer (9000), Plex, Postgres and so on. -The list is what answered out of the ports probed, not a full port scan. `--ports` -widens the liveness set if you want more. +### Every open port becomes a Service -### Applications become Services - -When a port answers HTTP with something that names itself, that is a fact about what the -host **runs**, not about what the host **is** — so it becomes a Service hanging off the -host's card rather than renaming it: +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: host-1a2b3c4d + name: nebula labels: - open-ports: 1883,8123 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: [host-1a2b3c4d] + runsOn: [nebula] ``` -A host may run several, so each identified port gets its own Service with its own stable -id — a rescan updates them rather than duplicating them. This is 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. +This replaces the old `open-ports` label, which was a comma-separated string nothing +could link to, filter on, or hang a note from. -An appliance's own management page is not a service running on it, so a title matching -the host's own name is skipped — a firewall already called `opnsense` does not also need -a service called `opnsense`. +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. -### Vendor from the MAC +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`. -Where the sweep has a MAC, the card also gets a `vendor` label naming the organisation +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. -Two details worth knowing. A hypervisor's own prefix wins over the +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 @@ -569,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 From d801aaa685e520e06f4ef65091727e87f05c077b Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Tue, 29 Sep 2026 08:23:25 +0100 Subject: [PATCH 9/9] Never let an unbuildable address take down the page rendering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BrowsableUrl built its result with UriBuilder, which throws UriFormatException on a host it cannot parse — "a/b", a name with spaces, a bare run of dots. The address field holds whatever a person typed or a collector read off a device, and neither is obliged to produce something a URL can be built from. That throw was already latent on the service card, where it would have spoiled one card. It matters more now: the dependency trees call this for every service they render, so a single malformed address would blank the entire hardware or system page instead of costing one link. A missing link costs a click. Letting this escape costs the page. Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/Services/ServiceEndpoint.cs | 11 ++++++++++- Tests.Discovery/ServiceEndpointTests.cs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs index a59140ce..698c46c3 100644 --- a/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs +++ b/RackPeek.Domain/Resources/Services/ServiceEndpoint.cs @@ -61,7 +61,16 @@ public static string Describe(Network? network) { if (scheme == null) return null; - return new UriBuilder(scheme, ip) { Port = port }.Uri.ToString(); + 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) { diff --git a/Tests.Discovery/ServiceEndpointTests.cs b/Tests.Discovery/ServiceEndpointTests.cs index f0c08457..94b78f05 100644 --- a/Tests.Discovery/ServiceEndpointTests.cs +++ b/Tests.Discovery/ServiceEndpointTests.cs @@ -93,4 +93,23 @@ public void A_service_with_no_address_of_its_own_can_borrow_its_hosts() { [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); + } }