Patterns and Conventions Found

Null-guard convention in this codebase: if (x is null) throw new ArgumentNullException(nameof(x)); inline at all public method entries. osisa.Validation and Precondition.NotNull are NOT present in NETSetup.csproj and NOT used anywhere in the existing code. See EnsureCustomerVms.cs:110-112, ProvisionNixosVm.cs:145-149.

Static orchestrator + delegate-seam pattern: all provisioning orchestrators are static classes with a production Run(…​) method that wires real deps and a testable RunCore(…​) method that receives delegates. Source: ProvisionNixosVm.cs:144 (Run) and ProvisionNixosVm.cs:311 (RunCore). Mirror exactly.

OS constant routing: NetsetupOS.cs defines OperatingSystem.From("NixNextcloud", OperatingSystemFamily.Linux_GNU) etc. NixOsExtensions.IsNixBacked at src/NETSetup/Extensions/NixOsExtensions.cs:23 returns true for any OS name starting with "Nix". New OS names NixMail and NixDns satisfy IsNixBacked automatically. PmgLxc does NOT satisfy it (intentional: routed by separate EnsureCustomerLxcs).

pct execution: pct is a Proxmox host-local binary at /usr/sbin/pct. On production (running on Proxmox host), use LinuxCommandBase.Builder("/usr/sbin/pct") same as pvesm at UpdateMethods.cs:756. In tests, use proxmox.Execute(PveCommand.From("pct list")) over SSH.

FileOperator write pattern: see WriteAbsoluteFile private method at UpdateMethods.cs:772. Use PhysicalFileOperatorWorkaround.Create(dir, logger.As<PhysicalFileOperator>()).WriteAllTextAsync(name, content, default).Wait() for absolute-path writes. Use IFileOperatorExtensions.EnsureDirectory(dir) for directory creation (see ProvisionNixosVm.cs:279).

File Map (New and Modified)

Full path New/Mod What

src/NETSetup/Config/Nix/NetsetupOS.cs

Modified

Add NixMail, NixDns, PmgLxc OS constants after NixDocker (line 23)

src/NETSetup/Entities/Mail/MailRelayOptions.cs

New

Sealed record: Host string, Port int (default 587), User string, PassFile AbsolutePath

src/NETSetup/Entities/Dns/DnsProviderOptions.cs

New

Sealed record: Provider string, CredentialsFile AbsolutePath, CredentialsAttrs IReadOnlyDictionary<string,string>?

src/NETSetup/Entities/NETSetupConfig.cs

Modified

Add string? CustomerDomain, MailRelayOptions? MailRelay, DnsProviderOptions? DnsProvider properties

src/NETSetup/NETCommand/LxcCommand.cs

New

Builder for /usr/sbin/pct argv; create/start/stop/destroy/exec/set/status subcommands; AddNamed for all param-value pairs

src/NETSetup/Config/Nix/Templates/NixMailTemplate.cs

New

NixHostTemplate subclass for NixMail OS; GenerateDefaultNix emits postfix+dovecot+roundcube NixOS options

src/NETSetup/Config/Nix/Templates/NixDnsTemplate.cs

New

NixHostTemplate subclass for NixDns OS; GenerateDefaultNix emits coredns+step-ca+lego+ddns-updater NixOS options; consumes DnsProviderOptions

src/NETSetup/Config/Nix/Templates/NixTemplateFactory.cs

Modified

Add NixMail/NixDns branches at line 56; add ForMachine(Machine, NETSetupConfig) overload; wire domain, relay, dns provider onto templates

src/NETSetup/Stages/5-NETSetupLinux/ProvisionNixosVm.cs

Modified

EmitConfigBundle signature: add NETSetupConfig param; pass to ForMachine overload at line 284

src/NETSetup/Stages/5-NETSetupLinux/ProvisionPmgLxc.cs

New

Static orchestrator: ensure Debian-12 LXC template, pct create, pct start, pct exec install PMG, configure relay

src/NETSetup/Helpers/EnsureCustomerLxcs.cs

New

Mirror of EnsureCustomerVms; classifies PmgLxc machines; dispatches to ProvisionPmgLxc; per-LXC fault isolation

