Bläddra i källkod

Identify swept hosts by service banner, MAC vendor and open ports

A sweep of a multi-VLAN homelab produced 26 anonymous "host-<hash>" 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 <noreply@anthropic.com>
Tim Jones 2 dagar sedan
förälder
incheckning
f0199d21f9

+ 67 - 0
RackPeek.Domain/Discovery/DiscoveryIdResolver.cs

@@ -1,6 +1,8 @@
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
 
 
 namespace RackPeek.Domain.Discovery;
 namespace RackPeek.Domain.Discovery;
 
 
@@ -65,6 +67,71 @@ public static class DiscoveryIdResolver {
         }
         }
 
 
         PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName);
         PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName);
+        AnchorRunsOnByIp(existing, incoming, existingByName);
+    }
+
+    /// <summary>
+    ///     Gives a service whose <c>runsOn</c> names nothing the host it is plainly
+    ///     running on: the system at its own address.
+    ///     <para>
+    ///         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.
+    ///     </para>
+    ///     <para>
+    ///         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.
+    ///     </para>
+    /// </summary>
+    private static void AnchorRunsOnByIp(
+        IReadOnlyList<Resource> existing,
+        IReadOnlyList<Resource> incoming,
+        Dictionary<string, Resource> existingByName) {
+        var services = incoming.OfType<Service>().ToList();
+
+        if (services.Count == 0)
+            return;
+
+        var incomingNames = new HashSet<string>(
+            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<string, List<string>>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (SystemResource system in existing.OfType<SystemResource>().Concat(incoming.OfType<SystemResource>())) {
+            if (string.IsNullOrWhiteSpace(system.Ip))
+                continue;
+
+            if (!systemsByIp.TryGetValue(system.Ip, out List<string>? 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<string>? candidates) || candidates.Count != 1)
+                continue;
+
+            service.RunsOn = [candidates[0]];
+        }
     }
     }
 
 
     /// <summary>
     /// <summary>

+ 21 - 0
RackPeek.Domain/Discovery/INetworkProbe.cs

@@ -32,6 +32,27 @@ public interface INetworkProbe {
     /// <summary>The host's reverse-DNS name, or null when it has none worth keeping.</summary>
     /// <summary>The host's reverse-DNS name, or null when it has none worth keeping.</summary>
     Task<string?> ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
     Task<string?> ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
 
 
+    /// <summary>
+    ///     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.
+    /// </summary>
+    Task<string?> ReadTlsSubjectAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     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.
+    /// </summary>
+    Task<string?> ReadTcpBannerAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The head of an HTTP response to <c>GET /</c>: status line, headers, and enough
+    ///     body to reach a &lt;title&gt;. Null when the port serves no HTTP.
+    /// </summary>
+    Task<string?> ReadHttpHeadAsync(string ip, int port, bool tls, TimeSpan timeout, CancellationToken cancellationToken = default);
+
     /// <summary>
     /// <summary>
     ///     The subnet of the first up, non-loopback IPv4 interface with a gateway — what
     ///     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.
     ///     `--cidr` defaults to. Null when the machine has no such interface.

+ 106 - 0
RackPeek.Domain/Discovery/MacVendorLookup.cs

@@ -0,0 +1,106 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     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 <see cref="MacVendorTable" />), never fetched.
+/// </summary>
+public static class MacVendorLookup {
+    /// <summary>
+    ///     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.
+    /// </summary>
+    public static bool IsLocallyAdministered(string? mac) {
+        var octet = FirstOctet(mac);
+
+        return octet >= 0 && (octet & 0x02) != 0;
+    }
+
+    /// <summary>
+    ///     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".
+    /// </summary>
+    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;
+    }
+
+    /// <summary>The first six hex digits, upper-cased, or null when that cannot be read.</summary>
+    private static string? NormalisePrefix(string? mac) {
+        if (string.IsNullOrWhiteSpace(mac))
+            return null;
+
+        Span<char> 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);
+    }
+
+    /// <summary>
+    ///     Binary search over the packed records. Returns the index of the matching
+    ///     record's first char, or -1.
+    /// </summary>
+    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;
+    }
+}

+ 653 - 0
RackPeek.Domain/Discovery/MacVendorTable.g.cs

