Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 53: Writing a HomeLab Plugin — End to End

"A plugin is a NuGet that ships [Injectable] services. Everything else is convention."


Why

Part 10 defined the plugin system: IHomeLabPlugin, role-shaped contributor interfaces, the manifest, the discovery, the DI registration. This part is the end-to-end walkthrough of writing a plugin from scratch. By the end, you should be able to ship a NuGet that adds a meaningful new capability to HomeLab in an afternoon.

The plugin we will build: Cloudflare DNS provider for HomeLab. It adds an IDnsProvider implementation that uses the Cloudflare API to manage records for users who own a real domain and want to host DevLab on a public hostname (with proper Let's Encrypt certs via DNS-01).


Step 1: Scaffold the project

$ dotnet new classlib -n FrenchExDev.HomeLab.Plugin.Cloudflare
$ cd FrenchExDev.HomeLab.Plugin.Cloudflare
$ dotnet add package FrenchExDev.Net.HomeLab.PluginSdk
$ dotnet add package FrenchExDev.Net.Injectable
$ dotnet add package FrenchExDev.Net.HttpClient
$ dotnet add package FrenchExDev.Net.Result

The PluginSdk package gives you the IHomeLabPlugin interface, the IPluginContext type, the role interfaces (IDnsProvider, etc.), and the Result<T> types HomeLab uses. It is the only HomeLab dependency a plugin needs — everything else is wired through DI by the host.


Step 2: The plugin entry point

// CloudflarePlugin.cs

using FrenchExDev.Net.HomeLab.PluginSdk;

namespace FrenchExDev.HomeLab.Plugin.Cloudflare;

public sealed class CloudflarePlugin : IHomeLabPlugin
{
    public string Name => "FrenchExDev.HomeLab.Plugin.Cloudflare";
    public string Version => "1.0.0";
    public string Description => "Cloudflare DNS provider for HomeLab";

    public void Initialize(IPluginContext context)
    {
        context.Logger.LogInformation("Cloudflare DNS plugin {Version} initialized", Version);
        // The IDnsProvider implementation is auto-registered via [Injectable].
        // Nothing else to do here.
    }
}

The entry point is a stub. Its only job is to log a startup message and let the host know the plugin is loaded. The actual contribution is in the next file.


Step 3: The Cloudflare API client

// ICloudflareApi.cs

using FrenchExDev.Net.HttpClient;
using FrenchExDev.Net.Result;

namespace FrenchExDev.HomeLab.Plugin.Cloudflare;

[TypedHttpClient(BaseUri = "https://api.cloudflare.com/client/v4")]
public partial interface ICloudflareApi
{
    [Get("/zones/{zoneId}/dns_records")]
    Task<Result<CloudflareListResponse>> ListRecordsAsync(
        string zoneId,
        [Header("Authorization")] string token,
        [Query("name")] string? nameFilter = null,
        CancellationToken ct = default);

    [Post("/zones/{zoneId}/dns_records")]
    Task<Result<CloudflareCreateResponse>> CreateRecordAsync(
        string zoneId,
        [Body] CloudflareCreateRequest request,
        [Header("Authorization")] string token,
        CancellationToken ct = default);

    [Delete("/zones/{zoneId}/dns_records/{recordId}")]
    Task<Result<CloudflareDeleteResponse>> DeleteRecordAsync(
        string zoneId,
        string recordId,
        [Header("Authorization")] string token,
        CancellationToken ct = default);
}

public sealed record CloudflareCreateRequest(string Type, string Name, string Content, int Ttl = 60, bool Proxied = false);
public sealed record CloudflareListResponse(IReadOnlyList<CloudflareDnsRecord> Result);
public sealed record CloudflareDnsRecord(string Id, string Name, string Type, string Content);
public sealed record CloudflareCreateResponse(CloudflareDnsRecord Result, bool Success);
public sealed record CloudflareDeleteResponse(bool Success);

The interface is partial. The [TypedHttpClient] source generator emits the implementation. The plugin author writes zero HttpClient.GetAsync calls.


Step 4: The DNS provider

// CloudflareDnsProvider.cs

using FrenchExDev.Net.HomeLab.PluginSdk;
using FrenchExDev.Net.Injectable;
using FrenchExDev.Net.Result;
using Microsoft.Extensions.Options;

namespace FrenchExDev.HomeLab.Plugin.Cloudflare;

public sealed class CloudflareConfig
{
    public string ZoneId { get; init; } = "";
    public string ApiTokenSecretKey { get; init; } = "CLOUDFLARE_API_TOKEN";
}

[Injectable(ServiceLifetime.Singleton)]
public sealed class CloudflareDnsProvider : IDnsProvider
{
    public string Name => "cloudflare";

    private readonly ICloudflareApi _api;
    private readonly CloudflareConfig _config;
    private readonly ISecretStore _secrets;
    private readonly IHomeLabEventBus _events;
    private readonly IClock _clock;

    public CloudflareDnsProvider(
        ICloudflareApi api,
        IOptions<CloudflareConfig> config,
        ISecretStore secrets,
        IHomeLabEventBus events,
        IClock clock)
    {
        _api = api;
        _config = config.Value;
        _secrets = secrets;
        _events = events;
        _clock = clock;
    }

    public async Task<Result> AddAsync(string hostname, string ip, CancellationToken ct)
    {
        var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
        if (token.IsFailure) return token.Map();

        var bearer = $"Bearer {token.Value}";
        var result = await _api.CreateRecordAsync(
            _config.ZoneId,
            new CloudflareCreateRequest("A", hostname, ip, Ttl: 60, Proxied: false),
            bearer, ct);

        if (result.IsFailure) return result.Map();

        await _events.PublishAsync(new DnsEntryAdded(hostname, ip, "cloudflare", _clock.UtcNow), ct);
        return Result.Success();
    }

    public async Task<Result> RemoveAsync(string hostname, CancellationToken ct)
    {
        var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
        if (token.IsFailure) return token.Map();

        var bearer = $"Bearer {token.Value}";
        var list = await _api.ListRecordsAsync(_config.ZoneId, bearer, hostname, ct);
        if (list.IsFailure) return list.Map();

        foreach (var record in list.Value.Result)
        {
            var del = await _api.DeleteRecordAsync(_config.ZoneId, record.Id, bearer, ct);
            if (del.IsFailure) return del.Map();
        }

        await _events.PublishAsync(new DnsEntryRemoved(hostname, "cloudflare", _clock.UtcNow), ct);
        return Result.Success();
    }

    public async Task<Result<IReadOnlyList<DnsEntry>>> ListAsync(CancellationToken ct)
    {
        var token = await _secrets.ReadAsync(_config.ApiTokenSecretKey, ct);
        if (token.IsFailure) return token.Map<IReadOnlyList<DnsEntry>>();

        var bearer = $"Bearer {token.Value}";
        var list = await _api.ListRecordsAsync(_config.ZoneId, bearer, ct: ct);
        if (list.IsFailure) return list.Map<IReadOnlyList<DnsEntry>>();

        var entries = list.Value.Result
            .Where(r => r.Type == "A")
            .Select(r => new DnsEntry(r.Name, r.Content, "cloudflare"))
            .ToList();

        return Result.Success<IReadOnlyList<DnsEntry>>(entries);
    }
}

The provider:

  • Reads the API token from ISecretStore (the user is expected to store it under CLOUDFLARE_API_TOKEN via homelab secret set)
  • Calls the typed API client for each operation
  • Publishes the same DnsEntryAdded / DnsEntryRemoved events as the built-in providers
  • Returns Result<T> everywhere

Step 5: The manifest

{
  "$schema": "https://frenchexdev.lab/schemas/homelab-plugin.schema.json",
  "name": "FrenchExDev.HomeLab.Plugin.Cloudflare",
  "version": "1.0.0",
  "description": "Cloudflare DNS provider for HomeLab",
  "homelabApiVersion": "^1.0",
  "entryPoint": "FrenchExDev.HomeLab.Plugin.Cloudflare.CloudflarePlugin",
  "provides": {
    "dnsProviders": ["cloudflare"]
  },
  "requires": {
    "secrets": ["CLOUDFLARE_API_TOKEN"],
    "config": ["cloudflare.zone_id"]
  }
}

The manifest is a JSON file that ships in the NuGet package alongside the DLL. HomeLab's PluginHost reads it at discovery time and validates it against the schema.


Step 6: The csproj wiring

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PackageId>FrenchExDev.HomeLab.Plugin.Cloudflare</PackageId>
    <Version>1.0.0</Version>
    <Authors>FrenchExDev</Authors>
    <Description>Cloudflare DNS provider for HomeLab</Description>
    <PackageTags>homelab plugin dns cloudflare</PackageTags>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="FrenchExDev.Net.HomeLab.PluginSdk" Version="1.*" />
    <PackageReference Include="FrenchExDev.Net.Injectable"        Version="1.*" />
    <PackageReference Include="FrenchExDev.Net.HttpClient"        Version="1.*" />
    <PackageReference Include="FrenchExDev.Net.Result"            Version="1.*" />
  </ItemGroup>

  <ItemGroup>
    <None Include="homelab.plugin.json" Pack="true" PackagePath="content" />
  </ItemGroup>
</Project>

The <None Include="homelab.plugin.json" Pack="true" PackagePath="content"> puts the manifest into the NuGet package's content/ directory, where HomeLab's plugin host knows to look for it.


Step 7: The test harness

The plugin SDK ships a PluginTestHost that builds a minimal HomeLab DI container with the plugin loaded:

// CloudflareDnsProviderTests.cs

using FrenchExDev.Net.HomeLab.PluginSdk.Testing;
using FluentAssertions;
using Xunit;

namespace FrenchExDev.HomeLab.Plugin.Cloudflare.Tests;

public sealed class CloudflareDnsProviderTests
{
    [Fact]
    public async Task add_record_calls_cloudflare_api_with_correct_payload()
    {
        var api = new ScriptedCloudflareApi();
        api.OnCreate(zoneId: "test-zone", name: "gitlab.frenchexdev.lab", ip: "1.2.3.4",
                     returnRecordId: "rec-1");

        var secrets = new InMemorySecretStore();
        await secrets.WriteAsync("CLOUDFLARE_API_TOKEN", "fake-token", default);

        using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin(),
            services =>
            {
                services.Replace(ServiceDescriptor.Singleton<ICloudflareApi>(api));
                services.Replace(ServiceDescriptor.Singleton<ISecretStore>(secrets));
                services.Configure<CloudflareConfig>(c => c.ZoneId = "test-zone");
            });

        var provider = host.GetService<IDnsProvider>();
        provider.Name.Should().Be("cloudflare");

        var result = await provider.AddAsync("gitlab.frenchexdev.lab", "1.2.3.4", default);

        result.IsSuccess.Should().BeTrue();
        api.Calls.Should().ContainSingle(c =>
            c.Method == "Create" &&
            c.Name == "gitlab.frenchexdev.lab" &&
            c.Content == "1.2.3.4");
    }

    [Fact]
    public async Task missing_api_token_returns_failure()
    {
        using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin(),
            services =>
            {
                services.Replace(ServiceDescriptor.Singleton<ICloudflareApi>(new ScriptedCloudflareApi()));
                services.Replace(ServiceDescriptor.Singleton<ISecretStore>(new InMemorySecretStore()));
                services.Configure<CloudflareConfig>(c => c.ZoneId = "z");
            });

        var provider = host.GetService<IDnsProvider>();
        var result = await provider.AddAsync("x.lab", "1.2.3.4", default);

        result.IsFailure.Should().BeTrue();
        result.Errors.Should().Contain(e => e.Contains("CLOUDFLARE_API_TOKEN"));
    }

    [Fact]
    public void plugin_initializes_without_error_against_minimal_context()
    {
        using var host = PluginTestHost.NewWith(plugin: new CloudflarePlugin());
        host.Should().NotBeNull();
    }
}