src/NETSetup/Helpers/UpdateMethods.cs

Modified

EnsureProxmoxPostInstall: append EnsurePbsInstalled after EnsureLocalStorageEnabled (line 744). RunProxmoxUpdate: add EnsureCustomerLxcs.Run after EnsureCustomerVms.Run (after line 425)

src/NETSetup/nixos/modules/services/mail.nix

Already exists (backend-nix)

Single mail module emitting postfix+dovecot+roundcube; sub-options under host.mail.*

src/NETSetup/nixos/modules/services/dns.nix

Already exists (backend-nix)

Single dns module emitting coredns+step-ca+acme(lego)+ddns-updater; sub-options under host.dns.*

src/NETSetup/nixos/hosts/default-mail/default.nix

Already exists (backend-nix)

Host template enabling host.mail.*; consumed by NixMailTemplate emit

src/NETSetup/nixos/hosts/default-dns/default.nix

Already exists (backend-nix)

Host template enabling host.dns.*; consumed by NixDnsTemplate emit

src/NETSetup.Tests/TestInfrastructure/TestCompany.cs

Modified

Add mail@lib (102@lib), dns@lib (103@lib), pmg@lib (200@lib) devices after LibWin11 entry (line 43)

src/NETSetup.Tests/ConfigTests/NixMailTemplateTests.cs

New

Unit tests: GenerateDefaultNix with Relay=null (no relayhost), with Relay set (relayhost emitted)

src/NETSetup.Tests/ConfigTests/NixDnsTemplateTests.cs

New

Unit tests: GenerateDefaultNix emits coredns, step-ca, lego, ddns-updater blocks with correct domain + provider

src/NETSetup.Tests/StageTests/Stage5/ProvisionPmgLxcTests.cs

New

Unit tests: pct create arg verification, skip path when lxcExists=true, exec script content check

src/NETSetup.Tests/HelperTests/EnsureCustomerLxcsTests.cs

New

Unit tests: Classify returns Pmg for PmgLxc OS, Skip for others; fault isolation; aggregate throw

src/NETSetup.Tests/VirtualTests/OnHyperV/ProxmoxPublicMailTests.cs

New

E2E test class [Ignore("Local Tests")], Liberator() method; phases: PBS check, LXC created, mail VM services, DNS VM services, PMG running, webmail HTTPS probe

New Types

LxcCommand and Builder Family

File: src/NETSetup/NETCommand/LxcCommand.cs

Namespace: NETSetup.NETCommand

LxcCommand is NOT a subclass of LinuxCommandBase or CliWrapCommandBase. It is a composite command object whose Builder produces an argv list for the /usr/sbin/pct binary. The ExecuteLocal() method assembles a LinuxCommandBase.Builder("/usr/sbin/pct") from the stored argv, identical to how pvesm is driven at UpdateMethods.cs:756. The ToArgString() method produces a shell-safe single string for passing to proxmox.Execute(PveCommand.From(…​)) in tests.

public sealed class LxcCommand
{
    internal LxcCommand(string subcommand, IReadOnlyList<string> argv)

    public string Subcommand { get; }
    public IReadOnlyList<string> Argv { get; }

    public string ToArgString()
    public ICommandResult ExecuteLocal(TimeSpan? timeout = null)
}

public sealed class Builder
{
    public static Builder Create()

    // Subcommand selection (one must be called):
    public Builder WithCreate(int ctid, string templatePath)
    public Builder WithStart(int ctid)
    public Builder WithStop(int ctid)
    public Builder WithDestroy(int ctid)
    public Builder WithExec(int ctid, string shellCmd)
    public Builder WithSet(int ctid)
    public Builder WithStatus(int ctid)

    // Option methods (AddNamed enforced - never raw Add for param+value):
    public Builder WithHostname(string hostname)
    public Builder WithMemory(int mb)
    public Builder WithCores(int n)
    public Builder WithRootfs(string storage, int diskGb)
    public Builder WithNet(int id, string bridge, string ip)
    public Builder WithFeatures(string features)
    public Builder WithUnprivileged(bool yes)
    public Builder WithOsType(string type)
    public Builder WithSshKey(AbsolutePath keyFile)
    public Builder WithPassword(string pw)

