Prechádzať zdrojové kódy

Add rpk discover opnsense: read the firewall's neighbour table

The collector for everything a sweep can see but not identify.

ARP is link-local. Sweeping from one machine yields a MAC for that
machine's own segment and nothing but an address for every other subnet —
and an address alone cannot survive a DHCP re-lease or be matched against
anything already documented. The firewall routes every subnet, so its
neighbour table carries the MAC for all of them, the name it handed out,
and which leg each machine answered on.

Cards are seeded exactly as the network sweep seeds its own, keyed on the
MAC, because both describe the same thing by the same evidence: a machine
observed on the network rather than asked about itself. So a host the
firewall knows and a host a sweep found are one card, whichever ran first,
with no special case anywhere to say so. On a live estate this turned 21
resources into 13 updated in place and 8 machines no sweep had ever seen —
devices that answer no port and no ping but sit in the firewall's table.

What it leaves out, on purpose: the firewall's own addresses (every routed
subnet contributes one and they are all the same box, which is a Firewall
rather than the handful of Systems this would invent), entries that have
aged out, broadcast and multicast, and neighbours on public addresses.
That last one is the ISP's equipment on the WAN leg — not the user's
infrastructure, and recording it would put a public address into a file
people commit. --include-public asks for them.

Tolerant of the endpoint rename in OPNsense 25.7, which moved these
actions from camelCase to snake_case and broke integrations pinned to
either spelling; both are tried. Both response shapes are read too, since
the search wrapper wraps the same rows in an object. A key that lacks the
Diagnostics: ARP Table privilege gets a redirect to the login page rather
than a 401, so that is reported as the permission problem it is.

Verified against a live two-firewall estate; the fixtures and tests use
invented addresses and MAC suffixes throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 2 dní pred
rodič
commit
6cd8f9847a

+ 128 - 0
RackPeek.Domain/Discovery/OpnsenseApiClient.cs

@@ -0,0 +1,128 @@
+using System.Net.Http.Headers;
+using System.Net.Security;
+using System.Text;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Reads an OPNsense firewall's API. The IO half of firewall discovery.</summary>
+public interface IOpnsenseClient {
+    /// <summary>Where this client is pointed, for error messages.</summary>
+    string Endpoint { get; }
+
+    /// <summary>
+    ///     Every neighbour the firewall currently has an ARP entry for, across every
+    ///     subnet it routes.
+    /// </summary>
+    Task<IReadOnlyList<OpnsenseNeighbour>> GetNeighboursAsync(CancellationToken cancellationToken = default);
+}
+
+/// <summary>
+///     Talks to the OPNsense API with a key and secret, which OPNsense issues per user
+///     and sends as HTTP basic credentials. A key can be given a read-only role and
+///     revoked on its own, so it is used rather than a login.
+/// </summary>
+public sealed class OpnsenseApiClient : IOpnsenseClient, IDisposable {
+    public const string KeyEnvironmentVariable = "RPK_OPN_KEY";
+    public const string SecretEnvironmentVariable = "RPK_OPN_SECRET";
+
+    /// <summary>
+    ///     The ARP endpoint, newest spelling first. OPNsense 25.7 renamed its API actions
+    ///     from camelCase to snake_case and kept the old names only for a while — an
+    ///     integration pinned to either one breaks on half the installations out there.
+    ///     Asking for each in turn costs one extra request against older firmware and
+    ///     nothing against new.
+    /// </summary>
+    private static readonly string[] _arpPaths = [
+        "api/diagnostics/interface/get_arp",
+        "api/diagnostics/interface/getArp"
+    ];
+
+    private readonly HttpClient _httpClient;
+
+    /// <param name="allowUntrustedCertificate">
+    ///     OPNsense ships with a self-signed certificate and most installations keep it.
+    ///     Opt-in all the same.
+    /// </param>
+    public OpnsenseApiClient(
+        string host,
+        string key,
+        string secret,
+        bool allowUntrustedCertificate = false,
+        HttpClient? httpClient = null) {
+        Endpoint = Normalise(host);
+
+        _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate));
+        _httpClient.BaseAddress = new Uri(Endpoint + "/");
+        _httpClient.Timeout = TimeSpan.FromSeconds(30);
+
+        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
+            "Basic",
+            Convert.ToBase64String(Encoding.UTF8.GetBytes($"{key}:{secret}")));
+    }
+
+    public string Endpoint { get; }
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public async Task<IReadOnlyList<OpnsenseNeighbour>> GetNeighboursAsync(
+        CancellationToken cancellationToken = default) {
+        HttpRequestException? last = null;
+
+        foreach (var path in _arpPaths) {
+            try {
+                return OpnsenseResponseParser.ParseArp(await GetAsync(path, cancellationToken));
+            }
+            catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) {
+                // Wrong spelling for this firmware; try the other.
+                last = ex;
+            }
+        }
+
+        throw last ?? new HttpRequestException("The firewall has no ARP endpoint this understands.");
+    }
+
+    /// <summary>
+    ///     A bare name gets https, matching how the web UI is reached. The path is
+    ///     trimmed so callers may paste a URL straight out of the browser.
+    /// </summary>
+    public static string Normalise(string host) {
+        var trimmed = host.Trim().TrimEnd('/');
+
+        if (!trimmed.Contains("://", StringComparison.Ordinal))
+            trimmed = "https://" + trimmed;
+
+        var uri = new Uri(trimmed);
+
+        return uri.GetLeftPart(UriPartial.Authority);
+    }
+
+    private async Task<string> GetAsync(string path, CancellationToken cancellationToken) {
+        using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken);
+
+        // A key without the right privilege is the common setup mistake, and OPNsense
+        // answers it with a redirect to the login page rather than a 401.
+        if (response.StatusCode is System.Net.HttpStatusCode.Found
+            or System.Net.HttpStatusCode.MovedPermanently
+            or System.Net.HttpStatusCode.Unauthorized
+            or System.Net.HttpStatusCode.Forbidden)
+            throw new HttpRequestException(
+                $"The firewall refused the API key ({(int)response.StatusCode}). Check the key and secret, "
+                + "and that its user holds the Diagnostics: ARP Table privilege.",
+                null,
+                response.StatusCode);
+
+        response.EnsureSuccessStatusCode();
+
+        return await response.Content.ReadAsStringAsync(cancellationToken);
+    }
+
+    private static HttpClientHandler Handler(bool allowUntrustedCertificate) {
+        var handler = new HttpClientHandler();
+
+        if (allowUntrustedCertificate)
+            handler.ServerCertificateCustomValidationCallback =
+                (_, _, _, _) => true;
+
+        return handler;
+    }
+}