@@ -0,0 +1,653 @@
+// <auto-generated>
+//     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.
+// </auto-generated>
+
+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<A01E0BJA020A67" +
+        "A02195]A021B7MA0239F2A025D7<A027B6]A029195A02942AA02BB8<A02DDB\\A031DB?A03299EA0334F2A03679?A0369FAA036BC#A039EE\\A039F7DA039F9\\A03BE3&A03C20\\" +
+        "A03D6E2A03D6F2A0406F?A040A0MA04147?A042D1?A0445C?A0481C<A04839?A04A5EHA04EA7&A04ECF&A04F52AA04F85DA0510BAA05272&A052AB$A0551F\\A0554F2A0562C]" +
+        "A056F3&A057E3?A05950AA06090]A06391MA06C65mA070B7?A07591]A0764E7A07817&A0782D&A07D9C]A07F8A\\A08069AA0821F]A08527AA085E37A085FCHA086C6uA087BE&" +
+        "A08869AA0889D?A088B4AA088C2GA08CF8?A08CFD<A08D16?A08E78\\A09169DA09208nA093512A0999B&A09A8E&A09D22&A09F7A3A0A001<A0A0DC?A0A309&A0A33B?A0A3B37" +
+        "A0A3F03A0A47F2A0A4C5AA0A8CDAA0AB1B3A0AC69]A0AD62?A0AD9F#A0AF12?A0AFBDAA0B0BD]A0B15C;A0B339AA0B3CC<A0B40F&A0B4392A0B4A5]A0B7657A0BC6F2A0BD1D4" +
+        "A0C1C5&A0C20D?A0C589AA0C7D22A0C926?A0CBFD]A0CF5B2A0D05B]A0D0DC%A0D1B3&A0D2B1%A0D365AA0D37AAA0D3C1<A0D722]A0D795&A0D7A0?A0D7F3]A0D807?A0D91Am" +
+        "A0DD6C7A0DE0F?A0DF15?A0E0AF2A0E390&A0E453eA0E4CBwA0E6F8mA0E70BAA0ECF92A0EDCD&A0EE1A&A0F2627A0F3C1iA0F479?A0F6FDmA0F8492A0FAC8?A0FBC5&A0FF0C>" +
+        "A4004E2A400E2?A402B7%A402B9AA406E9mA407B6]A40801%A408F5\\A40987&A409B3?A40CC32A40E75<A410B62A41162)A41194EA411BB2A41437>A416C0&A416E7?A4178B?" +
+        "A418752A41A3AiA41F725A42249\\A426CAAA42902>A42A953A42B8CMA42BB0iA4307A]A43135&A434D9AA434F1mA4373E?A43828?A438CCNA439B3uA43B0E?A43FA7<A4423BA" +
+        "A44280&A44343?A44380?A44519uA446B4?A44BD5uA44BD9>A44C112A44CC85A44E31AA45046uA4515EBA4530E2A45590uA456302A459BFbA45C25mA45C27NA45D36<A45E60&" +
+        "A46706&A46BB6AA46C24?A46C2A2A46CF1]A46DA4?A46DD4bA47174?A475B9]A47733;A477F3&A478062A47952?A47B1A?A47B9D7A47CC9?A47F1BBA483E7&A48431]A488732" +
+        "A48CDBEA4933F?A4934C2A493AD?A493FE?A497002A49947?A49A58]A49B4F?A49BCD2A49DDD]A49E69bA49FE7]A4A459>A4A46B?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&A852D4<A8574EiA8595FAA85AE0?A85B78&A85BB7&A85BF7<A85C2C&A85E45#A85F61&A860B6&A861ECmA8637D3A863F2mA864F1AA8667F&A86A86uA86ABB\\A86DAAA" +
+        "A86E4E?A86E84iA8724DAA87650]A87971?A8798D]A87C01]A87C45?A87CF8&A87D12?A87EEAAA88055nA8817E&A88195]A886DD&A887B3]A88808&A88940?A88C3EHA88E24&" +
+        "A88FD9&A8913D&A89162fA8922CDA8968A&A899695A89A93\\A89C6CoA89C78&A89CEDuA89D212A89FBA]A8A159\"A8AA7C?A8ABB5&A8B13B<A8B1D42A8B271?A8B4562A8B57C[" +
+        "A8B86EDA8BA25<A8BA69]A8BB56&A8BBCF&A8BD27<A8BE27&A8C092?A8C252?A8C407?A8C83A?A8CA77%A8CA7B?A8D081?A8D0E5BA8D162]A8D4E0?A8DDBCnA8E2C1mA8E3EEe" +
+        "A8E544?A8E621%A8E77DmA8E978?A8EE67]A8F059?A8F07C?A8F266?A8F274]A8F5AC?A8FAD8&A8FB45;A8FD077A8FE9D&A8FFBA?AC007A&AC05C7AAC075F?AC0775&AC0BFB7" +
+        "AC0D1BDAC1203AAC15187AC15A2iAC15F4&AC1615&AC162D<AC16DEAAC198EAAC1A3D5AC1D06&AC1E92]AC1E9EuAC1F0FmAC1F6BgAC1F74&AC220B#AC2241?AC276E7AC293A&" +
+        "AC2AA12AC2B6EAAC3184?AC3328?AC3613]AC3870EAC3A672AC3A7A[AC3AE2KAC3B77\\AC3C0B&AC3DCBAAC3EB1;AC416A%AC4500&AC45EFAAC471B?AC49DB&AC4A562AC4A672" +
+        "AC4BC8BAC4D16mAC4E91?AC51AB?AC5322]AC5A14]AC5AF0DAC5AFCAAC5C2C&AC5E14?AC6089?AC6175?AC61EA&AC63BE%AC6490?AC675DAAC6784;AC67B27AC6C90]AC712E8" +
+        "AC7289AAC74B1AAC751D?AC78D1BAC7A562AC7BA1AAC7E01?AC7E8A2AC7F3E&AC800AeAC80FB]AC8247AAC82F0&AC84C6iAC84C9\\AC853D?AC86A3&AC87A3&AC88FD&AC8AC7?" +
+        "AC8BA9oAC8C46uAC8D34?AC8EBDHAC9073?AC9085&AC91A15AC9232?AC936A?AC9738&AC9929?AC9A3FmAC9B0AeAC9E17#AC9FC3ZACA0162ACA09DBACA26A7ACA31E<ACA7047" +
+        "ACA7F1iACA899mACAE19[ACAFB9]ACB3B5?ACB4805ACB92F>ACBC32&ACBCB5&ACBCD92ACBD70?ACC1EEuACC33A]ACC5B4?ACC906&ACCB51>ACCC8E-ACCCFC%ACCF5C&ACCF85?" +
+        "ACD0747ACD4002ACD75B\\ACDCCA?ACDFA1&ACE011?ACE215?ACE2D3<ACE342?ACE4B5&ACE4D8uACE6BB;ACE6FB&ACE87B?ACEAEA?ACEBE67ACED5CAACEE9E]ACF1DF3ACF2C52" +
+        "ACF466<ACF5E62ACF6F7DACF7F3uACF878nACF970?ACFAE4NACFDCEAACFDEC&ACFF6B?B000B42B00875?B00B22?B00CD1<B010A0mB01656?B01831&B01921iB019C6&B01BFC2" +
+        "B01F8C<B01FF4\\B0216F?B0227A<B02491?B02628/B026802B02A43;B02B642B02EE0?B033A6BB03495&B0359FAB035B5&B03795DB03956MB03ACE?B03CDCAB03F64&B03FD37" +
+        "B042B7?B04502?B047BF]B047E9AB0481A&B0487AiB04A6A]B04E26iB04F135B05476]B05508?B05A7B?B05ADA<B05B67?B05B99\\B05CDA<B06088AB061EBbB064E0]B065BD&" +
+        "B067B5&B06A41;B06EBF#B06FE0]B0702D&B07219&B0735D?B0739C%B0761B?B07ADF?B07B255B07D472B07D64AB07E11mB07FB9MB081847B082E2#B083FE5B08900?B08BA8%" +
+        "B08BCF2B08BD02B08C75&B08D572B0907E2B09122mB09200&B0924A\\B09575iB0958EiB0982B\\B098BC?B0995A?B099D7]B09C63uB09CB2;B09FBA&B0A460AB0A4F0?B0A5B3<" +
+        "B0A6047B0A6512B0A7327B0A737[B0A7B9iB0A86EBB0AA772B0B113mB0B21C7B0B28F\\B0B2DCwB0B448mB0B867<B0B98AMB0BBA9&B0BBE5\\B0BE45GB0BE76iB0BE83&B0C38E?" +
+        "B0C4E7]B0C53C2B0C5543B0C559]B0C61C?B0C69ABB0C7450B0C787?B0C7DEbB0CA68&B0CAE7?B0CBD87B0CCFE?B0CF0EGB0CFCB%B0D09C]B0D278mB0D576&B0D5CCmB0D5FB;" +
+        "B0D616gB0D77E?B0DCEFAB0DE28&B0DE31]B0DF3A]B0DFC1kB0E17E?B0E235uB0E45C]B0E4D5;B0E5ED?B0E5EF&B0E5F9&B0E8E8bB0EB57?B0EB7FBB0EC69&B0EC71]B0ECDD?" +
+        "B0EE7B[B0F1D8&B0F208$B0F2F6]B0F7C4%B0FA8B?B0FAEB2B0FC0D%B0FC88\\B0FEE5?B0FF0D>B402162B405A1uB40931?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&B45BD1iB45CB5GB45D50<B460EDuB46142?B46293]B46921AB46BFCAB46D83AB46E08?B47064]B47443]B476A4?B47AF1<B47B1A?B47C9C%B47E9FuB48351A" +
+        "B485E1&B48655?B48901?B48931bB48A0A7B48A5FBB48B19&B49107?B49691AB496A5&B4994CmB499BA<B49CDF&B49D02]B4A10A?B4A382>B4A4E32B4A5EF_B4A64A7B4A898?" +
+        "B4A8B92B4AC9DmB4AEC1&B4B024iB4B055?B4B291DB4B2E98B4B52F<B4B676AB4B686<B4B742%B4BC7CmB4BFE97B4BFF6]B4C0C3iB4C2F7?B4C3D9?B4C4FCuB4CADD2B4CD27?" +
+        "B4CE40]B4D1F6?B4D5BDAB4D5E5]B4DE312B4E10F5B4E25B<B4E3D0DB4E3F9bB4E454%B4E5C5?B4E62D7B4E9B02B4E9B85B4EED4mB4EF39]B4F0AB&B4F18C?B4F1DADB4F49B?" +
+        "B4F58E?B4F61C&B4F7A1DB4F95DBB4FA48&B4FBE4oB4FBF9?B4FC7D$B4FF98?B8005B?B8011F&B80305AB80533&B8060DnB808CFAB808D7?B8098A&B80B9A?B8114B2B8144D&" +
+        "B8145C?B817C2&B81D1F?B81DAADB81E9E?B81F3F7B8211C&B8220C&B82227mB827C5?B827EBWB82A725B82AA9&B82B68?B831B5HB8374A&B8374B<B837B2<B838612B83865<" +
+        "B83A08kB83A5A<B83BCCuB83C20?B83C28&B83DF6mB83E59[B83FD2GB841A4&B844D9&B845EB&B8496D&B84FA7&B84FD5HB850D8uB852E0uB85384uB853AC&B85600?B857D62" +
+        "B857D8]B8599FGB85A73]B85C5CHB85D0A&B85DC3?B85E7B]B85F98%B85FB0?B861FCBB8621F2B8634D&B86685\\B86870NB869F4IB86AF1\\B86CE0<B86CE8]B8752E?B87826N" +
+        "B8782E&B87B4D7B87BC5&B87BD4;B87CD0?B87E40?B8804FmB88198AB881FA&B88303<B8857B?B885845B88788<B88A60AB88AECNB88C2B\\B88D12&B88E82?B89047&B89436?" +
+        "B894D9mB894E7uB89734bB89A2AAB89FCC?B8A0B8]B8A175[B8A3772B8A3863B8A44F-B8A825]B8AC6F5B8AE6ENB8AF67<B8B2F8&B8B409]B8B81EAB8BA66HB8BB117B8BBAF]" +
+        "B8BC1B?B8BC5B]B8BEBF2B8BF83AB8C111&B8C253BB8C385?B8C68E]B8C75D&B8C9242B8CA3A5B8CB295B8CEED/B8CEF6GB8D4C3?B8D4E7<B8D526wB8D61A7B8D6F6?B8D94D\\" +
+        "B8D9CE]B8DA5EmB8DAE8?B8DB38;B8E3B1?B8E60C&B8E856&B8E924GB8E937dB8EA98uB8ECA3wB8EE0E\\B8F0097B8F015BB8F12A&B8F4A4;B8F6B1&B8F775AB8F8627B8F883i" +
+        "B8F934eB8FBB3iB8FE902B8FE96%B8FF61&B8FFFEmBC026EbBC0358ABC0543$BC071DiBC091BABC0963&BC0DA5mBC0EAB]BC0F64ABC0F9A3BC0FF3<BC0FFEBBC107B]BC1485]" +
+        "BC16652BC16F52BC17B8ABC1827&BC1896?BC19267BC192FmBC1AE4?BC1E85?BC20A4]BC22283BC2411SBC25E0?BC26C72BC277A]BC2978>BC2CE62BC2EF6?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&BC9FE4<BC9FEF&BCA080]BCA0B9?BCA231?BCA511M" +
+        "BCA58B]BCA5A9&BCA8A6ABCA920&BCABF52BCAD28>BCAEC5#BCB0E7?BCB1F3]BCB2CC]BCB30E2BCB863&BCBAC2>BCBB58&BCBCCA?BCBF2E#BCC427?BCC4932BCCD7F?BCCD99A" +
+        "BCCE25NBCCF4FwBCD074&BCD11F]BCD177iBCD206?BCD22CABCD2952BCD5ED\\BCD7A5<BCD7D4[BCDDC27BCDF58;BCE143&BCE265?BCE63F]BCE7122BCE92F<BCEAFA<BCEBE8]" +
+        "BCEC5D&BCEE7B#BCF105ABCF171ABCF1F22BCF5ACDBCF6853BCF730]BCFABAGBCFAEB2BCFCE7#BCFED9&BCFF4D7C00195%C00380BC0060C?C006C3iC01173]C014FE2C0151Be" +
+        "C01693uC0174D]C01754&C01803<C01944BC01ADA&C01C6A;C0238D]C02506$C0255C2C025A55C025E9iC02C172C02C5C&C02CEDbC02E5FwC03379?C0395A4C03A55iC03C04\\" +
+        "C03C59AC03D03]C03E50?C03E75?C03EBA5C03F0EMC03FDD?C042D0BC04442&C0470E5C048E6]C049EF7C04A00iC04A0EmC04E307C04E8A?C0517E>C05234?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?C4346B<C435D9&C436C00C43875dC43ABEeC43CEA0C43D1AAC43DC7MC43EAB?C44202]C4438FDC4447D?C444A02C446062C4473F?C4474EA" +
+        "C4491B&C44B312C44D842C44E5DmC44F337C44F5F?C45006]C4524F&C4550D?C45746mC4576E]C457CD?C45A86?C45AB15C45BAC&C45BBE7C45D83]C45E5C?C4618B&C4619Am" +
+        "C461C7HC462EA]C463C4?C464132C464E3mC46516<C467D1?C46940uC469F0?C46AB7uC46DD1?C46E1FiC470BDGC4710FuC47154iC471FE2C472952C4731E]C475ABAC475EA?" +
+        "C47764]C478A2?C47BE3?C47D4F2C47D9F]C47EE02C48025?C482E1nC48466&C484FC&C48508AC486E9?C488E5]C4910C&C493BBuC493D9]C49500%C4969F%C49880&C49A02D" +
+        "C49A31wC49D08?C49DEDHC49E7E7C49F4C?C4A1AE?C4A402?C4A81D3C4AA432C4AA99?C4AAC44C4AB4D2C4ACAA&C4AD34IC4AE12]C4B1D9?C4B2392C4B301&C4B349&C4B36A2" +
+        "C4B8B4?C4B9CD2C4BB03?C4BB897C4BDE5AC4BE84mC4C00AHC4C17D&C4C36B&C4C6032C4CB76HC4CBE15C4D0E3AC4D36AmC4D438?C4D6D35C4D738?C4D8C8bC4D8D4?C4D8D57" +
+        "C4D987AC4DB04?C4DBADZC4DD577C4DE7B?C4DEE27C4E287?C4E90A3C4E984iC4EB39\\C4EB41\\C4EB42\\C4EB43\\C4EB81&C4EDBAmC4EF3D]C4F081?C4F312mC4F445&C4F7C1&" +
+        "C4F7D52C4FBAA?C4FF1F?C4FF22?C4FF99AC800842C808E9DC809A8AC80CC8?C80E14$C8102FMC8120B]C81337BC81451?C81479]C8154EAC819F7]C81EE7&C81F665C81FBE?" +
+        "C81FE8&C82158AC82832uC828E52C82A14&C82ADD;C82B967C82E187C83049?C8334B&C83374wC833E5?C8348EAC834E52C83870]C839AC?C83A35kC83A6B[C83C85&C83DDCu" +
+        "C83E99mC83E9E?C83F26HC8418A]C847092C84805NC84AA0eC84BD65C84C752C84F86fC850CE?C85142]C85195?C8544BwC858B3AC858C0AC85ACF<C85CCCuC85EA9AC86000#" +
+        "C8608F2C863F1eC868DE?C869CD&C86C3D%C86C87wC86E08AC86F1D&C875DDDC8787D3C878F72C87E75]C87F54#C8806D&C882342C884A12C884CF?C885417C88550&C889F3&" +
+        "C88A7B7C88A9AAC88D83?C8908A]C890F7?C89143NC891F9\\C894BB?C895CEAC89665HC89C1D2C89D18?C89E43MC89F1A?C8A030mC8A6EF]C8A702>C8A776?C8A823]C8B1CD&" +
+        "C8B29BAC8B5AD<C8B5B7&C8B6D3?C8B78A?C8BB81?C8BC9C?C8BCC8&C8BD4D]C8BD69]C8BE193C8BF4CuC8BFFE?C8C2FA?C8C465?C8C83FmC8C919/C8C9A37C8C9B2%C8CA63?" +
+        "C8CB9EAC8CBB8<C8CD72\\C8D083&C8D15E?C8D1A9?C8D3A33C8D3FF<C8D7B0]C8D995BC8D9D2<C8DA297C8DDC9EC8DF84mC8E0EB&C8E265AC8E31D?C8E5E0?C8E600?C8E7F0B" +
+        "C8EA717C8ED8B7C8F09E7C8F319DC8F650&C8F733AC8F7505C8F9F92C8FD19mC8FE6ABCC00F1\\CC033DuCC037BmCC051B]CC0577?CC07AB]CC07E4ECC087B?CC088D&CC08E0&" +
+        "CC08FA&CC08FBiCC0DCBHCC115A&CC13F3>CC1531ACC167E2CC1E56?CC1E97?CC208C?CC20AC]CC20E8&CC2119]CC2293%CC22FE&CC25EF&CC2746&CC28AA#CC29F5&CC2D21k" +
+        "CC2DB7&CC2DE0ICC2F71ACC3089GCC3296?CC32E5iCC3331mCC33BB\\CC3429iCC35D9oCC36BBbCC36CF2CC38E1?CC3BFBZCC3D82ACC3DD1?CC3E5F<CC3F36&CC40D0MCC40F3G" +
+        "CC4210uCC4460?CC4463&CC45A5mCC464E]CC46D62CC483A5CC4B04&CC4D75uCC50E37CC53B5?CC5830\\CC5A532CC5B31NCC5C61?CC5D4EwCC6023&CC60C8HCC64A6?CC660A&" +
+        "CC68B6iCC68C77CC68E0&CC69FA&CC6A332CC6DA0[CC6EA4]CC70ED2CC722A&CC7645HCC785F&CC78ABmCC79D72CC7B5C7CC7E1F7CC7F752CC7F762CC808F&CC817D&CC86ECb" +
+        "CC88C7<CC895E?CC8A84?CC8CBFnCC8CE3mCC8DA27CC8E712CC90702CC9096?CC915B?CC96A0?CC96E55CC98912CC9E00NCC9EA2%CCA223?CCA30CbCCA7C1;CCA823bCCAA00H" +
+        "CCAFE3%CCB0A8?CCB0B3HCCB11A]CCB182?CCB2553CCB54CmCCB5D1uCCB6C82CCB775?CCB7C4?CCBA6F?CCBA977CCBABDiCCBBFE?CCBC2B?CCBCE3?CCBE61&CCC5E55CCC760&" +
+        "CCC95D&CCCC81?CCCCCCbCCCE1E$CCD083<CCD281&CCD3422CCD5392CCD73C?CCD843uCCD8C12CCD9ACACCDA20uCCDAB5mCCDB932CCDBA77CCE17FBCCE194BCCE1D50CCE686]" +
+        "CCE9FA]CCEB5EuCCED4D2CCEF482CCF411;CCF735%CCF826]CCF9E4ACCF9E8]CCF9F0]CCFA00DCCFA66?CCFAF1\\CCFB65NCCFE3C]CCFF90?D0034B&D003DF]D003EBmD00477?" +
+        "D004B0]D005E4?D00790mD007CABD009C82D00DF7?D011E5&D012CB$D013FDDD015A6<D016B4?D0176A]D017C2#D01B49]D01BF4\\D021F9oD023DB&D02598&D02B20&D02C392" +
+        "D02DB3?D02EABmD03169]D032C33D032E7nD03311&D03745iD03761mD03972mD039FA]D03C1FAD03E07&D03E5C?D03F27sD03FAA&D0431E5D0460C5D048A1BD04D2C[D04D86&" +
+        "D04DC6<D04E99?D04F7E&D05099\"D05162eD05509ND056F20D056FB]D0574C2D0577BAD0577EAD05794\\D058A5&D059E4]D05FB8mD06158?D06544&D06578AD065CA?D0667B]" +
+        "D06726<D067E55D069C1?D06B78&D06DC8?D06DC9\\D06EDE\\D06F82?D072DC2D07380?D076E7iD07AB5?D07D33?D07E01?D07E28<D07E35AD07FA0]D0817A&D081C5BD082E8n" +
+        "D082EBnD084B0\\D085432D087E2]D0880C&D08CB5mD08E795D09282&D0929EHD094665D094CF?D09AAF7D09C7AuD0A5A62D0A637&D0ABD5AD0AD08<D0AE05uD0B128]D0B324&" +
+        "D0B45D?D0B5C2mD0BF9C<D0C050&D0C1B1]D0C1B55D0C1BFuD0C24E]D0C2822D0C5F3&D0C637AD0C65B?D0C67F?D0C7892D0C7C0iD0CDBFDD0CEC0uD0CF0E\\D0CF137D0D003]" +
+        "D0D04B?D0D0FD2D0D23C&D0D2B0&D0D3E0<D0D49F&D0D783?D0D7BE?D0DAD7&D0DC2C2D0DD49BD0DFC7]D0E0422D0E140&D0E581&D0EA11ID0EC352D0EF767D0EFC1?D0F3F5?" +
+        "D0F4F7?D0F815?D0FCCC]D0FF50mD0FF98?D40129/D4016DiD401C3ID404E6/D404FFBD405927D4060FmD40F9E&D411A3]D4126E&D41761uD41972<D41AD1wD42122_D4223FE" +
+        "D424DD$D4258BAD427877D427FF\\D42B6F2D42C442D42C460D42CA6]D42DCC&D42FCA&D43538uD43639mD4389CeD43A2C;D43B04AD43D7EFD43DF3wD440F0?D4430E4D4438Au" +
+        "D44649?D446E1&D44867bD44A85bD44F67?D45039\\D4532AuD4548BAD45763&D45A3FBD45D64#D45EECuD45F7A?D460E3_D4612E?D4619D&D461DA&D462EA?D463C0&D466242" +
+        "D468AA&D46A352D46AA8?D46BA6?D46D502D46D6DAD46E0EiD46E5C?D47327?D47415?D476A08D477982D4789B2D47954?D47AE2]D47F352D481D75D48564<D487D8]D48866?" +
+        "D48890]D489C1oD48A39]D48AFC7D48C497D48CB52D48F33HD48FA2?D4909C&D4910F%D49400?D494A1mD494A9AD494E8?D4970BuD4996CBD49A20&D49DC0]D49FDD?D4A02A2" +
+        "D4A148?D4A254?D4A2CD5D4A33D&D4A365uD4A651nD4A923?D4AB61AD4AD712D4ADBD2D4AE05]D4AE525D4B110?D4B4C08D4B5CD\\D4BBE6?D4BED75D4BED95D4BEDC[D4C93C2" +
+        "D4C9EF<D4CA6DID4CE40&D4D252AD4D4DA7D4D51B?D4D6DFiD4D7482D4D853AD4D892?D4DA21uD4DCCD&D4E053<D4E13CGD4E22F[D4E6B7]D4E853>D4E8802D4E8B2]D4E95Em" +
+        "D4E98AAD4E9F47D4EB682D4F057ND4F0EAuD4F242?D4F32DAD4F46F&D4F513mD4F547;D4F5EF<D4F7D5eD4F829\\D4F98D7D4F9A1?D4FB8E&D4FE28bD4FF1A&D8004D&D801D0?" +
+        "D807B6iD80831]D80A60?D80B9A]D80D17iD8109F?D8132A7D8150DiD818D3BD81BB5?D81BF4mD81C79&D81D13mD81D72&D81F12nD820A2?D824BD2D82918?D829F8?D83062&" +
+        "D83134[D831CF]D83214kD832E3uD833B7\\D83ADDWD83BBFAD83BDA7D84008?D843AEFD84489iD846CE&D84732iD847BB?D8490B?D84C90&D850E6#D852FAmD8539ABD853BCE" +
+        "D85437?D8543AmD854F2?D85575]D857EF]D85982?D85B2A]D85D4CiD85ED3:D862CA2D86375uD867D3?D867D92D86852?D868A0]D868C3]D86B83ND86BF7ND86C63;D86CE9\\" +
+        "D86D17?D8714DmD87154]D87157ED87475&D874DF?D876AE?D878F0bD87A3BbD87D7F\\D880DC?D885AC7D88863?D88ADC?D88C79;D88F76&D890E8]D8912AwD89333?D893D4u" +
+        "D89403<D89424GD8952FmD89695&D89B3B?D89D13]D89D67<D89E3F&D89E61?D89EF35D8A01D7D8A25E&D8A35C]D8A491?D8A756\\D8A98BmD8B053uD8B122BD8B1902D8B1DE<" +
+        "D8B249?D8B370oD8B673mD8BB2C&D8BBC1FD8BC387D8BE1F&D8BE65%D8BF42AD8BFC07D8C262oD8C4E9]D8C771?D8C7C8<D8C80CnD8C960?D8CB8AFD8CC98?D8CE3AuD8CF61\\" +
+        "D8CF9C&D8D0905D8D1CB&D8D385<D8D43CeD8D668nD8D775\\D8DAF1?D8DC40&D8DDFDmD8DE3A&D8E0E1]D8E2DFHD8E374uD8E593&D8EB46;D8ECE5wD8EDA8uD8EF42?D8F02Fm" +
+        "D8F12EiD8F15B7D8F2CAAD8F883AD8FB58GD8FBD6%D8FC92nD8FC93AD8FEE33DC0077iDC05392DC06757DC07F8>DC080F&DC094C?DC0A697DC0B092DC0B34DDC0C5C&DC1057&" +
+        "DC121D?DC15C8$DC16B2?DC1B48mDC1BA1ADC1ED57DC2148ADC215CADC21E2?DC2727?DC2B2A&DC2B61&DC2C6EIDC2D3C?DC333D?DC3714&DC38E1BDC396F$DC39792DC3A5E[" +
+        "DC415F&DC41A9ADC42C8?DC44B6]DC4546ADC45B8&DC4628ADC4A3E<DC4F227DC5285&DC5360ADC5392&DC54757DC54D7%DC55B17DC56E7&DC5D892DC6180?DC621F?DC6279i" +
+        "DC6672]DC680C<DC68EBNDC69E2]DC6AE7uDC6B1B?DC6DBC&DC7196ADC71D0&DC729B?DC7385?DC73FCGDC74A8]DC774C2DC7794?DC7B942DC7E1D?DC7EF5?DC8084&DC868D?" +
+        "DC86D8&DC87F8]DC8983]DC8B28ADC8C372DC8E6D?DC8E95bDC9009ADC9088?DC9166?DC91BF%DC9272\\DC9396&DC9566&DC97BAADC97E6\\DC9840HDC9914?DC9B9C&DC9C99?" +
+        "DC9DED]DC9E8F&DC9FDBoDCA0D0%DCA4CA&DCA5F42DCA632WDCA782?DCA904&DCA971ADCB4D97DCB54F&DCB72EuDCB7AC<DCB87D<DCBE04mDCC49C]DCC64B?DCCCE6]DCCD18N" +
+        "DCCEC12DCCF96]DCD26A>DCD2FC?DCD2FD?DCD3A2&DCD444?DCD7A0?DCD83B2DCD916?DCDA0C7DCDB27?DCDCE2]DCDEE3mDCE541&DCE55B;DCEAE73DCEB942DCED83uDCEE06?" +
+        "DCEF09MDCEF80?DCF31CmDCF4015DCF4CA&DCF7192DCF756]DCFB020DCFB48ADCFE18iE00084?E0036B]E005C5iE00630?E0071B<E00855$E00C7FNE00CE5?E00DEE?E00EDA2" +
+        "E0150B?E0191D?E01ADF;E01CFC3E01F6A?E01F88uE023FF8E0247F?E02481?E02611&E0280AiE02861?E0286D$E02A662E02B96&E02BE9AE02CB2EE02E0BAE02E3F?E02EFE4" +
+        "E02F6D2E030F9BE0338E&E03676?E03AAAAE03E44/E03F49#E04007?E04027?E0469AME046EEME04BA6?E04E5D?E04F95\\E0508B4E05A1B7E05F45&E05FB92E06066_E06234m" +
+        "E06267uE063DAoE063E5eE06678&E069BA2E06CC5?E06D17&E070EA<E07256AE07291bE072A17E073E7<E07726?E0798DbE07DEAmE0806BuE0897E&E0899D2E08C3C2E08CFE7" +
+        "E08F4CAE091F5ME0925C&E0928FmE09467AE095B3mE09796?E098067E09971]E09D13]E09D31AE09D73GE0A3AC?E0AA96]E0ABC3GE0ACCB&E0ACF12E0AD9B?E0AEA2?E0B52D&" +
+        "E0B55F&E0B655uE0B9BA&E0BA78&E0BAAD>E0BDA0&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]E4115B<E4121D]E4135C2E415F6mE4186BwE419C1?E41B43uE41D2DGE41F7B2E41FD5AE4233CBE4246C4" +
+        "E425E7&E4268B?E42AACHE42B34&E42F37&E432CB]E43493?E435C8?E4379F2E4387E2E43883oE43A852E43D1A/E43EC6?E440E2]E442A6AE4434B5E44389&E443CFiE44519u" +
+        "E446DAuE44AE0AE44E2D2E450EB&E4521EmE45341&E454E5?E454E85E456ACbE458B8]E458E7]E45D37BE45D39mE45D75]E45E1B;E45E37AE45ECCBE45F01WE46017AE462C42" +
+        "E465B87E468A3?E46DABGE46F133E47010&E470B8AE472E2?E47319?E47684&E47727?E4793FBE47CF9]E47DBD]E47E66?E48210?E48326?E484D3uE489CA2E48B7F&E48D8CI" +
+        "E48EC5?E48F1D?E4902A?E490FD&E49282]E492FB]E498D6&E4995F?E49A79&E49ADC&E49C67&E49F7D]E4A41C2E4A430]E4A471AE4A7A0AE4A7C5?E4A7D0?E4A8B6?E4AA5D2" +
+        "E4AAE4uE4AEE4nE4B021]E4B0637E4B107?E4B16C&E4B224?E4B2FB&E4B318AE4B3237E4B555?E4B97A5E4BCAAuE4BEFB?E4C0E2\\E4C0FE&E4C2D1?E4C32AiE4C63D&E4C7222" +
+        "E4C767AE4CE8F&E4D332iE4D373?E4D398uE4D3F12E4D58B>E4DB6DuE4DC43?E4DCCC?E4DE40<E4E0A6&E4E0C5]E4E112mE4E4AB&E4E749<E4ECE8]E4F0045E4F042;E4F27CB" +
+        "E4F3C4]E4F4C6ME4F89CAE4F8EF]E4FA5BmE4FAC4iE4FADEHE4FAED]E4FAFDAE4FB1EHE4FB5D?E4FC82BE4FD45AE4FDA1?E4FE43uE4FED4HE8039A]E8040B&E804622E80688&" +
+        "E806907E8088B?E80AB92E81098<E81132]E8136E?E81B69_E81CA5<E81CBA8E81CD8&E81E92?E824A6BE82689<E82725-E8288D?E82AEAAE82BC5?E831CD7E83617&E8377Aw" +
+        "E83935<E83A12]E83DC17E83F67?E840402E843B6UE848B8iE84A54uE84A78&E84C4A%E84D74?E84DD0?E84E84]E84EBA;E84ECENE84FA7?E85497]E85A8BuE85C0A2E85F02&" +
+        "E85FB4uE862BEAE865492E8655F5E865D4kE86819?E868E77E86BEA7E86DCB]E86DE9?E86E3AeE87865&E879A32E87E1C?E87EEFuE87F6B]E87F95&E8802E&E880E7?E88152&" +
+        "E884A5AE884C6?E8854B&E88843uE88D28&E892A4DE89309]E894F6iE89847uE898EE&E89C25#E89E49GE89F6D7E8A0CDNE8A0ED>E8A245BE8A34E?E8A55ABE8A660?E8A6CA?" +
+        "E8A72FHE8A730&E8AACB]E8ABF3?E8AC23?E8ADA6\\E8B0C5AE8B1FCAE8B2655E8B2AC&E8B4C8]E8B5D05E8B6C2BE8B7482E8BA17uE8BA702E8BCE42E8BDD1?E8BE81\\E8BFB8A" +
+        "E8BFE1AE8C1D7QE8C386&E8C3C5?E8C829AE8C913]E8CC183E8CD2D?E8CF835E8D2FF\\E8D3222E8D52B;E8D765?E8D775?E8D87E%E8D8D1<E8DA20NE8DA3E?E8DB847E8DC6C2" +
+        "E8DE27iE8DF70$E8E07EbE8E5D6]E8EA34?E8EA4D?E8EB11mE8EB342E8EBD3GE8EDD68E8EDF32E8F085?E8F1B0\\E8F408AE8F60A7E8F654?E8F673HE8F724<E8F72F?E8F791u" +
+        "E8F9D4?E8FA23?E8FBE9&E8FCAFME8FD35?E8FDF87E8FEBE&E8FF98?E8FFF4&EC01D52EC0273<EC086BiEC09C9mEC0D51&EC0D9AGEC0DE4%EC1055uEC107B]EC1127mEC13DBB" +
+        "EC172FiEC192E2EC1A02?EC1B5F<EC1BBDbEC1D53?EC1D8B2EC22803EC233D?EC24B8mEC2651&EC26CAiEC28D3&EC2A725EC2BEB%EC2C0D&EC2C73&EC2CE2&EC30912EC30B3u" +
+        "EC315F%EC33CC<EC3586&EC3873BEC388F?EC3A52?EC3CBB?EC3EB3wEC3EF7BEC4118uEC42CC&EC43F6wEC44762EC4654&EC4684HEC4C8CAEC4D3EuEC4D47?EC50A6\\EC50AA<" +
+        "EC536F?EC551C?EC5623?EC59E7HEC5A310EC5AA3?EC6073iEC62607EC63D7AEC64C97EC6794<EC71DBYEC7379&EC748CeEC750CiEC753E?EC7C2C?EC7C5CBEC7CB6]EC7CBA<" +
+        "EC8150&EC8152?EC819C?EC8350HEC852F&EC86C4GEC888FiEC8914?EC89F5EEC8AC4%EC8C9A?EC8E77AEC8EB5<EC90C1]EC94CB7EC94D5BEC97A2&EC9A34mEC9A74<EC9B75[" +
+        "EC9B8B<ECA138%ECA1CC2ECA1D1?ECA62F?ECA78D2ECA907&ECA971>ECAA25]ECAA8F?ECADB8&ECADE03ECB157\\ECB1D7<ECB293<ECB550]ECB5FAaECB878?ECB931iECB9A5?" +
+        "ECBB782ECBD1D2ECBEDD\\ECBFD0mECC0182ECC01B?ECC40DNECC5D2?ECC8822ECC89C>ECC9FF7ECCB30?ECCE132ECCED7&ECD09FuECD508&ECD61B7ECDA3B7ECDCAA&ECDD242" +
+        "ECE09B]ECE1A92ECE3347ECE61D?ECE7A7AECE9D2&ECE9F5?ECEBB8<ECED04AECF33CAECF40C2ECF4BB5ECF64CbECF8D0?ECFA5CuECFABC7ECFC2F\\ECFCC6<ECFF3A&F003BC2" +
+        "F004E1&F0051B]F008D17F008F1]F0090DiF00FEC?F010A5mF0161D7F01628jF01898&F01AA0<F01C13DF01C2DBF01D2D2F01DBCHF01FAF5F01FC7&F020FFAF02475&F024F97" +
+        "F025722F0258E?F0272D%F027A0&F029292F02F4B&F02F74#F02F9E%F02FA7?F02FBA&F033E5?F037CF?F03965]F03F95?F040D9&F0421CAF042F5?F04347?F044D3bF045DAm" +
+        "F04A022F04B3ABF04DA25F04DD4\\F04EA4<F04F7C%F05501?F057A6AF05A09]F05B7B]F05C0E?F05C19<F05C77;F05CD5&F05D88?F05ECDmF061C0<F063F9?F065AE]F067B1]" +
+        "F06BCA]F06C5DuF06E0BHF0704F]F0728C]F072EA;F074BFbF0766F&F077C3AF07807&F078162F07959#F07960&F07B65\\F07CC7BF07D683F07F062F08173%F08175\\F08261\\" +
+        "F082C0bF08756wF08A76]F0921C<F09838?F0989D&F099B6&F099BF&F09BB8?F09E4AAF09E632F09E9E7F09FC2oF0A0B1?F0A225%F0A35A&F0A4EA?F0A731iF0A951?F0B014$" +
+        "F0B0E7&F0B13F?F0B163mF0B2B9AF0B2E52F0B3EC&F0B429uF0B479&F0B4D23F0B5D1mF0B61EAF0BC50GF0BDEE?F0BF97eF0C1F1&F0C371&F0C42F?F0C478?F0C725&F0C77Fm" +
+        "F0C850?F0C88BsF0C8B5?F0CBA1&F0CD31]F0D018<F0D1A9&F0D2F1%F0D31F&F0D32BBF0D415AF0D4E25F0D5BFAF0D635&F0D793&F0D7EE?F0D8052F0DBE2&F0DBF8&F0DCE2&" +
+        "F0E426]F0E4A2?F0E77E]F0EE10]F0EE7A&F0EF86;F0F0A4%F0F336iF0F564]F0F5BD7F0F61C&F0F6C1dF0F7552F0F7E7?F0F7FC?F0F832;F0F84A0F0F8F2mF0FABAKF0FAC7?" +
+        "F0FB7FGF0FD45bF0FE6B7F0FEE7?F400A2]F401CCbF402705F40304;F4032A%F40343<F40595\\F40616&F4063CmF40669AF406A3?F40E01&F40E22]F40F1B2F40F24&F412FA7" +
+        "F41A9CuF41BA1&F41D6B?F41E57IF41FC22F4204DGF421CA&F4248B?F425FE?F42679AF42A7DiF42B8C]F42DC97F42E7F<F4308BuF430B9<F431C3&F433922F433B7&F434F0&" +
+        "F437B7&F438C1?F43909<F439A6&F43BD8AF4419E?F4428F]F44588?F44637AF44C7F?F44D5CwF44E052F44EE3AF45214GF45293&F453E4?F4559C?F45B29?F45BB42F45C42?" +
+        "F45C89&F45EABmF46077mF460E2uF462DC?F4631F?F46412eF464B6_F4650B7F465A6&F46802?F46AD7HF46BEF\\F46D04#F46D2FiF46D3FAF47190]F474702F478AC&F47946?" +
+        "F47960?F47B09AF47B5E]F47D8F;F47DEF]F47F352F481C4&F483CDiF4844CmF4848DiF487C5?F48918?F489742F48B32uF48C50AF48CEB3F48E385F48E92?F492BFoF49634A" +
+        "F49AB1<F49F54]F49FF3?F4A157?F4A1A6&F4A310&F4A475AF4A4D6?F4A59D?F4A739BF4ACC12F4AFE7&F4B1C24F4B24E?F4B301AF4B3B1bF4B52FBF4B599&F4B78D?F4B8212" +
+        "F4B85EmF4B898mF4BD9E2F4BEEC&F4BF80?F4BFA8BF4C248]F4C714?F4C88AAF4CB52?F4CBE7&F4CC55BF4CE23AF4CE36OF4CE46<F4CFA27F4CFE22F4D108AF4D488&F4D9FB]" +
+        "F4DBE3&F4DBE62F4DCF9?F4DD06]F4DEAF?F4E11EmF4E1FC<F4E2C6oF4E3FB?F4E451?F4E5F2?F4E8C7&F4EA672F4EB38\\F4EC38iF4EE085F4EE312F4F15A&F4F26DiF4F28A?" +
+        "F4F309]F4F50BiF4F5D8;F4F5DBuF4F5E8;F4F951&F4FBB8?F4FC32mF4FE3E&F4FEFB]F800A1?F80113?F801B4DF80377&F8075D?F8084F\\F80BCB2F80CF3DF80DA9wF80DAC<" +
+        "F80F6F2F80FF9;F81093&F814DD2F81654AF8172DnF81A2B;F81A67iF81E49&F81EDF&F820A9?F823B2?F82793&F828C9?F8293A?F82AE2&F82B7F?F82D7C&F82E0CmF82E3F?" +
+        "F82F65?F83002mF832E4#F83331mF83441AF8369BmF83869DF83880&F839182F83B7E?F83DFF?F83E95?F83F51]F84288&F843EE7F843EFuF84477bF8461CeF84ABF?F84CDA?" +
+        "F84D89&F84DFC>F84E17eF84E58]F84E73&F84F572F85329?F854B8%F85548mF85971AF85B1B7F85B6E]F85C24dF85EA0AF860F0<F86214&F8633FAF8665A&F866F22F868FF2" +
+        "F86BD92F86EEE?F86FB0iF86FC1&F8710CuF871A6&F872EA2F87394MF873DF&F87588?F877B8]F87907?F87A412F87B202F87D3F?F87D76&F88306uF884F2]F887F1&F88A5Em" +
+        "F88C21iF88F07]F88FCA;F8916FmF894970F894C2AF89522?F895C7DF895EA&F89753?F898B9?F898EF?F89A25?F89A78?F89E94AF8A45FuF8A5C52F8A73A2F8A986HF8A9D0D" +
+        "F8AB05\\F8AB82uF8AC65AF8AF05?F8B132?F8B1565F8B1DD&F8B22C[F8B3B77F8B46A<F8B54DAF8B7E22F8BC125F8BF09?F8C001BF8C116BF8C2882F8C362bF8C39E?F8C3CC&" +
+        "F8C6502F8C903iF8CAB85F8CB15&F8CE21iF8CF52AF8D0ACeF8D0BD]F8D111iF8D3F0&F8DB885F8DE73?F8E252&F8E4E3AF8E57E2F8E5CE&F8E61A]F8E811?F8E9033F8E94E&" +
+        "F8E94F2F8EDFC<F8F1E6]F8F21EAF8F58C&F8F7B9?F8FB90mF8FCE1%F8FE5EAF8FFC2&FC012C7FC0296uFC039F]FC0736?FC0F4BmFC0F76%FC0FE6eFC1119bFC1193?FC122C?" +
+        "FC15B4<FC1803?FC183C&FC1910]FC1999uFC1A46]FC1BD1?FC1D3A?FC1D43&FC21202FC22F4wFC253F&FC2640?FC268CaFC2A9C&FC315D&FC3342BFC3497#FC3CD7nFC3F7C?" +
+        "FC3FDB<FC4116;FC4203]FC4345uFC4482AFC45C3mFC47D8&FC48EF?FC492D%FC4CEA5FC4CEF?FC4D6AbFC4DA6?FC4E6D?FC4EA4&FC50D6?FC51B5?FC5557&FC5708/FC589A2" +
+        "FC5B392FC5B8CuFC5F494FC643A]FC64BAuFC65B3?FC65DE%FC6637\\FC66CF&FC671FnFC6947mFC6A1CGFC6D77AFC6E83]FC72882FC73FB?FC75163FC7774AFC79DD?FC7FF1<" +
+        "FC862A?FC8743?FC8827&FC8C11HFC8F90]FC915D;FC931D?FC936B]FC9435?FC9643BFC99472FC9C98)FC9CA7&FC9D05FFC9DD2&FC9E53AFC9F2AwFC9FFD>FCA0F3?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&";
+}

