Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,4 @@ FodyWeavers.xsd

# macOS
.DS_Store
ISSUE_TRIAGE.md
12 changes: 10 additions & 2 deletions RackPeek.Domain/Api/UpsertInventoryUseCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,18 @@ public async Task<ImportYamlResponse> ExecuteAsync(ImportYamlRequest request) {
List<Resource>? incomingResources = incomingRoot.Resources;
IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();

IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();

// Line discovered resources up with what they already map to before anything
// else looks at names, so the diff below reports against the right resources.
DiscoveryIdResolver.ResolveNames(currentResources, incomingResources, incomingRoot.Connections);
// A dry run gets the same reconciliation but is not allowed to improve stored
// names, because that rewrites the inventory and a dry run must not.
DiscoveryIdResolver.ResolveNames(
currentResources,
incomingResources,
incomingRoot.Connections,
currentConnections,
!request.DryRun);

IGrouping<string, Resource>? duplicate = incomingResources
.GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
Expand Down Expand Up @@ -123,7 +132,6 @@ public async Task<ImportYamlResponse> ExecuteAsync(ImportYamlRequest request) {
else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
}

IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
List<Connection>? mergedConnections = ConnectionMerger.Merge(
currentConnections,
incomingRoot.Connections,
Expand Down
338 changes: 333 additions & 5 deletions RackPeek.Domain/Discovery/DiscoveryIdResolver.cs

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions RackPeek.Domain/Discovery/DockerApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,31 @@ private static (HttpClient Client, string Endpoint) Create(string? dockerHost) {
return (UnixSocketClient(path), dockerHost);
}

// Anything else is rejected here rather than at send time. HttpClient accepts an
// ssh:// or npipe:// URI happily and only throws NotSupportedException on the
// first request — which is not in the caller's catch list, so a DOCKER_HOST that
// `docker context` set up quite normally crashed with a stack trace instead of
// saying what was wrong.
if (!StartsWithScheme(dockerHost, "tcp://")
&& !StartsWithScheme(dockerHost, "http://")
&& !StartsWithScheme(dockerHost, "https://"))
// A UriFormatException on purpose: both front ends already turn that into
// "not a usable Docker endpoint", so there is one phrasing for a bad endpoint
// rather than two.
throw new UriFormatException(
"Use a unix socket (unix:///var/run/docker.sock) or a TCP endpoint "
+ "(tcp://host:2375). For an ssh:// context, forward the socket first — "
+ "ssh -L 2375:/var/run/docker.sock user@host — and point --docker-host at that.");

// tcp:// is the scheme people have in DOCKER_HOST, but it is plain HTTP on the wire.
var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase));

return (new HttpClient { BaseAddress = uri }, dockerHost);
}

private static bool StartsWithScheme(string value, string scheme) =>
value.StartsWith(scheme, StringComparison.OrdinalIgnoreCase);

private static HttpClient UnixSocketClient(string socketPath) {
var handler = new SocketsHttpHandler {
ConnectCallback = async (_, cancellationToken) => {
Expand Down
21 changes: 21 additions & 0 deletions RackPeek.Domain/Discovery/INetworkProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ public interface INetworkProbe {
/// <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);

/// <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>
/// 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.
Expand Down
12 changes: 12 additions & 0 deletions RackPeek.Domain/Discovery/IProxmoxClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,16 @@ Task<ProxmoxGuestConfig> GetGuestConfigAsync(
string endpoint,
int vmId,
CancellationToken cancellationToken = default);

/// <summary>
/// Addresses the guest reports for its own interfaces — the only way to learn a
/// DHCP guest's address, since the config only carries one when it was set
/// statically. Needs the guest agent for a VM and a running container for LXC,
/// so an empty list is the normal answer for anything that has neither.
/// </summary>
Task<IReadOnlyList<ProxmoxGuestAddress>> GetGuestAddressesAsync(
string node,
string endpoint,
int vmId,
CancellationToken cancellationToken = default);
}
106 changes: 106 additions & 0 deletions RackPeek.Domain/Discovery/MacVendorLookup.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading