using RackPeek.Domain.Discovery;
namespace Tests.Discovery;
///
/// Reading a host's name out of what its services volunteer. The fixtures follow the
/// shape of what real homelab gear emits — a Proxmox node, an OPNsense firewall, a
/// network printer — because the value of this parser is entirely in what it makes of
/// awkward real-world output rather than well-formed examples. The names and
/// addresses throughout are invented.
///
public class ServiceIdentityParserTests {
[Theory]
// Proxmox ships a per-node certificate naming the node. This single line is what
// turns "host-1a2b3c4d" into "pve-node-01".
[InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-01.example.com", "pve-node-01.example.com")]
[InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-02.example", "pve-node-02.example")]
[InlineData("CN=OPNsense.localdomain, O=OPNsense self-signed", "OPNsense.localdomain")]
[InlineData("CN=printer-lobby.local", "printer-lobby.local")]
[InlineData("CN = spaced.example.lan, O = Org", "spaced.example.lan")]
public void A_certificate_common_name_is_read_from_its_subject(string subject, string expected) =>
Assert.Equal(expected, ServiceIdentityParser.ParseTlsSubject(subject));
[Theory]
[InlineData("CN=*.example.com, O=Org")] // names a domain, not this machine
[InlineData("CN=localhost")] // the installer's placeholder
[InlineData("CN=-6268337161588699610")] // an appliance serial: no letters, names nothing
[InlineData("O=Org Only, OU=No Common Name")]
[InlineData("")]
[InlineData(null)]
public void A_subject_that_names_nothing_useful_is_rejected(string? subject) =>
Assert.Null(ServiceIdentityParser.ParseTlsSubject(subject));
[Theory]
[InlineData("SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5", "OpenSSH")]
[InlineData("SSH-2.0-dropbear", "dropbear")]
[InlineData("SSH-2.0-dropbear_2022.83", "dropbear")]
[InlineData("SSH-1.99-Cisco-1.25", "Cisco")]
// The version is deliberately dropped: it goes stale on the next patch, and a
// resource named after a point release is worse than one named after the daemon.
public void An_ssh_greeting_yields_the_software_without_its_version(string banner, string expected) =>
Assert.Equal(expected, ServiceIdentityParser.ParseSshBanner(banner));
[Theory]
[InlineData("220 mail.example.com ESMTP Postfix")] // not SSH
[InlineData("HTTP/1.1 200 OK")]
[InlineData("")]
[InlineData(null)]
public void Something_that_is_not_an_ssh_greeting_yields_nothing(string? banner) =>
Assert.Null(ServiceIdentityParser.ParseSshBanner(banner));
[Fact]
public void A_page_title_is_preferred_over_the_server_header() {
// "Home Assistant" identifies the box; "nginx" identifies half the internet.
var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0\r\n\r\n
Home Assistant";
Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
}
[Fact]
public void The_server_header_is_used_when_there_is_no_title() {
var head = "HTTP/1.0 200 OK\r\nServer: llama.cpp\r\nContent-Type: text/html\r\n\r\n";
Assert.Equal("llama.cpp", ServiceIdentityParser.ParseHttpIdentity(head));
}
[Fact]
public void A_server_headers_version_is_trimmed() {
var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0 (Ubuntu)\r\n\r\n";
Assert.Equal("nginx", ServiceIdentityParser.ParseHttpIdentity(head));
}
[Theory]
// Observed on a live sweep: the tagline became the card's name.
[InlineData("Forgejo: Beyond coding. We Forge.", "Forgejo")]
[InlineData("Grafana | Dashboards", "Grafana")]
[InlineData("Jellyfin – Media", "Jellyfin")]
[InlineData("Home Assistant", "Home Assistant")]
public void A_title_is_trimmed_to_the_phrase_before_its_separator(string body, string expected) =>
Assert.Equal(expected, ServiceIdentityParser.ParseHttpIdentity("HTTP/1.0 200 OK\r\n\r\n" + body));
[Fact]
public void A_title_whose_lead_is_too_short_to_stand_alone_is_kept_whole() {
// A stray separator near the start is not a product name.
var head = "HTTP/1.0 200 OK\r\n\r\nmy: little server";
Assert.Equal("my: little server", ServiceIdentityParser.ParseHttpIdentity(head));
}
[Fact]
public void Whitespace_in_a_title_is_collapsed() {
var head = "HTTP/1.0 200 OK\r\n\r\n\n Home Assistant\n";
Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
}
[Theory]
[InlineData("HTTP/1.0 401 Unauthorized\r\nWWW-Authenticate: Basic\r\n\r\n")]
[InlineData("HTTP/1.0 200 OK\r\n\r\n ")]
[InlineData("HTTP/1.0 200 OK\r\n\r\n404")] // digits name nothing
[InlineData("")]
[InlineData(null)]
public void An_http_response_that_names_nothing_yields_nothing(string? head) =>
Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
[Fact]
public void An_error_pages_title_is_not_a_name() {
// Observed on a live sweep: a host answering 400 produced a card called
// "http-status-400-bad-request".
var head = "HTTP/1.1 400 Bad Request\r\nServer: Apache\r\n\r\n"
+ "HTTP Status 400 – Bad Request";
// The Server header is still accurate on an error response, so it is used.
Assert.Equal("Apache", ServiceIdentityParser.ParseHttpIdentity(head));
}
[Fact]
public void An_error_response_with_no_server_header_names_nothing() {
var head = "HTTP/1.1 500 Internal Server Error\r\n\r\nSomething broke";
Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
}
[Fact]
public void A_redirect_still_counts_as_a_working_page() {
var head = "HTTP/1.1 302 Found\r\nLocation: /ui\r\n\r\nHome Assistant";
Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
}
[Fact]
public void An_absurdly_long_title_is_rejected_rather_than_becoming_a_name() {
var head = $"HTTP/1.0 200 OK\r\n\r\n{new string('x', 300)}";
Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
}
}