ServiceIdentityParserTests.cs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. using RackPeek.Domain.Discovery;
  2. namespace Tests.Discovery;
  3. /// <summary>
  4. /// Reading a host's name out of what its services volunteer. The fixtures follow the
  5. /// shape of what real homelab gear emits — a Proxmox node, an OPNsense firewall, a
  6. /// network printer — because the value of this parser is entirely in what it makes of
  7. /// awkward real-world output rather than well-formed examples. The names and
  8. /// addresses throughout are invented.
  9. /// </summary>
  10. public class ServiceIdentityParserTests {
  11. [Theory]
  12. // Proxmox ships a per-node certificate naming the node. This single line is what
  13. // turns "host-1a2b3c4d" into "pve-node-01".
  14. [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-01.example.com", "pve-node-01.example.com")]
  15. [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-02.example", "pve-node-02.example")]
  16. [InlineData("CN=OPNsense.localdomain, O=OPNsense self-signed", "OPNsense.localdomain")]
  17. [InlineData("CN=printer-lobby.local", "printer-lobby.local")]
  18. [InlineData("CN = spaced.example.lan, O = Org", "spaced.example.lan")]
  19. public void A_certificate_common_name_is_read_from_its_subject(string subject, string expected) =>
  20. Assert.Equal(expected, ServiceIdentityParser.ParseTlsSubject(subject));
  21. [Theory]
  22. [InlineData("CN=*.example.com, O=Org")] // names a domain, not this machine
  23. [InlineData("CN=localhost")] // the installer's placeholder
  24. [InlineData("CN=-6268337161588699610")] // an appliance serial: no letters, names nothing
  25. [InlineData("O=Org Only, OU=No Common Name")]
  26. [InlineData("")]
  27. [InlineData(null)]
  28. public void A_subject_that_names_nothing_useful_is_rejected(string? subject) =>
  29. Assert.Null(ServiceIdentityParser.ParseTlsSubject(subject));
  30. [Theory]
  31. [InlineData("SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5", "OpenSSH")]
  32. [InlineData("SSH-2.0-dropbear", "dropbear")]
  33. [InlineData("SSH-2.0-dropbear_2022.83", "dropbear")]
  34. [InlineData("SSH-1.99-Cisco-1.25", "Cisco")]
  35. // The version is deliberately dropped: it goes stale on the next patch, and a
  36. // resource named after a point release is worse than one named after the daemon.
  37. public void An_ssh_greeting_yields_the_software_without_its_version(string banner, string expected) =>
  38. Assert.Equal(expected, ServiceIdentityParser.ParseSshBanner(banner));
  39. [Theory]
  40. [InlineData("220 mail.example.com ESMTP Postfix")] // not SSH
  41. [InlineData("HTTP/1.1 200 OK")]
  42. [InlineData("")]
  43. [InlineData(null)]
  44. public void Something_that_is_not_an_ssh_greeting_yields_nothing(string? banner) =>
  45. Assert.Null(ServiceIdentityParser.ParseSshBanner(banner));
  46. [Fact]
  47. public void A_page_title_is_preferred_over_the_server_header() {
  48. // "Home Assistant" identifies the box; "nginx" identifies half the internet.
  49. var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0\r\n\r\n<html><head><title>Home Assistant</title>";
  50. Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
  51. }
  52. [Fact]
  53. public void The_server_header_is_used_when_there_is_no_title() {
  54. var head = "HTTP/1.0 200 OK\r\nServer: llama.cpp\r\nContent-Type: text/html\r\n\r\n<html><body>";
  55. Assert.Equal("llama.cpp", ServiceIdentityParser.ParseHttpIdentity(head));
  56. }
  57. [Fact]
  58. public void A_server_headers_version_is_trimmed() {
  59. var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0 (Ubuntu)\r\n\r\n";
  60. Assert.Equal("nginx", ServiceIdentityParser.ParseHttpIdentity(head));
  61. }
  62. [Theory]
  63. // Observed on a live sweep: the tagline became the card's name.
  64. [InlineData("<title>Forgejo: Beyond coding. We Forge.</title>", "Forgejo")]
  65. [InlineData("<title>Grafana | Dashboards</title>", "Grafana")]
  66. [InlineData("<title>Jellyfin – Media</title>", "Jellyfin")]
  67. [InlineData("<title>Home Assistant</title>", "Home Assistant")]
  68. public void A_title_is_trimmed_to_the_phrase_before_its_separator(string body, string expected) =>
  69. Assert.Equal(expected, ServiceIdentityParser.ParseHttpIdentity("HTTP/1.0 200 OK\r\n\r\n" + body));
  70. [Fact]
  71. public void A_title_whose_lead_is_too_short_to_stand_alone_is_kept_whole() {
  72. // A stray separator near the start is not a product name.
  73. var head = "HTTP/1.0 200 OK\r\n\r\n<title>my: little server</title>";
  74. Assert.Equal("my: little server", ServiceIdentityParser.ParseHttpIdentity(head));
  75. }
  76. [Fact]
  77. public void Whitespace_in_a_title_is_collapsed() {
  78. var head = "HTTP/1.0 200 OK\r\n\r\n<title>\n Home Assistant\n</title>";
  79. Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
  80. }
  81. [Theory]
  82. [InlineData("HTTP/1.0 401 Unauthorized\r\nWWW-Authenticate: Basic\r\n\r\n")]
  83. [InlineData("HTTP/1.0 200 OK\r\n\r\n<title> </title>")]
  84. [InlineData("HTTP/1.0 200 OK\r\n\r\n<title>404</title>")] // digits name nothing
  85. [InlineData("")]
  86. [InlineData(null)]
  87. public void An_http_response_that_names_nothing_yields_nothing(string? head) =>
  88. Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
  89. [Fact]
  90. public void An_error_pages_title_is_not_a_name() {
  91. // Observed on a live sweep: a host answering 400 produced a card called
  92. // "http-status-400-bad-request".
  93. var head = "HTTP/1.1 400 Bad Request\r\nServer: Apache\r\n\r\n"
  94. + "<title>HTTP Status 400 – Bad Request</title>";
  95. // The Server header is still accurate on an error response, so it is used.
  96. Assert.Equal("Apache", ServiceIdentityParser.ParseHttpIdentity(head));
  97. }
  98. [Fact]
  99. public void An_error_response_with_no_server_header_names_nothing() {
  100. var head = "HTTP/1.1 500 Internal Server Error\r\n\r\n<title>Something broke</title>";
  101. Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
  102. }
  103. [Fact]
  104. public void A_redirect_still_counts_as_a_working_page() {
  105. var head = "HTTP/1.1 302 Found\r\nLocation: /ui\r\n\r\n<title>Home Assistant</title>";
  106. Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
  107. }
  108. [Fact]
  109. public void An_absurdly_long_title_is_rejected_rather_than_becoming_a_name() {
  110. var head = $"HTTP/1.0 200 OK\r\n\r\n<title>{new string('x', 300)}</title>";
  111. Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
  112. }
  113. }