The PluginTestHost wraps a ServiceCollection that has AddHomeLab() (via the SDK), the plugin's [Injectable] types registered, and a stub IPluginContext. The plugin author writes their tests against this host without spinning up a real lab.


Step 8: Pack and publish

$ dotnet pack -c Release -o ./out
$ dotnet nuget push out/FrenchExDev.HomeLab.Plugin.Cloudflare.1.0.0.nupkg \
    --source https://baget.frenchexdev.lab/v3/index.json \
    --api-key $BAGET_API_KEY

The plugin is on the feed. Any HomeLab user can now install it:

$ dotnet add package FrenchExDev.HomeLab.Plugin.Cloudflare --source https://baget.frenchexdev.lab/v3/index.json

And use it:

# config-homelab.yaml
plugins:
  - FrenchExDev.HomeLab.Plugin.Cloudflare

dns:
  provider: cloudflare
  cloudflare:
    zone_id: abc123def456789
$ homelab secret set CLOUDFLARE_API_TOKEN
Enter value: ************

$ homelab dns add gitlab.frenchexdev.lab 1.2.3.4
✓ created Cloudflare A record gitlab.frenchexdev.lab → 1.2.3.4

The plugin is in the dependency graph. HomeLab discovered it. Its IDnsProvider is registered. The user picked it via config. Everything just works.


What you get for the afternoon's work

  • One plugin entry point class (~10 lines)
  • One typed API client interface (~30 lines, the rest is generated)
  • One IDnsProvider implementation (~80 lines)
  • One JSON manifest (~15 lines)
  • One csproj (~25 lines)
  • One test class (~50 lines)

That is the entire plugin: ~210 lines of code, zero of which are wiring (the source generators and DI handle it). Compare to forking HomeLab and adding the same capability in-tree, which would require touching the build, the composition root, the test suite, and the docs — and would be merged once, by one team, and never benefit anyone else.


What this gives you that bash doesn't

A bash script does not have plugins. Adding a new DNS provider to a bash homelab is a fork or a wrapper script that grows new flags every quarter.

A typed plugin SDK with [Injectable] registration, a JSON manifest, and a test harness gives you, for the same surface area:

  • An afternoon project to add a new capability to HomeLab
  • NuGet distribution so the plugin is shareable
  • A test harness that builds a minimal HomeLab DI container
  • Architecture compliance (the plugin uses Result<T>, [Injectable], the event bus — same as the core)
  • Zero forking of HomeLab itself

The bargain pays back the first time you ship a plugin and someone else uses it without ever knowing your name.


⬇ Download