Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e5cce72
Add auto discovery: rpk discover system / docker / proxmox
Timmoth Sep 25, 2026
b8f6d23
Fix flaky E2E add: wait for the Blazor circuit before typing
Timmoth Sep 25, 2026
a93700c
Merge pull request #335 from Timmoth/feature/auto-discovery
Timmoth Sep 25, 2026
8c57cf0
Merge branch 'staging' of https://github.com/Timmoth/RackPeek into st…
Timmoth Sep 26, 2026
e1a7a6f
Add MCP server: manage, query and build the inventory from AI assistants
Timmoth Sep 26, 2026
c489817
Add ports to Laptop, Other and Ups, and a usb port type
BialySztorm Sep 26, 2026
2ed50ae
Add CLI and Playwright coverage for the new ports
BialySztorm Sep 26, 2026
b029745
Regenerate CLI docs for the new port commands
BialySztorm Sep 26, 2026
1443bfe
Merge pull request #338 from BialySztorm/feature/ports-laptop-other-ups
Timmoth Sep 26, 2026
1501fee
Merge branch 'staging' into mcp
Timmoth Sep 27, 2026
12f6196
Merge branch 'staging' of https://github.com/Timmoth/RackPeek into st…
Timmoth Sep 27, 2026
be0cb36
Add rpk discover network: sweep a subnet, emit answering hosts as Sys…
Timmoth Sep 27, 2026
e5ed5cf
Harden network discovery: Windows ARP, shared-MAC hosts, ansible fit
Timmoth Sep 27, 2026
6ce03a7
Address code-review findings on network discovery
Timmoth Sep 27, 2026
f89a2a6
Unify system and network discovery through the MAC bridge
Timmoth Sep 27, 2026
09226b3
Bring Proxmox guests onto the MAC bridge
Timmoth Sep 27, 2026
598b223
Fix flaky YAML-import E2E: wait for the Blazor circuit before typing
Timmoth Sep 27, 2026
c2855b4
Merge pull request #339 from Timmoth/mcp
Timmoth Sep 27, 2026
d7a5c27
Merge branch 'staging' into feature/network-discovery
Timmoth Sep 27, 2026
4624ffa
Merge pull request #340 from Timmoth/feature/network-discovery
Timmoth Sep 27, 2026
426bbbf
Merge branch 'main' into staging
Timmoth Sep 27, 2026
33a2a1a
Prep the v2.2.0 release: bump versions, repair docs plumbing, add rel…
Timmoth Sep 27, 2026
eee4e0b
Add failing E2E repros for #337: damaged config.yaml is accepted sile…
Timmoth Sep 27, 2026
c22af6d
Fix install-guide bind-mount ownership to the real container UID (#210)
Timmoth Sep 27, 2026
676529e
Make config saves atomic and refuse to read a damaged config (#337)
Timmoth Sep 27, 2026
92a7a67
Merge pull request #343 from Timmoth/bug/337-non-atomic-config-saves
Timmoth Sep 27, 2026
89c92aa
Show a system's type in the breadcrumb, not its storage kind
Timmoth Sep 27, 2026
ca5b0dd
Merge pull request #345 from Timmoth/fix/breadcrumb-shows-system-type
Timmoth Sep 27, 2026
5f99c51
Use invented host names in the breadcrumb test's documentation
Timmoth Sep 27, 2026
f0199d2
Identify swept hosts by service banner, MAC vendor and open ports
Timmoth Sep 27, 2026
fca6052
Learn where Proxmox guests are, and stop a sweep duplicating them
Timmoth Sep 27, 2026
d51415a
Fix three discovery defects found in review
Timmoth Sep 28, 2026
6cd8f98
Add rpk discover opnsense: read the firewall's neighbour table
Timmoth Sep 28, 2026
a5143e3
Make every open port a Service, and name labels for what they are
Timmoth Sep 29, 2026
8900008
Let names and identity improve as collectors learn more
Timmoth Sep 29, 2026
bed8c3a
Only offer a service link a browser can actually follow
Timmoth Sep 29, 2026
fbd0b2b
Document the discovery changes, and ignore the local triage scratch file
Timmoth Sep 29, 2026
1adc168
Stop the Git service test racing every other test that registers serv…
Timmoth Sep 29, 2026
d801aaa
Never let an unbuildable address take down the page rendering it
Timmoth Sep 29, 2026
48378e4
Merge pull request #349 from Timmoth/fix/flaky-git-static
Timmoth Sep 29, 2026
eeb2726
Allow renaming a resource to a different case of its own name (#328)
VirSanctus Sep 28, 2026
b9b35be
Merge pull request #348 from Timmoth/feature/network-host-identity
Timmoth Sep 29, 2026
1f4c9fa
Merge pull request #350 from VirSanctus/bug/328-case-d
Timmoth Sep 29, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/generate-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@ jobs:
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add Shared.Rcl/wwwroot/raw_docs/CommandIndex.md Shared.Rcl/wwwroot/raw_docs/Commands.md
git add Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md Shared.Rcl/wwwroot/raw_docs/cli-commands.md
git commit -m "Update CLI docs" || echo "No changes to commit"
git push
4 changes: 2 additions & 2 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ on:
workflow_dispatch:
inputs:
version:
description: "RackPeek version (e.g. 0.1.0)"
description: "RackPeek version (e.g. 2.2.0)"
required: true
default: "0.0.3"
default: "2.2.0"

permissions:
contents: write
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish-docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ on:
workflow_dispatch:
inputs:
version:
description: "RackPeek version (e.g. v1.0.0)"
description: "RackPeek version (e.g. v2.2.0)"
required: true
default: "v0.0.12"
default: "v2.2.0"

permissions:
contents: read
Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,59 @@ jobs:
run: dotnet format --verify-no-changes


discovery-tests:
name: Discovery Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: format

# Discovery runs on the machines being inventoried rather than on the RackPeek
# host, so its tests are the ones that have to pass on every platform. They are
# driven entirely from captured fixtures and never read the runner itself.
# windows-latest joins this list when the Windows probe lands.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Restore
run: dotnet restore Tests.Discovery

- name: Run Discovery Tests
run: dotnet test Tests.Discovery --configuration Release --verbosity normal


mcp-tests:
name: MCP Tests
runs-on: ubuntu-latest
needs: format

# Every MCP test is end-to-end: a real MCP client speaking streamable HTTP to the
# real server over WebApplicationFactory, asserting on the YAML that lands on disk.
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Restore
run: dotnet restore Tests.Mcp

- name: Run MCP Tests
run: dotnet test Tests.Mcp --configuration Release --verbosity normal


cli-tests:
name: CLI Tests
runs-on: ubuntu-latest
Expand Down
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ When you change persisted YAML shape, the PR **must** include:

## 7. CLI surface

The full command tree is documented in `docs/Commands.md` and `docs/CommandIndex.md` (auto-generated by `generate-docs.sh`). At a glance:
The full command tree is documented in `Shared.Rcl/wwwroot/raw_docs/cli-commands.md` and `Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md` (auto-generated by `generate-docs.sh`). At a glance:

```
rpk <kind> <verb> [name] [flags]
Expand Down Expand Up @@ -311,8 +311,9 @@ Default branches: feature work targets `staging`; releases flow `staging → mai
| `docs/development/contribution-guidelines.md` | PR process |
| `docs/development/dev-cheat-sheet.md` | Build / release / Docker / Playwright details |
| `docs/development/dev-setup.md` | First-time environment setup |
| `docs/development/release-guide.md` | End-to-end release process (version bump, docs regen, publish workflows) |
| `docs/development/testing-guidelines.md` | Testing philosophy + examples |
| `docs/Commands.md` / `docs/CommandIndex.md` | Auto-generated CLI reference |
| `Shared.Rcl/wwwroot/raw_docs/cli-commands.md` / `cli-commands-index.md` | Auto-generated CLI reference |
| `schemas/v1,v2,v3/` | Versioned YAML schemas |
| `README.md` | User-facing overview, Docker install, links |
| `LICENSE` | License terms |
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png)

![Version](https://img.shields.io/badge/Version-2.1.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
![Version](https://img.shields.io/badge/Version-2.2.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
[![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)

RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure.

It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process.
It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process. It can auto-discover what's already running (`rpk discover system / docker / proxmox / network`), and its built-in MCP server lets AI assistants query and manage your inventory.

### The roadmap for the next wave of features is actively being discussed, please make your voice heard!

Expand Down Expand Up @@ -85,6 +85,12 @@ volumes:
*
[**Ansible Inventory Generator Guide**](https://timmoth.github.io/RackPeek/docs/ansible-generator-guide)

*
[**Auto Discovery Guide**](https://timmoth.github.io/RackPeek/docs/discovery-guide)

*
[**MCP Server Guide**](https://timmoth.github.io/RackPeek/docs/mcp-guide) — let AI assistants query, manage and build your stack over the built-in `/mcp` endpoint

*
[**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands)

Expand Down
15 changes: 14 additions & 1 deletion RackPeek.Domain/Api/UpsertInventoryUseCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
using RackPeek.Domain.Discovery;
using RackPeek.Domain.Persistence;
using RackPeek.Domain.Persistence.Yaml;
using RackPeek.Domain.Resources;
Expand Down Expand Up @@ -62,6 +63,19 @@ 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.
// 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)
.FirstOrDefault(g => g.Count() > 1);
Expand Down Expand Up @@ -118,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
107 changes: 107 additions & 0 deletions RackPeek.Domain/Discovery/ArpTableParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Net;
using System.Net.Sockets;

namespace RackPeek.Domain.Discovery;

/// <summary>
/// Reads an ARP table into ip → MAC, from either format the probe can produce:
/// Linux's <c>/proc/net/arp</c> or BSD/macOS <c>arp -an</c> output. MACs are
/// normalised (lowercase, zero-padded octets) because macOS prints <c>1:0:5e:…</c>
/// where Linux prints <c>01:00:5e:…</c> — and the MAC seeds the discovery id, so
/// the same machine must hash the same from every workstation. Pure; never throws.
/// </summary>
public static class ArpTableParser {
public static IReadOnlyDictionary<string, string> Parse(string? text) {
var result = new Dictionary<string, string>(StringComparer.Ordinal);

if (string.IsNullOrWhiteSpace(text))
return result;

foreach (var line in text.Split('\n')) {
(string Ip, string Mac)? entry = ParseLine(line.Trim());

if (entry != null)
result.TryAdd(entry.Value.Ip, entry.Value.Mac);
}

return result;
}

private static (string Ip, string Mac)? ParseLine(string line) {
if (line.Length == 0)
return null;

// BSD/macOS: "? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]"
var open = line.IndexOf('(');
var close = line.IndexOf(')');

if (open >= 0 && close > open) {
var ip = line[(open + 1)..close];
var at = line.IndexOf(" at ", close, StringComparison.Ordinal);

if (at < 0 || !IsIpv4(ip))
return null;

var rest = line[(at + 4)..];
var end = rest.IndexOf(' ');
var mac = NormaliseMac(end > 0 ? rest[..end] : rest);

return mac == null ? null : (ip, mac);
}

var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);

if (columns.Length < 2 || !IsIpv4(columns[0]))
return null;

// Linux /proc/net/arp: "192.168.1.1 0x1 0x2 a4:91:b1:4e:3c:20 * eth0"
if (columns.Length >= 4 && columns[1].StartsWith("0x", StringComparison.Ordinal)) {
// Flags 0x0 marks an entry the kernel gave up resolving.
if (columns[2] == "0x0")
return null;

var linuxMac = NormaliseMac(columns[3]);

return linuxMac == null ? null : (columns[0], linuxMac);
}

// Windows arp -a: "192.168.1.1 a4-91-b1-4e-3c-20 dynamic"
var windowsMac = NormaliseMac(columns[1]);

return windowsMac == null ? null : (columns[0], windowsMac);
}

/// <summary>
/// Lowercase, colon-separated, zero-padded — or null for anything that is not a
/// usable MAC. Accepts Windows' dash separators so the same machine hashes the
/// same from every platform's ARP output.
/// </summary>
public static string? NormaliseMac(string? raw) {
if (string.IsNullOrWhiteSpace(raw))
return null;

var parts = raw.Trim().Split(':', '-');

if (parts.Length != 6)
return null;

var octets = new string[6];

for (var i = 0; i < 6; i++) {
var part = parts[i];

if (part.Length is 0 or > 2 || !part.All(Uri.IsHexDigit))
return null;

octets[i] = part.Length == 1 ? "0" + char.ToLowerInvariant(part[0]) : part.ToLowerInvariant();
}

var mac = string.Join(':', octets);

// All-zero means the neighbour never answered — no identity there.
return mac == "00:00:00:00:00:00" ? null : mac;
}

private static bool IsIpv4(string value) =>
IPAddress.TryParse(value, out IPAddress? ip) && ip.AddressFamily == AddressFamily.InterNetwork;
}
15 changes: 15 additions & 0 deletions RackPeek.Domain/Discovery/DiscoveryDocument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using RackPeek.Domain.Persistence.Yaml;
using RackPeek.Domain.Resources;

namespace RackPeek.Domain.Discovery;

/// <summary>Renders discovered resources as a RackPeek YAML document.</summary>
public static class DiscoveryDocument {
public static string ToYaml(IEnumerable<Resource> resources) {
return YamlResourceCollection.SerializeRootAsync(new YamlRoot {
Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
Resources = resources.ToList(),
Connections = []
});
}
}
49 changes: 49 additions & 0 deletions RackPeek.Domain/Discovery/DiscoveryId.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Security.Cryptography;
using System.Text;

namespace RackPeek.Domain.Discovery;

/// <summary>
/// Deterministic, globally unique identity for a discovered resource.
/// Format: <c>rpk1:{scheme}:{16 hex chars}</c>
/// The hash keeps low-value identifiers (machine-id, MAC) out of a config file
/// that is frequently committed to a public repository. Note this is
/// obfuscation rather than secrecy: the salt is public, so a low entropy seed
/// such as a MAC address remains recoverable by brute force.
/// </summary>
public static class DiscoveryId {
public const string Prefix = "rpk1";
public const string SystemScheme = "sys";
public const string DockerScheme = "docker";
public const string NetworkScheme = "net";

public static string Create(string scheme, string seed) {
if (string.IsNullOrWhiteSpace(scheme))
throw new ArgumentException("Scheme is required.", nameof(scheme));

if (string.IsNullOrWhiteSpace(seed))
throw new ArgumentException("Seed is required.", nameof(seed));

var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{Prefix}:{scheme}:{seed}"));

return $"{Prefix}:{scheme}:{Convert.ToHexString(hash, 0, 8).ToLowerInvariant()}";
}

/// <summary>The scheme segment of an id — "sys" for rpk1:sys:… — or null for anything malformed.</summary>
public static string? Scheme(string? discoveryId) {
if (string.IsNullOrWhiteSpace(discoveryId))
return null;

var parts = discoveryId.Split(':');

return parts.Length == 3 ? parts[1] : null;
}

/// <summary>Short, stable fragment used to disambiguate generated names.</summary>
public static string ShortSuffix(string discoveryId) {
var lastColon = discoveryId.LastIndexOf(':');
var hash = lastColon >= 0 ? discoveryId[(lastColon + 1)..] : discoveryId;

return hash.Length <= 8 ? hash : hash[..8];
}
}
Loading
Loading