+ 108 - 0
RackPeek.Domain/Discovery/OpnsenseDiscovery.cs

@@ -0,0 +1,108 @@
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Turns a firewall's neighbour table into System resources.
+///     <para>
+///         The cards are seeded exactly as <see cref="NetworkScanMapper" /> seeds its
+///         own — the network scheme, keyed on the MAC — because they describe the same
+///         thing by the same evidence: a machine observed on the network rather than
+///         asked about itself. That makes the two collectors interchangeable. A host the
+///         firewall knows and a host a sweep found are one card, whichever ran first,
+///         with no special case anywhere to say so.
+///     </para>
+/// </summary>
+public static class OpnsenseDiscovery {
+    public static async Task<List<Resource>> ReadAsync(
+        IOpnsenseClient client,
+        bool includePublic = false,
+        CancellationToken cancellationToken = default) =>
+        ToResources(await client.GetNeighboursAsync(cancellationToken), includePublic);
+
+    /// <summary>
+    ///     Whether an address belongs to a network someone runs themselves: the RFC 1918
+    ///     ranges plus the carrier-grade block an ISP may hand out.
+    ///     <para>
+    ///         A firewall's WAN leg has neighbours too, and they are the ISP's equipment
+    ///         rather than anything the user owns. Recording them would also put a public
+    ///         address into a file people commit to git, which is a surprising thing for
+    ///         an inventory of a home lab to do on its own. Anyone documenting a fleet on
+    ///         public addresses can ask for them.
+    ///     </para>
+    /// </summary>
+    public static bool IsPrivate(string ip) {
+        var parts = ip.Split('.');
+
+        if (parts.Length != 4 || !int.TryParse(parts[0], out var a) || !int.TryParse(parts[1], out var b))
+            return false;
+
+        return a switch {
+            10 => true,
+            172 => b is >= 16 and <= 31,
+            192 => b == 168,
+            100 => b is >= 64 and <= 127, // carrier-grade NAT
+            _ => false
+        };
+    }
+
+    public static List<Resource> ToResources(
+        IReadOnlyList<OpnsenseNeighbour> neighbours,
+        bool includePublic = false) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var resources = new List<Resource>();
+
+        foreach (OpnsenseNeighbour neighbour in neighbours
+                     .Where(n => includePublic || IsPrivate(n.Ip))
+                     .OrderBy(n => Order(n.Ip))) {
+            var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, neighbour.Mac);
+
+            var system = new SystemResource {
+                Kind = SystemResource.KindLabel,
+                Name = DiscoveryNaming.Unique(
+                    DiscoveryNaming.Suggest(
+                        DiscoveryNaming.HostLabel(neighbour.Hostname),
+                        "host",
+                        discoveryId),
+                    discoveryId,
+                    taken),
+                DiscoveryId = discoveryId,
+                // Sparse for the same reason a scan's cards are: the firewall knows where
+                // a machine is and what its NIC is, never what runs on it. Writing a guess
+                // here would overwrite the real values on the next run of a collector that
+                // does know.
+                Ip = neighbour.Ip
+            };
+
+            system.Labels["mac"] = neighbour.Mac;
+
+            // Ours first so the vocabulary matches every other card, the firewall's own
+            // lookup second — it carries the whole IEEE registry, so it answers for the
+            // prefixes the curated table leaves out.
+            var vendor = MacVendorLookup.Lookup(neighbour.Mac) ?? neighbour.Manufacturer;
+
+            if (vendor != null)
+                system.Labels["vendor"] = vendor;
+
+            // Which leg of the firewall saw it — the closest thing to a physical location
+            // the firewall can offer, and the thing that says which VLAN a host is on.
+            if (neighbour.Interface != null)
+                system.Labels["segment"] = neighbour.Interface;
+
+            resources.Add(system);
+        }
+
+        return resources;
+    }
+
+    private static uint Order(string ip) {
+        try {
+            return Resources.Services.Networking.IpHelper.ToUInt32(ip);
+        }
+        catch (ArgumentException) {
+            // A malformed address still deserves a card; it just sorts last.
+            return uint.MaxValue;
+        }
+    }
+}