    public LxcCommand Build()
}

Forbidden in Builder: _argv.Add($"--hostname {hostname}"). Always two tokens: _argv.Add("--hostname"); _argv.Add(hostname).

ProvisionPmgLxc

File: src/NETSetup/Stages/5-NETSetupLinux/ProvisionPmgLxc.cs

Namespace: NETSetup.Stages._5_NETSetupLinux

Mirrors ProvisionNixosVm (static class, Run + RunCore, delegate seams).

public sealed class ProvisionPmgLxcResult
{
    public bool SkippedExisting { get; init; }
    public int ContainerId { get; init; }
}

public static class ProvisionPmgLxc
{
    public const string DebianTemplateName = "debian-12-standard_12.7-1_amd64.tar.zst";
    public static readonly AbsolutePath LxcTemplateCacheDir =
        (AbsolutePath)"/var/lib/vz/template/cache";

    public static ProvisionPmgLxcResult Run(
        Host proxmoxHost, Machine machine, NETSetupConfig config, ISystemHost sysHost)

    public static ProvisionPmgLxcResult RunCore(
        Machine machine,
        string customerDomain,
        Func<bool> lxcExists,
        Action<int, string> ensureTemplate,
        Action<int, string> createLxc,
        Action<int> startLxc,
        Func<int, string, string> execInLxc,
        ILogger logger)
}

RunCore sequence:

  1. ExtractCtid(machine) - mirror ProvisionNixosVm.ExtractVmId at ProvisionNixosVm.cs:362. Parse machine.SerialNumber.HostedId as int. Throw NETSetupException if not numeric.

  2. lxcExists() check. If true, log skip and return SkippedExisting=true.

  3. ensureTemplate(ctid, LxcTemplateCacheDir / DebianTemplateName). Production: check (LxcTemplateCacheDir / DebianTemplateName).FileExists(). If missing, run pveam update && pveam download local debian-12-standard via LinuxCommandBase.Builder("/usr/sbin/pveam").

  4. createLxc(ctid, templatePath). Production: LxcCommand.Builder.Create().WithCreate(ctid, templatePath).WithHostname(machine.MachineName.Value).WithMemory(2048).WithCores(2).WithRootfs(diskStorage, 8).WithNet(0, "vmbr0", "dhcp").WithFeatures("nesting=1").WithUnprivileged(true).WithOsType("debian").Build().ExecuteLocal(TimeSpan.FromMinutes(5)).

  5. startLxc(ctid). Then poll pct status <ctid> until "running" (60 s, 5 s sleep).

  6. execInLxc(ctid, installScript). Script: wait-for-network ping loop, add PMG apt key+repo, apt-get update, apt-get install -y proxmox-mailgateway, write basic relay config to /etc/pmg/main.cf pointing to mail.<customerDomain>:26. Timeout 10 min.

  7. Return ProvisionPmgLxcResult with ContainerId.

Error handling: each ICommandResult.IsSuccess checked; throw NETSetupException($"step X failed: {result.StandardError}") on failure.

EnsureCustomerLxcs

File: src/NETSetup/Helpers/EnsureCustomerLxcs.cs

Namespace: NETSetup.Helpers

Direct mirror of EnsureCustomerVms.cs.

public enum LxcProvisionKind { Skip, Pmg }

public sealed class LxcProvisionOutcome
{
    public string LxcName { get; init; } = string.Empty;
    public LxcProvisionKind Kind { get; init; }
    public bool Success { get; init; }
    public string? Error { get; init; }
}

public sealed class EnsureCustomerLxcsResult
{
    public IReadOnlyList<LxcProvisionOutcome> Outcomes { get; init; } = Array.Empty<LxcProvisionOutcome>();
}

public static class EnsureCustomerLxcs
{
    public static LxcProvisionKind Classify(Machine machine)
    public static IReadOnlyList<Machine> GetOrderedLxcsForHost(NETSetupConfig config, SerialNumber hostSerial)
    public static void Run(ISystemHost host, NETSetupConfig config, IServiceProvider services)
    public static EnsureCustomerLxcsResult RunCore(
        IReadOnlyList<Machine> orderedLxcs,
        Action<Machine> provisionPmg,
        ILogger logger)
}