+ 132 - 0
RackPeek.Domain/Discovery/NetworkProbe.cs

@@ -1,6 +1,9 @@
 using System.Net;
 using System.Net;
 using System.Net.NetworkInformation;
 using System.Net.NetworkInformation;
+using System.Net.Security;
 using System.Net.Sockets;
 using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
 using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.Services.Networking;
 
 
 namespace RackPeek.Domain.Discovery;
 namespace RackPeek.Domain.Discovery;
@@ -83,6 +86,135 @@ public sealed class NetworkProbe : INetworkProbe {
         }
         }
     }
     }
 
 
+    public async Task<string?> 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<string?> 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<string?> 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 <title> 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() {
     public Cidr? LocalSubnet() {
         try {
         try {
             foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) {
             foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) {

+ 38 - 1
RackPeek.Domain/Discovery/NetworkScanFacts.cs

@@ -12,7 +12,27 @@ public sealed record NetworkHostFact(
     string? Mac,
     string? Mac,
     string? Hostname,
     string? Hostname,
     bool AnsweredPing,
     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>
 /// <summary>How to sweep. The defaults suit a quiet home /24.</summary>
 public sealed record NetworkScanOptions {
 public sealed record NetworkScanOptions {
@@ -31,4 +51,21 @@ public sealed record NetworkScanOptions {
 
 
     /// <summary>How many hosts are probed at once.</summary>
     /// <summary>How many hosts are probed at once.</summary>
     public int Concurrency { get; init; } = 128;
     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;
 }
 }

+ 81 - 1
RackPeek.Domain/Discovery/NetworkScanMapper.cs

@@ -1,4 +1,5 @@
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
 
 
@@ -18,11 +19,18 @@ public static class NetworkScanMapper {
                 DiscoveryId.NetworkScheme,
                 DiscoveryId.NetworkScheme,
                 host.Mac ?? $"ip:{host.Ip}");
                 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 {
             var system = new SystemResource {
                 Kind = SystemResource.KindLabel,
                 Kind = SystemResource.KindLabel,
                 Name = DiscoveryNaming.Unique(
                 Name = DiscoveryNaming.Unique(
                     DiscoveryNaming.Suggest(
                     DiscoveryNaming.Suggest(
-                        DiscoveryNaming.HostLabel(host.Hostname),
+                        label,
                         "host",
                         "host",
                         discoveryId),
                         discoveryId),
                     discoveryId,
                     discoveryId,
@@ -37,15 +45,87 @@ public static class NetworkScanMapper {
             if (host.Mac != null)
             if (host.Mac != null)
                 system.Labels["mac"] = host.Mac;
                 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)
             if (allIps.Count > 1)
                 system.Labels["ips"] = string.Join(",", allIps);
                 system.Labels["ips"] = string.Join(",", allIps);
 
 
             resources.Add(system);
             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;
         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>
     /// <summary>
     ///     One MAC answering on several addresses — a gateway's VIPs and aliases — is
     ///     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
     ///     still one machine, so it becomes one card: the lowest address as the card's

+ 171 - 2
RackPeek.Domain/Discovery/NetworkScanner.cs

@@ -73,11 +73,29 @@ public static class NetworkScanner {
             }
             }
         }));
         }));
 
 
+        // 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);
         var facts = new List<NetworkHostFact>(alive.Count);
 
 
         for (var i = 0; i < alive.Count; i++) {
         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
         return facts
@@ -85,6 +103,157 @@ public static class NetworkScanner {
             .ToList();
             .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>
     /// <summary>One host's liveness check; null when nothing answered.</summary>
     private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync(
     private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync(
         INetworkProbe probe,
         INetworkProbe probe,

+ 211 - 0
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})</title>",
+                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*(?<server>[^\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);
+    }
+
+    /// <summary>
+    ///     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.
+    /// </summary>
+    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;
+    }
+
+    /// <summary>
+    ///     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.
+    /// </summary>
+    private static bool IsSuccessful(string responseHead) {
+        Match status = Regex.Match(
+            responseHead,
+            @"^HTTP/\d(?:\.\d)?\s+(?<code>\d{3})",
+            RegexOptions.IgnoreCase,
+            TimeSpan.FromSeconds(1));
+
+        return status.Success
+               && int.TryParse(status.Groups["code"].Value, out var code)
+               && code is >= 200 and < 400;
+    }
+
+    /// <summary>
+    ///     Collapses whitespace, drops anything that is only punctuation or digits, and
+    ///     rejects the placeholder names that would label half a rack identically.
+    /// </summary>
+    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;
+    }
+}

+ 27 - 0
RackPeek.Domain/Discovery/WellKnownPorts.cs

@@ -20,4 +20,31 @@ public static class WellKnownPorts {
         8443, // alt https
         8443, // alt https
         9100 // node-exporter / jetdirect
         9100 // node-exporter / jetdirect
     ];
     ];
+
+    /// <summary>
+    ///     The wider list a host is checked against once it has already proven it is
+    ///     alive. Liveness is paid per address, so <see cref="Defaults" /> 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.
+    /// </summary>
+    public static readonly IReadOnlyList<int> 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
+    ];
 }
 }