+ 120 - 0
RackPeek.Domain/Discovery/OpnsenseModels.cs

@@ -0,0 +1,120 @@
+using System.Text.Json;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     One neighbour the firewall has seen, from its ARP table.
+///     <para>
+///         This is the record a sweep cannot produce for anything off its own segment:
+///         ARP is link-local, so a host on another subnet gives a scanner an address and
+///         nothing else. The firewall routes every subnet, so its table carries the MAC
+///         for all of them — and a MAC is the identity that survives a DHCP re-lease.
+///     </para>
+/// </summary>
+public sealed record OpnsenseNeighbour(
+    string Ip,
+    string Mac,
+    string? Hostname,
+    string? Manufacturer,
+    string? Interface);
+
+public static class OpnsenseResponseParser {
+    /// <summary>
+    ///     Reads the ARP table. OPNsense answers either a flat array or, through the
+    ///     search wrapper, an object with a <c>rows</c> array; both shapes are accepted so
+    ///     the caller need not care which endpoint answered.
+    /// </summary>
+    public static List<OpnsenseNeighbour> ParseArp(string json) {
+        var neighbours = new List<OpnsenseNeighbour>();
+
+        using var document = JsonDocument.Parse(json);
+
+        JsonElement root = document.RootElement;
+
+        JsonElement rows = root.ValueKind switch {
+            JsonValueKind.Array => root,
+            JsonValueKind.Object when root.TryGetProperty("rows", out JsonElement r) => r,
+            _ => default
+        };
+
+        if (rows.ValueKind != JsonValueKind.Array)
+            return neighbours;
+
+        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (JsonElement entry in rows.EnumerateArray()) {
+            if (entry.ValueKind != JsonValueKind.Object)
+                continue;
+
+            // An address the firewall holds itself. Every routed subnet contributes one,
+            // and they are all the same box — which is a Firewall, not the handful of
+            // Systems this would otherwise invent.
+            if (IsTrue(entry, "permanent"))
+                continue;
+
+            // The entry is still listed after it ages out; it says where something used
+            // to be, which is not evidence that it is there now.
+            if (IsTrue(entry, "expired"))
+                continue;
+
+            var mac = ArpTableParser.NormaliseMac(Text(entry, "mac"));
+            var ip = Text(entry, "ip");
+
+            if (mac == null || string.IsNullOrWhiteSpace(ip))
+                continue;
+
+            // Broadcast and multicast are not machines.
+            if (mac is "ff:ff:ff:ff:ff:ff" || IsMulticast(mac))
+                continue;
+
+            // One row per machine: a host answering on several of the firewall's
+            // interfaces is still one machine, and the first row carries its address.
+            if (!seen.Add(mac))
+                continue;
+
+            neighbours.Add(new OpnsenseNeighbour(
+                ip,
+                mac,
+                Clean(Text(entry, "hostname")),
+                Clean(Text(entry, "manufacturer")),
+                Clean(Text(entry, "intf_description")) ?? Clean(Text(entry, "intf"))));
+        }
+
+        return neighbours;
+    }
+
+    /// <summary>
+    ///     A locally administered group address — the low bit of the first octet marks
+    ///     multicast, which no host owns.
+    /// </summary>
+    private static bool IsMulticast(string mac) =>
+        Convert.ToInt32(mac[..2], 16) % 2 == 1;
+
+    private static string? Text(JsonElement element, string name) =>
+        element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
+            ? value.GetString()
+            : null;
+
+    /// <summary>
+    ///     OPNsense writes these as real booleans in some versions and as the strings
+    ///     "1"/"true" in others.
+    /// </summary>
+    private static bool IsTrue(JsonElement element, string name) {
+        if (!element.TryGetProperty(name, out JsonElement value))
+            return false;
+
+        return value.ValueKind switch {
+            JsonValueKind.True => true,
+            JsonValueKind.String => value.GetString() is "1" or "true" or "yes",
+            JsonValueKind.Number => value.TryGetInt32(out var number) && number != 0,
+            _ => false
+        };
+    }
+
+    /// <summary>Blank and placeholder values arrive as empty strings or dashes.</summary>
+    private static string? Clean(string? value) {
+        var trimmed = value?.Trim();
+
+        return string.IsNullOrEmpty(trimmed) || trimmed is "-" or "(none)" ? null : trimmed;
+    }
+}