Per-LXC try/catch, accumulate failures, throw aggregate NETSetupException at end (mirror EnsureCustomerVms.cs:225).

EnsurePbsInstalled

Private static method in UpdateMethods.cs, called from EnsureProxmoxPostInstall after EnsureLocalStorageEnabled(host) at line 744.

Signature: private static void EnsurePbsInstalled(ISystemHost host)

Sequence:

  1. Idempotency: dpkg -l proxmox-backup-server | grep '^ii'. If matched, log and return.

  2. Install: RunAptWithRetry(host, "apt install proxmox-backup-server", () ⇒ new LinuxCommandBase.Builder("/usr/bin/env").Add("DEBIAN_FRONTEND=noninteractive").Add("apt-get").Add("install").Add("-y").Add("--no-install-recommends").Add("proxmox-backup-server").Build()).

  3. Enable service: systemctl enable --now proxmox-backup. Log warning on failure (non-fatal).

  4. Datastore: check ((AbsolutePath)"/var/lib/proxmox-backup/datastore").DirectoryExists(). If false: proxmox-backup-manager datastore create datastore /var/lib/proxmox-backup/datastore. Throw on failure.

  5. Log "PBS installed and datastore configured."

MailRelayOptions

File: src/NETSetup/Entities/Mail/MailRelayOptions.cs

Namespace: NETSetup.Entities.Mail

// osisa copyright header
using Nuke.Common.IO;

namespace NETSetup.Entities.Mail;

public sealed record MailRelayOptions
{
    public string Host { get; init; } = string.Empty;
    public int Port { get; init; } = 587;
    public string User { get; init; } = string.Empty;
    public AbsolutePath PassFile { get; init; } = (AbsolutePath)"/run/secrets/relay-pass";
}

PassFile is a path to a SOPS-encrypted secret on the mail VM; NETSetup never reads its content.

DnsProviderOptions

File: src/NETSetup/Entities/Dns/DnsProviderOptions.cs

Namespace: NETSetup.Entities.Dns

// osisa copyright header
using System.Collections.Generic;
using Nuke.Common.IO;

namespace NETSetup.Entities.Dns;

public sealed record DnsProviderOptions
{
    // Provider name. MUST match a string supported by both lego (security.acme dnsProvider)
    // AND services.ddns-updater. Examples: "cloudflare", "duckdns", "porkbun", "namecheap",
    // "gandi", "hetzner", "godaddy", "ovh", "dyn", "noip".
    public string Provider { get; init; } = string.Empty;

    // Env-format file (KEY=value lines) decrypted by SOPS at runtime. Provider-specific
    // env-var contract per lego docs. Examples:
    //   cloudflare: CF_DNS_API_TOKEN=...
    //   duckdns:    DUCKDNS_TOKEN=...
    //   namecheap:  NAMECHEAP_API_USER=... NAMECHEAP_API_KEY=...
    public AbsolutePath CredentialsFile { get; init; } =
        (AbsolutePath)"/run/secrets/dns-provider-credentials";

    // Provider-specific extras not covered by env file (account IDs, zone IDs, etc.).
    // Passed verbatim into the ddns-updater settings attrset.
    public IReadOnlyDictionary<string, string>? CredentialsAttrs { get; init; }
}

MailHostTemplate (NixMailTemplate)

File: src/NETSetup/Config/Nix/Templates/NixMailTemplate.cs

Namespace: NETSetup.Config.Nix.Templates

public sealed class NixMailTemplate : NixHostTemplate
{
    public string CustomerDomain { get; set; } = string.Empty;
    public MailRelayOptions? Relay { get; set; }
    public string PmgLanIp { get; set; } = string.Empty;

    public override string GenerateDefaultNix() { ... }
}