+ 7 - 0
RackPeek.Domain/RackPeek.Domain.csproj

@@ -6,6 +6,13 @@
         <Nullable>enable</Nullable>
         <Nullable>enable</Nullable>
     </PropertyGroup>
     </PropertyGroup>
 
 
+    <ItemGroup>
+        <!-- Tests.Discovery walks the generated OUI table to prove its packed index
+             encoding maps every record to a real vendor; the table itself stays
+             internal so it is not part of the domain's surface. -->
+        <InternalsVisibleTo Include="Tests.Discovery" />
+    </ItemGroup>
+
     <ItemGroup>
     <ItemGroup>
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
         <PackageReference Include="LibGit2Sharp" Version="0.32.0" />
         <PackageReference Include="LibGit2Sharp" Version="0.32.0" />

+ 6 - 1
Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs

@@ -28,6 +28,10 @@ public sealed class DiscoverNetworkSettings : DiscoverSettings {
     [Description("How many hosts to probe at once.")]
     [Description("How many hosts to probe at once.")]
     public int Parallel { get; init; } = 128;
     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; }
+
     /// <summary>The parsed --cidr, or null when it was omitted or does not parse.</summary>
     /// <summary>The parsed --cidr, or null when it was omitted or does not parse.</summary>
     public NetworkCidr? ParsedCidr =>
     public NetworkCidr? ParsedCidr =>
         NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
         NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
@@ -128,7 +132,8 @@ public sealed class DiscoverNetworkCommand(INetworkProbe probe)
             Cidr = cidr,
             Cidr = cidr,
             Ports = settings.ResolvedPorts,
             Ports = settings.ResolvedPorts,
             PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
             PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
-            Concurrency = settings.Parallel
+            Concurrency = settings.Parallel,
+            IdentifyServices = !settings.NoIdentify
         };
         };
 
 
         var targets = NetworkScanner.EnumerateTargets(cidr).Count();
         var targets = NetworkScanner.EnumerateTargets(cidr).Count();

+ 2 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands.md

@@ -4090,6 +4090,8 @@ OPTIONS:
                            e.g. 22,80,443. Defaults to a curated homelab list   
                            e.g. 22,80,443. Defaults to a curated homelab list   
         --timeout <MS>     Milliseconds to wait on each port probe              
         --timeout <MS>     Milliseconds to wait on each port probe              
         --parallel <N>     How many hosts to probe at once                      
         --parallel <N>     How many hosts to probe at once                      
+        --no-identify      Skip asking living hosts what they are — sweep for   
+                           liveness only                                        
 ```
 ```
 
 
 ## `rpk ansible`
 ## `rpk ansible`

+ 102 - 12
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 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 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 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
 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.
 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 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
   (model, cores and threads per socket), memory, physical disks with Proxmox's own
   nvme/ssd/hdd classification, and its GPUs;
   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.
   machine, carrying the PVE version.
 
 
 Guests then run on the hypervisor, giving the full Hardware → System → System tree that
 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
 ```yaml
 - kind: Server
 - kind: Server
-  name: kepler
+  name: pve-node-01
   cpus:
   cpus:
   - model: AMD Ryzen 5 5600G
   - model: AMD Ryzen 5 5600G
     cores: 6
     cores: 6
@@ -252,14 +252,14 @@ the graph views are built around.
   - model: GeForce RTX 3090
   - model: GeForce RTX 3090
   - model: GeForce RTX 3090
   - model: GeForce RTX 3090
 - kind: System
 - kind: System
-  name: kepler-pve
+  name: pve-node-01-pve
   type: hypervisor
   type: hypervisor
   os: Proxmox VE 8.2.2
   os: Proxmox VE 8.2.2
-  runsOn: [kepler]
+  runsOn: [pve-node-01]
 - kind: System
 - kind: System
   name: docker-01
   name: docker-01
   type: vm
   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
 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
   type: vm
   labels:
   labels:
     gpu: GeForce RTX 3090, GeForce RTX 3090
     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
 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`
 ## `rpk discover network`
 
 
 The collector for machines nothing else can describe: no agent, no API — just an
 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
 ```bash
 # Sweep this machine's own subnet and look at the result
 # 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
 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.
 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
 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.
 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:
 and the ids keep re-runs honest:
 
 
 ```bash
 ```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
 ### Identity

+ 96 - 0
Tests.Discovery/MacVendorLookupTests.cs

@@ -0,0 +1,96 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     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.
+/// </summary>
+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.");
+        }
+    }
+}

+ 216 - 0
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;
+
+/// <summary>
+///     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-&lt;hash&gt;" cards; these tests own the
+///     rules that turn those into recognisable machines.
+/// </summary>
+public class NetworkIdentityTests {
+    private static NetworkHostFact Host(
+        string ip,
+        string? mac = null,
+        string? hostname = null,
+        ServiceIdentity? identity = null,
+        string? vendor = null,
+        IReadOnlyList<ServiceIdentity>? services = null) =>
+        new(ip, mac, hostname, true, []) {
+            Identity = identity,
+            Vendor = vendor,
+            Services = services ?? []
+        };
+
+    private static SystemResource Single(params NetworkHostFact[] hosts) =>
+        Assert.IsType<SystemResource>(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-<hash>", 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<Resource> cards = NetworkScanMapper.ToResources([
+            Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
+        ]);
+
+        SystemResource host = Assert.Single(cards.OfType<SystemResource>());
+        Service service = Assert.Single(cards.OfType<Service>());
+
+        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<Resource> cards = NetworkScanMapper.ToResources([
+            Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
+        ]);
+
+        Assert.StartsWith("host-", Assert.Single(cards.OfType<SystemResource>()).Name);
+    }
+
+    [Fact]
+    public void Several_applications_on_one_host_each_get_their_own_service() {
+        List<Resource> 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<Service>().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-<hash> saying nothing new.
+        List<Resource> 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<SystemResource>()).Name);
+        Assert.Empty(cards.OfType<Service>());
+    }
+
+    [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<Service>().Select(s => s.DiscoveryId).ToList();
+        var second = NetworkScanMapper.ToResources([host]).OfType<Service>().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<Resource> 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());
+    }
+}

+ 204 - 1
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.
 ///     The probe is the IO seam — everything above it is what these tests own.
 /// </summary>
 /// </summary>
 public class NetworkScannerTests {
 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) =>
     private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) =>
         new() {
         new() {
             Cidr = Cidr.Parse(cidr),
             Cidr = Cidr.Parse(cidr),
             Ports = ports.Length > 0 ? ports : [22, 80],
             Ports = ports.Length > 0 ? ports : [22, 80],
             PingTimeout = TimeSpan.FromMilliseconds(5),
             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<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> 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\n<title>Home Assistant</title>" }
+        };
+
+        IReadOnlyList<NetworkHostFact> 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\n<title>Home Assistant</title>" }
+        };
+
+        IReadOnlyList<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> 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<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        Assert.Null(Assert.Single(hosts).Vendor);
+    }
+
     [Fact]
     [Fact]
     public async Task A_host_that_answers_nothing_is_not_reported() {
     public async Task A_host_that_answers_nothing_is_not_reported() {
         var probe = new ScriptedProbe();
         var probe = new ScriptedProbe();
@@ -118,8 +279,13 @@ public class NetworkScannerTests {
         public Dictionary<string, string> Names { get; } = [];
         public Dictionary<string, string> Names { get; } = [];
         public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
         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<(string Ip, int Port)> PortProbes { get; } = [];
         public List<string> DnsLookups { get; } = [];
         public List<string> DnsLookups { get; } = [];
+        public List<(string Ip, int Port, string Kind)> IdentityProbes { get; } = [];
         public bool IsSupported => true;
         public bool IsSupported => true;
         public int MaxInFlight { get; private set; }
         public int MaxInFlight { get; private set; }
         public bool ArpReadAfterSweep { get; private set; }
         public bool ArpReadAfterSweep { get; private set; }
@@ -178,6 +344,43 @@ public class NetworkScannerTests {
             return Task.FromResult(Names.GetValueOrDefault(ip));
             return Task.FromResult(Names.GetValueOrDefault(ip));
         }
         }
 
 
+        public Task<string?> 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<string?> 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<string?> 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;
         public Cidr? LocalSubnet() => null;
     }
     }
 }
 }

+ 128 - 0
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;
+
+/// <summary>
+///     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.
+/// </summary>
+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<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", "192.0.2.50", "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["nas01"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_service_with_no_host_at_all_is_anchored_too() {
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", "192.0.2.50")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["nas01"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_host_arriving_in_the_same_payload_counts() {
+        // discover docker emits the host alongside its services.
+        List<Resource> 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<Service>().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<Resource> existing = [
+            System("nas01", "192.0.2.50"),
+            System("some-vm", "192.0.2.99")
+        ];
+        List<Resource> incoming = [Service("immich", "192.0.2.50", "some-vm")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["some-vm"], incoming.OfType<Service>().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<Resource> existing = [
+            System("site-a-host", "192.0.2.50"),
+            System("site-b-host", "192.0.2.50")
+        ];
+        List<Resource> incoming = [Service("immich", "192.0.2.50", "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["NAS01.lan"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_service_with_no_address_is_left_alone() {
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", null, "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["NAS01.lan"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void An_address_no_system_claims_anchors_nothing() {
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", "198.51.100.99", "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["NAS01.lan"], incoming.OfType<Service>().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<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [System("scanned-host", "192.0.2.50", "rpk1:net:b")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Empty(incoming.OfType<SystemResource>().Single().RunsOn);
+    }
+}

+ 138 - 0
Tests.Discovery/ServiceIdentityParserTests.cs

@@ -0,0 +1,138 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     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.
+/// </summary>
+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\n<html><head><title>Home Assistant</title>";
+
+        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<html><body>";
+
+        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("<title>Forgejo: Beyond coding. We Forge.</title>", "Forgejo")]
+    [InlineData("<title>Grafana | Dashboards</title>", "Grafana")]
+    [InlineData("<title>Jellyfin – Media</title>", "Jellyfin")]
+    [InlineData("<title>Home Assistant</title>", "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\n<title>my: little server</title>";
+
+        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<title>\n   Home    Assistant\n</title>";
+
+        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<title>   </title>")]
+    [InlineData("HTTP/1.0 200 OK\r\n\r\n<title>404</title>")] // 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"
+                   + "<title>HTTP Status 400 – Bad Request</title>";
+
+        // 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\n<title>Something broke</title>";
+
+        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\n<title>Home Assistant</title>";
+
+        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<title>{new string('x', 300)}</title>";
+
+        Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+}

+ 209 - 0
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'''// <auto-generated>
+//     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.
+// </auto-generated>
+
+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]))