+ 5 - 0
Shared.Rcl/CliBootstrap.cs

@@ -806,6 +806,11 @@ public static class CliBootstrap {
                     .WithExample("discover", "proxmox", "--host", "https://pve.lan:8006", "--insecure")
                     .WithExample("discover", "proxmox", "--host", "pve.lan", "--push");
 
+                discover.AddCommand<DiscoverOpnsenseCommand>("opnsense")
+                    .WithDescription("Read an OPNsense firewall's neighbour table and emit every machine on it.")
+                    .WithExample("discover", "opnsense", "--host", "https://firewall.lan", "--insecure")
+                    .WithExample("discover", "opnsense", "--host", "firewall.lan", "--push");
+
                 discover.AddCommand<DiscoverNetworkCommand>("network")
                     .WithDescription("Sweep a subnet and emit every answering host as a System resource.")
                     .WithExample("discover", "network")

+ 100 - 0
Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs

@@ -0,0 +1,100 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverOpnsenseSettings : DiscoverSettings {
+    [CommandOption("--host <URL>")]
+    [Description("OPNsense host, e.g. https://firewall.lan. A bare host name gets https.")]
+    public string? Host { get; init; }
+
+    [CommandOption("--key <KEY>")]
+    [Description("API key. Defaults to RPK_OPN_KEY.")]
+    public string? Key { get; init; }
+
+    [CommandOption("--secret <SECRET>")]
+    [Description("API secret. Defaults to RPK_OPN_SECRET.")]
+    public string? Secret { get; init; }
+
+    [CommandOption("--insecure")]
+    [Description("Accept a self-signed certificate, which OPNsense ships with by default.")]
+    public bool Insecure { get; init; }
+
+    [CommandOption("--include-public")]
+    [Description("Also record neighbours on public addresses, such as the ISP equipment on the WAN leg.")]
+    public bool IncludePublic { get; init; }
+
+    public string? ResolvedKey =>
+        DiscoveryPublisher.Resolve(Key, OpnsenseApiClient.KeyEnvironmentVariable);
+
+    public string? ResolvedSecret =>
+        DiscoveryPublisher.Resolve(Secret, OpnsenseApiClient.SecretEnvironmentVariable);
+
+    public override ValidationResult Validate() {
+        if (string.IsNullOrWhiteSpace(Host))
+            return ValidationResult.Error("Pass --host, e.g. --host https://firewall.lan");
+
+        if (string.IsNullOrWhiteSpace(ResolvedKey))
+            return ValidationResult.Error(
+                $"No API key. Pass --key or set {OpnsenseApiClient.KeyEnvironmentVariable}.");
+
+        if (string.IsNullOrWhiteSpace(ResolvedSecret))
+            return ValidationResult.Error(
+                $"No API secret. Pass --secret or set {OpnsenseApiClient.SecretEnvironmentVariable}.");
+
+        return base.Validate();
+    }
+}
+
+/// <summary>
+///     Reads an OPNsense firewall's neighbour table and emits every machine on it.
+///     <para>
+///         The collector for everything a sweep can see but not identify. ARP is
+///         link-local, so sweeping from one host yields a MAC only for that host's own
+///         segment and an address for everything else — and an address alone cannot
+///         survive a DHCP re-lease or be matched to anything. The firewall routes every
+///         subnet, so its table has the MAC for all of them.
+///     </para>
+/// </summary>
+public sealed class DiscoverOpnsenseCommand : AsyncCommand<DiscoverOpnsenseSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverOpnsenseSettings settings,
+        CancellationToken cancellationToken) {
+        using var client = new OpnsenseApiClient(
+            settings.Host!,
+            settings.ResolvedKey!,
+            settings.ResolvedSecret!,
+            settings.Insecure);
+
+        List<Resource> resources;
+
+        try {
+            resources = await OpnsenseDiscovery.ReadAsync(client, settings.IncludePublic, cancellationToken);
+        }
+        catch (Exception ex) when (
+            ex is HttpRequestException or IOException or TimeoutException
+            || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+            AnsiConsole.MarkupLine(
+                $"[red]Could not read {Markup.Escape(client.Endpoint)}.[/] {Markup.Escape(ex.Message)}");
+
+            // A self-signed certificate is the other common cause, and its message is
+            // opaque enough to be worth naming.
+            if (!settings.Insecure && ex is HttpRequestException { StatusCode: null })
+                AnsiConsole.MarkupLine(
+                    "[grey]If the firewall uses its own certificate, add --insecure.[/]");
+
+            return 1;
+        }
+
+        if (resources.Count == 0)
+            AnsiConsole.MarkupLine(
+                "[grey]The firewall's ARP table is empty. It only holds neighbours it has "
+                + "spoken to recently, so this is normal on a quiet network.[/]");
+
+        return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
+    }
+}

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