GenerateDefaultNix() emits:

  • All base system options (packages, nix, gc, shell, user, timezone, qemuGuest, network, disk, security, ssh, authorizedKeys, hostname) - copy from NixNextcloudTemplate

  • host.mail.enable = true;

  • host.mail.domain = "<CustomerDomain>";

  • host.mail.relay = …​ (conditional: null when Relay is null, else { host = …​; port = …​; user = …​; passFile = …​; })

  • networking.firewall.allowedTCPPorts = [ 25 26 80 143 443 587 993 ];

  • system.stateVersion = "25.11";

Conditional relay block:

var relayBlock = this.Relay is null
    ? "null"
    : $$"""
        {
            host = "{{this.Relay.Host}}";
            port = {{this.Relay.Port}};
            user = "{{this.Relay.User}}";
            passFile = "{{this.Relay.PassFile}}";
          }
        """;

Guard at method entry: if (string.IsNullOrWhiteSpace(this.CustomerDomain)) throw new NETSetupException("NixMailTemplate.CustomerDomain is required.");

DnsHostTemplate (NixDnsTemplate)

File: src/NETSetup/Config/Nix/Templates/NixDnsTemplate.cs

Namespace: NETSetup.Config.Nix.Templates

public sealed class NixDnsTemplate : NixHostTemplate
{
    public string CustomerDomain { get; set; } = string.Empty;
    public DnsProviderOptions? DnsProvider { get; set; }
    public string MailLanIp { get; set; } = string.Empty;
    public string PmgLanIp { get; set; } = string.Empty;
    public bool AcmeStaging { get; set; } = true;
    public string AcmeContactEmail { get; set; } = string.Empty;

    public override string GenerateDefaultNix() { ... }
}

GenerateDefaultNix() emits (matching host.dns.* option shape from nixos/modules/services/dns.nix):

  • All base system options

  • host.dns.enable = true;

  • host.dns.zone = "<CustomerDomain>";

  • host.dns.records = { mail = "<MailLanIp>"; pmg = "<PmgLanIp>"; mx = "<PmgLanIp>"; }; (use empty string when LAN IPs unset; backend-nix coredns module must handle empty)

  • host.dns.provider = "<DnsProvider.Provider>";

  • host.dns.credentialsFile = "<DnsProvider.CredentialsFile>";

  • host.dns.credentialsAttrs = { …​ }; from DnsProvider.CredentialsAttrs ({} when null)

  • host.dns.acmeStaging = <AcmeStaging>;

  • host.dns.acmeContactEmail = "<AcmeContactEmail>";

  • host.dns.publicHostnames = [ "mail.<CustomerDomain>" "pmg.<CustomerDomain>" ];

  • system.stateVersion = "25.11";

Guard: throw NETSetupException if CustomerDomain or DnsProvider null.

Modified Types

NetsetupOS.cs

Add three constants after NixDocker (line 23):

public static readonly OperatingSystem NixMail = OperatingSystem.From("NixMail", OperatingSystemFamily.Linux_GNU);
public static readonly OperatingSystem NixDns = OperatingSystem.From("NixDns", OperatingSystemFamily.Linux_GNU);
public static readonly OperatingSystem PmgLxc = OperatingSystem.From("PmgLxc", OperatingSystemFamily.Linux_GNU);

NixMail/NixDns names start with "Nix" so IsNixBacked returns true. PmgLxc does not, so EnsureCustomerVms.Classify returns Skip; only EnsureCustomerLxcs handles it.

NixTemplateFactory.cs

  1. Add two branches at line 56 before else if (os == OS.NixOS):

else if (os == NetsetupOS.NixMail)
{
    template = new NixMailTemplate();
}
else if (os == NetsetupOS.NixDns)
{
    template = new NixDnsTemplate();
}
  1. Add new public overload:

public static NixHostTemplate ForMachine(Machine machine, NETSetupConfig config)
{
    if (machine is null) throw new ArgumentNullException(nameof(machine));
    if (config is null) throw new ArgumentNullException(nameof(config));

    var template = ForMachine(machine);
    var customerDomain = config.CustomerDomain
        ?? config.DirectoryService?.Name?.Value
        ?? string.Empty;

    if (template is NixMailTemplate mailTemplate)
    {
        mailTemplate.CustomerDomain = customerDomain;
        mailTemplate.Relay = config.MailRelay;
    }
    else if (template is NixDnsTemplate dnsTemplate)
    {
        dnsTemplate.CustomerDomain = customerDomain;
        dnsTemplate.DnsProvider = config.DnsProvider;
        dnsTemplate.AcmeContactEmail = $"admin@{customerDomain}";
    }

    return template;
}
  1. Update ProvisionNixosVm.EmitConfigBundle at line 274/284 to accept NETSetupConfig config and pass to ForMachine(machine, config). Thread config through RunRunCoreemitConfigBundle delegate.

NETSetupConfig.cs

Add three properties:

public string? CustomerDomain { get; set; }
public MailRelayOptions? MailRelay { get; set; }
public DnsProviderOptions? DnsProvider { get; set; }

Add usings: using NETSetup.Entities.Mail; and using NETSetup.Entities.Dns;

TestCompany.cs

Insert after LibWin11 (line 43):

.AddDevice("mail@lib", DeviceTypes.Server)
.WithSerialNumber("102@lib")
.WithDescription("Mail")

.AddDevice("dns@lib", DeviceTypes.Server)
.WithSerialNumber("103@lib")
.WithDescription("Dns")

.AddDevice("pmg@lib", DeviceTypes.Server)
.WithSerialNumber("200@lib")
.WithDescription("PMG")

Backend agent must also update DeviceTranslator.cs to map "Mail" → NetsetupOS.NixMail, "Dns" → NetsetupOS.NixDns, "PMG" → NetsetupOS.PmgLxc. Search for "NixNextcloud" or "Nextcloud" in DeviceTranslator.cs to find the table.

The TestCompany.Create() builder must also set .WithCustomerDomain("test01.iam.free") and .WithDnsProvider(new DnsProviderOptions { Provider = "cloudflare", CredentialsFile = "/run/secrets/dns-provider-credentials" }) somewhere — if Company builder does not yet support these, set them on the resulting NETSetupConfig after Build() (e.g. in the test fixture wrapper). Backend agent: choose path based on existing builder shape.

EnsureProxmoxPostInstall (UpdateMethods.cs)

Insert at line 744 (after EnsureLocalStorageEnabled(host);):

EnsurePbsInstalled(host);

RunProxmoxUpdate (UpdateMethods.cs)

After EnsureCustomerVms.Run block (lines 416-425), before RunBtrfsScrub at line 427:

host.Logger.LogInformation("Ensuring customer LXCs (PMG)...");
try
{
    EnsureCustomerLxcs.Run(host, config, services);
}
catch (Exception ex)
{
    host.Logger.LogError(ex, "EnsureCustomerLxcs failed during RunProxmoxUpdate.");
    throw;
}

Ordering: PMG relay points to mail VM; mail VM is provisioned by EnsureCustomerVms (NixMail). So LXCs MUST run AFTER VMs.

DI Wiring

No new DI registrations. EnsureCustomerLxcs.Run uses the same IServiceProvider already wired by the CLI host. MailRelayOptions and DnsProviderOptions are POCOs on NETSetupConfig; no injection.

Conventions Checklist

  • ❏ Null guards: if (x is null) throw new ArgumentNullException(nameof(x)); (NOT Precondition.NotNull, NOT in codebase).

  • is null / is not null everywhere. Never == null / != null.

  • ❏ Always braces on if/else/for/foreach/while/using.

  • ❏ File-scoped namespaces.

  • ❏ One class per file. Generic types: [T] in filename.

  • ❏ osisa copyright header on every .cs file. Copy from ProvisionNixosVm.cs:1-5.

  • ❏ No System.IO: use AbsolutePath / IFileOperatorExtensions.EnsureDirectory / PhysicalFileOperatorWorkaround.

  • ❏ No raw ProcessStartInfo. All process via LinuxCommandBase.Builder or LxcCommand.Builder.

  • LxcCommand.Builder: never Add($"--flag {value}"). Always two argv tokens.

  • ❏ No new .Result / .Wait() introductions; mirror existing sync-over-async pattern.

  • osisa.Validation NOT added. ArgumentNullException directly.

  • ❏ ASCII only in all new files.