@@ -242,6 +242,7 @@
     - [system](/docs/cli-commands#rpk-discover-system) - Inspect this machine and emit it as a System resource
     - [docker](/docs/cli-commands#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this
     - [proxmox](/docs/cli-commands#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems
+    - [opnsense](/docs/cli-commands#rpk-discover-opnsense) - Read an OPNsense firewall's neighbour table and emit every machine on it
     - [network](/docs/cli-commands#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource
   - [ansible](/docs/cli-commands#rpk-ansible) - Generate and manage Ansible inventory
     - [inventory](/docs/cli-commands#rpk-ansible-inventory) - Generate an Ansible inventory

+ 40 - 5
Shared.Rcl/wwwroot/raw_docs/cli-commands.md

@@ -3966,11 +3966,14 @@ OPTIONS:
     -h, --help    Prints help information
 
 COMMANDS:
-    system     Inspect this machine and emit it as a System resource            
-    docker     Read the Docker API and emit each published container as a       
-               Service on this host's System                                    
-    proxmox    Read a Proxmox cluster and emit its nodes and guests as Systems  
-    network    Sweep a subnet and emit every answering host as a System resource
+    system      Inspect this machine and emit it as a System resource           
+    docker      Read the Docker API and emit each published container as a      
+                Service on this host's System                                   
+    proxmox     Read a Proxmox cluster and emit its nodes and guests as Systems 
+    opnsense    Read an OPNsense firewall's neighbour table and emit every      
+                machine on it                                                   
+    network     Sweep a subnet and emit every answering host as a System        
+                resource                                                        
 ```
 
 ## `rpk discover system`
@@ -4061,6 +4064,38 @@ OPTIONS:
                                    Proxmox ships with by default                
 ```
 
+## `rpk discover opnsense`
+```
+DESCRIPTION:
+Read an OPNsense firewall's neighbour table and emit every machine on it
+
+USAGE:
+    rpk discover opnsense [OPTIONS]
+
+EXAMPLES:
+    rpk discover opnsense --host https://firewall.lan --insecure
+    rpk discover opnsense --host firewall.lan --push
+
+OPTIONS:
+    -h, --help               Prints help information                            
+        --push               Upload the result to a RackPeek server instead of  
+                             printing it                                        
+        --server <URL>       RackPeek server to upload to. Defaults to the      
+                             RPK_SERVER environment variable                    
+        --api-key <KEY>      API key for the server. Defaults to the RPK_API_KEY
+                             environment variable                               
+        --dry-run            Ask the server what would change, without changing 
+                             anything. Implies --push                           
+        --host <URL>         OPNsense host, e.g. https://firewall.lan. A bare   
+                             host name gets https                               
+        --key <KEY>          API key. Defaults to RPK_OPN_KEY                   
+        --secret <SECRET>    API secret. Defaults to RPK_OPN_SECRET             
+        --insecure           Accept a self-signed certificate, which OPNsense   
+                             ships with by default                              
+        --include-public     Also record neighbours on public addresses, such as
+                             the ISP equipment on the WAN leg                   
+```
+
 ## `rpk discover network`
 ```
 DESCRIPTION:

+ 69 - 0
Shared.Rcl/wwwroot/raw_docs/discovery-guide.md

@@ -8,6 +8,7 @@ don't have to type in what the machine already knows about itself.
 | `rpk discover system` | the machine it runs on | one **System** resource |
 | `rpk discover docker` | the Docker Engine API | one **Service** per published container, plus the **System** they run on |
 | `rpk discover proxmox` | a Proxmox VE cluster | a **Server** and **System** per node, a **System** per guest, already wired together |
+| `rpk discover opnsense` | an OPNsense firewall's neighbour table | one **System** per machine it has seen, on every subnet it routes |
 | `rpk discover network` | a subnet, from outside | one **System** per host that answers, plus a **Service** for each web application it recognises |
 
 Both print YAML to standard output by default and change nothing, so it is always safe
@@ -346,6 +347,74 @@ described in full rather than becoming a second, emptier card beside it.
 
 ---
 
+## `rpk discover opnsense`
+
+The collector for everything a sweep can see but not identify.
+
+ARP is link-local. Sweeping from one machine gets you a MAC for that machine's own
+segment and nothing but an address for every other subnet — and an address alone cannot
+survive a DHCP re-lease or be matched against anything else you have documented. The
+firewall routes every subnet, so its neighbour table has the MAC for all of them, plus
+the name it handed out and which leg it was seen on.
+
+```bash
+# Look at what the firewall knows
+rpk discover opnsense --host https://firewall.lan --insecure
+
+# Merge it into your server
+rpk discover opnsense --host firewall.lan --push
+```
+
+| Option | Meaning |
+|---|---|
+| `--host <URL>` | The firewall. A bare name gets `https`. |
+| `--key <KEY>` | API key. Defaults to `RPK_OPN_KEY`. |
+| `--secret <SECRET>` | API secret. Defaults to `RPK_OPN_SECRET`. |
+| `--insecure` | Accept the self-signed certificate OPNsense ships with. |
+| `--include-public` | Also record neighbours on public addresses (see below). |
+
+Plus the same `--push` / `--server` / `--api-key` / `--dry-run` options as every other
+collector.
+
+Create the credentials in the firewall under **System → Access → Users**, on a user that
+holds the **Diagnostics: ARP Table** privilege. Read-only is enough; nothing here writes.
+
+### What it records, and what it leaves out
+
+One **System** per machine, carrying its address, its MAC, the vendor that MAC belongs
+to, and a `segment` label naming the firewall leg it answered on — which is the closest
+thing to "which VLAN is this on" that the firewall can tell you.
+
+Left out on purpose:
+
+* **The firewall's own addresses.** Every routed subnet contributes one and they are all
+  the same box — which is a Firewall, not the handful of Systems this would invent.
+* **Entries that have aged out.** They say where something used to be.
+* **Broadcast and multicast addresses**, which no machine owns.
+* **Neighbours on public addresses**, such as the ISP equipment on the WAN leg. They are
+  not your infrastructure, and recording one would put a public address into a file you
+  may well commit. Pass `--include-public` if you are documenting a fleet that lives on
+  them.
+
+A machine answering on two of the firewall's legs is one card, not two: its identity is
+its MAC.
+
+### Why it lines up with everything else
+
+A card from the firewall is seeded exactly as `rpk discover network` seeds its own —
+keyed on the MAC — because both describe the same thing by the same evidence: a machine
+observed on the network rather than asked about itself. So a host the firewall knows and
+a host a sweep found are **one card**, whichever collector ran first, with no special
+case anywhere to say so. Run both and the firewall fills in the identity a sweep of a
+routed subnet could never get.
+
+One wrinkle worth knowing: names are yours, so discovery never renames a resource that
+already exists — including one a sweep named `host-<hash>` before the firewall could
+offer something better. Running the firewall collector first, or on a fresh inventory,
+gets you the good names.
+
+---
+
 ## `rpk discover network`
 
 The collector for machines nothing else can describe: no agent, no API — just an

+ 86 - 0
Tests.Discovery/Fixtures/opnsense-arp.json

@@ -0,0 +1,86 @@
+[
+  {
+    "mac": "00:50:c2:00:1a:01",
+    "ip": "198.51.100.7",
+    "intf": "igb0",
+    "expired": false,
+    "expires": -1,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Cisco Systems",
+    "hostname": "",
+    "intf_description": "WAN"
+  },
+  {
+    "mac": "00:1b:21:00:1a:02",
+    "ip": "192.0.2.1",
+    "intf": "igb1",
+    "expired": false,
+    "expires": -1,
+    "permanent": true,
+    "type": "ethernet",
+    "manufacturer": "Intel Corporate",
+    "hostname": "",
+    "intf_description": "LAB"
+  },
+  {
+    "mac": "1c:6a:1b:00:1a:03",
+    "ip": "192.168.10.150",
+    "intf": "igb1_vlan20",
+    "expired": false,
+    "expires": 1132,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Ubiquiti Inc",
+    "hostname": "ap-outdoor",
+    "intf_description": "home"
+  },
+  {
+    "mac": "bc:24:11:00:1a:04",
+    "ip": "192.168.50.105",
+    "intf": "igb1_vlan50",
+    "expired": false,
+    "expires": 980,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Proxmox Server Solutions GmbH",
+    "hostname": "forgejo",
+    "intf_description": "HomeServices"
+  },
+  {
+    "mac": "aa:bb:cc:00:1a:05",
+    "ip": "192.168.10.99",
+    "intf": "igb1_vlan20",
+    "expired": true,
+    "expires": -1,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "",
+    "hostname": "gone-away",
+    "intf_description": "home"
+  },
+  {
+    "mac": "ff:ff:ff:ff:ff:ff",
+    "ip": "192.168.10.255",
+    "intf": "igb1_vlan20",
+    "expired": false,
+    "expires": -1,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "",
+    "hostname": "",
+    "intf_description": "home"
+  },
+  {
+    "mac": "1c:6a:1b:00:1a:03",
+    "ip": "192.168.30.150",
+    "intf": "igb1_vlan30",
+    "expired": false,
+    "expires": 900,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Ubiquiti Inc",
+    "hostname": "ap-outdoor",
+    "intf_description": "IOT"
+  }
+]

+ 169 - 0
Tests.Discovery/OpnsenseDiscoveryTests.cs

@@ -0,0 +1,169 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     A firewall's neighbour table in, System resources out.
+///     <para>
+///         This is the collector for everything a sweep can see but not identify. ARP is
+///         link-local, so sweeping from one host yields a MAC for that host's own segment
+///         and an address for everything else — and an address alone cannot survive a
+///         DHCP re-lease or be matched to anything. The firewall routes every subnet, so
+///         its table has the MAC, the name it handed out, and which leg it was seen on.
+///     </para>
+/// </summary>
+public class OpnsenseDiscoveryTests {
+    private static List<OpnsenseNeighbour> Neighbours() =>
+        OpnsenseResponseParser.ParseArp(Fixture.Read("opnsense-arp.json"));
+
+    private static List<Resource> Discover(bool includePublic = false) =>
+        OpnsenseDiscovery.ToResources(Neighbours(), includePublic);
+
+    [Fact]
+    public void The_table_yields_one_neighbour_per_machine() {
+        List<OpnsenseNeighbour> neighbours = Neighbours();
+
+        // Of seven rows: one is the firewall's own address, one has aged out, one is the
+        // broadcast address, and one is a second sighting of a host that answers on two
+        // of the firewall's legs.
+        Assert.Equal(3, neighbours.Count);
+    }
+
+    [Fact]
+    // Every routed subnet contributes one, and they are all the same box — which is a
+    // Firewall, not the handful of Systems this would otherwise invent.
+    public void An_address_the_firewall_holds_itself_is_not_a_neighbour() =>
+        Assert.DoesNotContain(Neighbours(), n => n.Mac == "00:1b:21:00:1a:02");
+
+    [Fact]
+    // It says where something used to be, which is not evidence that it is there now.
+    public void An_entry_that_has_aged_out_is_not_a_neighbour() =>
+        Assert.DoesNotContain(Neighbours(), n => n.Hostname == "gone-away");
+
+    [Fact]
+    public void The_broadcast_address_is_not_a_machine() =>
+        Assert.DoesNotContain(Neighbours(), n => n.Mac.StartsWith("ff:", StringComparison.Ordinal));
+
+    [Fact]
+    // Its identity is the MAC, so two sightings must not become two machines.
+    public void A_host_seen_on_two_legs_is_still_one_card() =>
+        Assert.Single(Discover().OfType<SystemResource>(), s => s.Labels["mac"] == "1c:6a:1b:00:1a:03");
+
+    [Fact]
+    // It is not the user's infrastructure, and recording it would put a public address
+    // into a file people commit.
+    public void The_isps_equipment_on_the_wan_leg_is_left_out_by_default() =>
+        Assert.DoesNotContain(Discover().OfType<SystemResource>(), s => s.Ip == "198.51.100.7");
+
+    [Fact]
+    // A fleet documented on public addresses is a real case; it just is not the default.
+    public void Public_neighbours_can_be_asked_for() =>
+        Assert.Contains(Discover(true).OfType<SystemResource>(), s => s.Ip == "198.51.100.7");
+
+    [Theory]
+    [InlineData("10.0.0.5")]
+    [InlineData("172.16.4.9")]
+    [InlineData("172.31.255.254")]
+    [InlineData("192.168.1.1")]
+    [InlineData("100.64.0.1")] // carrier-grade NAT
+    public void Private_and_carrier_ranges_count_as_ones_own_network(string ip) =>
+        Assert.True(OpnsenseDiscovery.IsPrivate(ip));
+
+    [Theory]
+    [InlineData("198.51.100.7")]
+    [InlineData("8.8.8.8")]
+    [InlineData("172.15.0.1")] // just below the private block
+    [InlineData("172.32.0.1")] // just above it
+    [InlineData("100.63.0.1")] // just below the carrier block
+    [InlineData("not-an-address")]
+    public void Everything_else_does_not(string ip) =>
+        Assert.False(OpnsenseDiscovery.IsPrivate(ip));
+
+    [Fact]
+    // The whole point: a sweep of another subnet can only call this host-<hash>.
+    public void The_name_the_firewall_handed_out_becomes_the_card_name() =>
+        Assert.Contains(Discover().OfType<SystemResource>(), s => s.Name == "forgejo");
+
+    [Fact]
+    public void A_neighbour_with_no_name_still_gets_a_stable_one() {
+        List<Resource> cards = Discover(true);
+
+        SystemResource wan = cards.OfType<SystemResource>().Single(s => s.Ip == "198.51.100.7");
+        Assert.StartsWith("host-", wan.Name);
+    }
+
+    [Fact]
+    public void A_card_carries_the_mac_the_vendor_and_the_leg_it_was_seen_on() {
+        SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        Assert.Equal("bc:24:11:00:1a:04", card.Labels["mac"]);
+        Assert.Equal("Proxmox", card.Labels["vendor"]);
+        Assert.Equal("HomeServices", card.Labels["segment"]);
+        Assert.Equal("192.168.50.105", card.Ip);
+    }
+
+    [Fact]
+    public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() {
+        // Ours is a curated subset, so it answers "Proxmox" where the firewall says
+        // "Proxmox Server Solutions GmbH" — but the firewall carries the whole registry
+        // and answers for prefixes ours has never heard of.
+        List<Resource> cards = Discover(true);
+
+        SystemResource wan = cards.OfType<SystemResource>().Single(s => s.Ip == "198.51.100.7");
+        Assert.Equal("Cisco Systems", wan.Labels["vendor"]);
+    }
+
+    [Fact]
+    public void A_card_is_seeded_exactly_as_a_sweep_would_seed_it() {
+        // The contract that makes the two collectors interchangeable: same evidence, same
+        // identity, so a host the firewall knows and a host a sweep found are one card
+        // whichever ran first — with no special case anywhere to say so.
+        SystemResource fromFirewall = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        SystemResource fromSweep = NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.168.50.105", "bc:24:11:00:1a:04", null, true, [])
+        ]).OfType<SystemResource>().Single();
+
+        Assert.Equal(fromSweep.DiscoveryId, fromFirewall.DiscoveryId);
+    }
+
+    [Fact]
+    public void The_cards_stay_sparse() {
+        // The firewall knows where a machine is and what its NIC is, never what runs on
+        // it. A guess here would overwrite the real values on the next run of a collector
+        // that does know.
+        SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        Assert.Null(card.Type);
+        Assert.Null(card.Os);
+        Assert.Null(card.Cores);
+        Assert.Null(card.Ram);
+    }
+
+    [Fact]
+    public void The_paginated_shape_reads_the_same_as_the_flat_one() {
+        // The search wrapper returns {rows: [...]} while the direct call returns a bare
+        // array; a caller should not have to know which endpoint answered.
+        var rows = $$"""{"rows": {{Fixture.Read("opnsense-arp.json")}}, "total": 7}""";
+
+        Assert.Equal(
+            Neighbours().Select(n => n.Mac),
+            OpnsenseResponseParser.ParseArp(rows).Select(n => n.Mac));
+    }
+
+    [Theory]
+    [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":"1"}]""")]
+    [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":1}]""")]
+    [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":true}]""")]
+    public void A_flag_is_read_however_the_firmware_spells_it(string json) =>
+        // OPNsense writes these as real booleans in some versions and as "1" in others.
+        Assert.Empty(OpnsenseResponseParser.ParseArp(json));
+
+    [Fact]
+    public void An_empty_table_is_not_an_error() {
+        Assert.Empty(OpnsenseResponseParser.ParseArp("[]"));
+        Assert.Empty(OpnsenseResponseParser.ParseArp("""{"rows":[],"total":0}"""));
+    }
+}