Decisions Made

  1. PmgLxc OS constant for LXC routing (not Description string match).

  2. MailRelayOptions + DnsProviderOptions in NETSetup.Entities.{Mail,Dns} (not osisa.Enterprise.Entities; cannot extend external package without netbase PR).

  3. CustomerDomain as nullable override on NETSetupConfig (falls back to DirectoryService.Name.Value).

  4. ForMachine(Machine, NETSetupConfig) overload required; EmitConfigBundle signature must add NETSetupConfig param.

  5. EnsureCustomerLxcs.Run after EnsureCustomerVms.Run (mail VM must exist before PMG relay config references it).

  6. LxcCommand.Builder produces argv list; ExecuteLocal() uses LinuxCommandBase; no ProcessStartInfo.

  7. SSH-push cert distribution from DNS VM to mail/PMG (NixOS-side concern; no C# code).

  8. VMID/CTID assignments: mail=102@lib, dns=103@lib, pmg=200@lib.

  9. Precondition.NotNull NOT used; raw ArgumentNullException.

  10. DDNS + ACME provider pluggable per-customer via DnsProviderOptions. Cloudflare = example, NOT hardcoded.

Open Questions for Backend Agent

  1. proxmox-backup-manager datastore create exact CLI arg syntax; confirm on live PBS.

  2. LXC network bridge name on Liberator (assumed vmbr0; confirm via pvesh get /nodes/pve/network).

  3. PMG apt repo URL for Proxmox 9 / trixie if Liberator is trixie-based.

  4. coredns module behavior for empty MailLanIp/PmgLanIp; module must handle empty gracefully.

  5. PMG relay config file path and syntax (/etc/pmg/main.cf vs pmgconfig set).

  6. lego vs ddns-updater provider name string parity; verify per provider before shipping.

  7. ddns-updater credential transport: env-file vs inline JSON. Backend-Nix flagged: cloudflare-style providers work via env file; others may need SOPS-templated JSON. Out-of-scope for v1 if cloudflare is the test provider.

Build Order for Backend Agent

  1. Add MailRelayOptions + DnsProviderOptions records.

  2. Add CustomerDomain, MailRelay, DnsProvider properties to NETSetupConfig.

  3. Add NetsetupOS.NixMail, NixDns, PmgLxc constants.

  4. Update DeviceTranslator.cs Description-to-OS map.

  5. Add LxcCommand + LxcCommand.Builder.

  6. Add NixMailTemplate.cs and NixDnsTemplate.cs.

  7. Add ForMachine(Machine, NETSetupConfig) overload + NixMail/NixDns branches.

  8. Update ProvisionNixosVm.EmitConfigBundle signature; thread config through Run/RunCore.

  9. Add ProvisionPmgLxc.cs.

  10. Add EnsureCustomerLxcs.cs.

  11. Add EnsurePbsInstalled private method to UpdateMethods.cs. Wire into EnsureProxmoxPostInstall.

  12. Wire EnsureCustomerLxcs.Run into RunProxmoxUpdate.

  13. Update TestCompany.cs: add 3 hosted devices + customer domain + dns provider.

  14. Run dotnet build src/NETSetup.sln and fix any build errors.

Notes on Pre-existing NixOS Modules

Backend-Nix already created the following nix files (do not recreate):

  • src/NETSetup/nixos/modules/services/mail.nix — options: host.mail.{enable,domain,tlsCertPath,tlsKeyPath,user,relay,dnsServer,pmgSourceCidr}

  • src/NETSetup/nixos/modules/services/dns.nix — options: host.dns.{enable,zone,records,provider,credentialsFile,credentialsAttrs,acmeStaging,acmeContactEmail,publicHostnames}

  • src/NETSetup/nixos/hosts/default-mail/default.nix

  • src/NETSetup/nixos/hosts/default-dns/default.nix

The C# templates NixMailTemplate.GenerateDefaultNix and NixDnsTemplate.GenerateDefaultNix MUST emit option names that match these existing modules exactly. Verify by reading the actual nix files before writing the C# string interpolation.