NETSetup

Version: 0.9.0.368

Branch: develop

Release Date: 05/16/2026 03:09:36

Install (Windows) — Chocolatey

Primary install path on Windows. Use this unless you have a reason not to.

choco source add --name=netsetup --source=http://netsetupprod.osisa.com:8080/chocolatey
choco install netsetup -y

Upgrade an existing installation:

choco upgrade netsetup -y

Direct download (advanced / fallback)

For environments without Chocolatey or for building an install ISO locally:

NETSetup install ISO is no longer published as a release asset. To build it: download NETSetup.exe above, run NETSetup.exe create-iso (see Usage below). Flash resulting NETSetup.iso to USB.

Purpose

NETSetup allows users to automatically (or automagically) setup and configure any number of machines (servers, client computers, laptops) in a given organisation from OS installation, to server configuration, to domain joining of client computers, to software installation, all according to a given NETSetup configuration file.

In other words: NETSetup translates an enterprise structure to a network configuration and sets up the network accordingly.

In a future revision a new machine will receive its NETSetup automatically during its OOBE experience ("Just plug it in!"™).

Step by Step Process

@startuml

'Actors
actor Customer
actor Agent as "Agent (public)"
actor Ticketing as "Ticketing (public)"
actor Accounting as "Accounting (private)"
actor NETSetup as "NETSetup (private)"
actor Supplier as "Supplier (Alltron)"
actor Provider as "Provider (Dropbox)"
actor Computer
actor SWVendor as "Software Vendor (Microsoft)"
actor HWVendor as "Hardware Vendor (HP)"

'Contact
Customer -> Agent: [[https://github.com/osisa/NETSetup/blob/feature/proxmoxvms/docs/NETSetup/050%20Kontaktaufnahme/Kontaktaufnahme%20V1.1.adoc callAgent() -> new order]]
Agent -> Ticketing: [[https://github.com/osisa/NETSetup/blob/feature/proxmoxvms/docs/NETSetup/050%20Kontaktaufnahme/Ticketverwaltung%20in%20Freshdesk.adoc createNETSetupTicket(order) -> new ticket]]
Ticketing -> Customer: notifyCustomer(ticket)

'Delta
Agent -> Customer: [[https://github.com/osisa/NETSetup/blob/feature/proxmoxvms/docs/NETSetup/100%20Offerte/Offertprozess.adoc#ist-soll-analyse requestCurrentMode(ticket)]] -> ensureConfig(ticket.customer.currentMode)[[https://github.com/osisa/NETSetup/blob/feature/proxmoxvms/docs/Templates/TemplateCompany.adoc Template Company]] [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup.Tests/TestInfrastructure/OfflineCompany.cs Example Code]]
Customer -> Agent: responseCurrentMode(ticket) -> updateConfig(updatedCurrentMode)
Agent -> Customer: requestFutureMode(updatedCurrentMode.config)
Customer -> Agent: responseFutureMode(updatedCurrentMode.config) -> futureMode.config

'Quote
Agent -> Accounting: [[https://github.com/osisa/NETSetup/blob/feature/proxmoxvms/docs/NETSetup/100%20Offerte/Offertprozess.adoc#offerterstellung createQuote(updatedCurrentMode.config, futureMode.config) -> new quote]]
Accounting -> Agent: returnQuote(quote)
Agent -> Customer: sendQuote(quote)
Customer -> Agent: responseQuote(quote)

'Config
Agent -> Supplier: [[https://github.com/osisa/NETSetup/blob/feature/proxmoxvms/docs/NETSetup/230%20Beschaffung/Beschaffung.adoc createOrder(quote) -> new order]]
Supplier -> Agent: return order.Success
Agent -> NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup.Tests/Customers/TranslateCompanyToConfig.cs createConfigs(ticket, futureMode.config)]] -> new config[]
note over Agent, NETSetup: someConfig[] = { {HASTAG}ticket.json, ... }
NETSetup -> Provider: [[https://www.dropbox.com/home/NETSetup/BOOT/Config save config() ]]
Provider -> NETSetup: return save.Success
NETSetup -> Agent: return save.Success

newpage Old Prerequisite Steps
Supplier -> Agent: return order.HardwareHashes
Agent -> NETSetup: TODO: updateConfigs(ticket, hardwareHashes)
NETSetup -> Provider: TODO: updateConfigs(ticket, hardwareHashes)
Provider -> NETSetup: return update.Success
NETSetup -> Agent: return update.Success
Supplier -> Customer: [[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/300%20Auslieferung/Auslieferung.adoc deliverOrder(setup)]]
Agent -> NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/input/NETSetupInstallUSB.adoc#NS-INSTALL-USB-CREATE-STICK Create NETSetup USB Stick]]
NETSetup -> Agent: NETSetup ready USB Stick

'Installation
loop For every computer
    Agent -> Computer: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/input/NETSetupInstall.adoc#NS-INSTALL-USB-USE-STICK plugIn(Power, USB Stick, Display, Keyboard)]]
    Agent -> Computer: turnOn()
    Agent -> Computer: hold(F9 or other key to get into Boot Menu)
    Agent -> Computer: select(USB Stick)

newpage NETSetup Setup Process - Boot Phase 1: USB Boot into WinPE

    == PHASE 1: UEFI Firmware + WinPE (runs from USB stick) ==
    note over Computer: UEFI firmware is running.\n**Proxmox installs require TWO USB sticks PRE-FLASHED WITH RUFUS (each >= 16 GB):**\n  USB #1 (NETSetup/WinPE, Rufus: Partitionsschema GPT, Zielsystem UEFI, NTFS):\n    Partition 1: FAT32 ESP — Windows Boot Manager + WinPE boot files\n    Partition 2: NTFS (label=DATA) — WinPE boot.wim, NETSetup\n  USB #2 (Proxmox installer, Rufus: DD image mode from proxmox.iso):\n    Unmodified Proxmox hybrid ISO layout (ESP + iso9660).\n    NETSetup does NOT touch USB #2 — it is booted as-is by the agent\n    after WinPE finishes writing the answer.toml to USB #1.\n**Agent boots USB #1 first; after WinPE prep + reboot, agent boots USB #2.**
    Computer -> Computer: UEFI firmware loads Windows Boot Manager from USB ESP (Secure Boot OK)
    alt Can boot NTFS Partition
        Computer -> Computer: Boots WinPE from NTFS Partition on USB
    else Lacks NTFS Driver
        Computer -> Computer: Boots FAT32 NTFS Driver from USB
        Computer -> Computer: Boots WinPE from NTFS Partition on USB
    end
    note over Computer: WinPE is now running from USB RAM disk.\nProcess: startnet.cmd -> autoexecute.ps1 -> NETSetup.exe
    Computer -> Computer: WinPE runs startnet.cmd
    Computer -> Computer: startnet.cmd searches every drive for autoexecute.ps1
    Computer -> Computer: cd to drive and run autoexecute.ps1
    Computer -> NETSetup: autoexecute.ps1 runs [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/CLI/Commands/InstallCommand.cs NETSetup.exe install]]
    note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/CLI/Commands/InstallCommand.cs InstallCommand.cs]]\nhost = NETSetup.GetSystemHost()\nhost.OperatingSystem, host.ExitIfNotElevated()\nconfig = ConfigMethods.GetConfig(host, services, ticket, hostname)\nBranches on host.IsOnWinPE() -> [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/2-WinPE/MainWinPE.cs MainWinPE.InstallOperatingSystem(cli, config, host)]]
    NETSetup -> Computer: host = GetSystemHost() — ISystemHost with Hardware, Logger, OperatingSystem
    NETSetup -> Computer: logs NETSetup Version, host.OperatingSystem
    NETSetup -> Computer: host.ExitIfNotElevated()
    NETSetup -> Computer: config = ConfigMethods.GetConfig(host, services, ticket, hostname)
    opt ConfigMethods.GetConfig()
        NETSetup -> Computer: host.Hardware.SerialNumber
        NETSetup -> Computer: online = CheckOnline(timeout 10s)
        alt online
            NETSetup -> Computer: check for online map file
            note over Agent, NETSetup: foreach machine in config a map file. Nomenclature: SerialNumber#Ticket.json
            NETSetup -> Computer: download map file
            NETSetup -> Computer: check for single matching map file
        end
        NETSetup -> Computer: if no map file found online -> check local map files as fallback (covers USB stick with embedded map file)
        NETSetup -> Computer: if no disks or not online -> try to install drivers
        NETSetup -> Computer: if nothing is found, but there are configs locally -> UI select which
    end

    NETSetup -> Computer: computer = config.IdentifyHost(host).CastTo<Computer>()

    alt operatingSystem is Windows (target)

newpage NETSetup Setup Process - Windows Install Path

        == PHASE 1 continued: WinPE prepares Windows installation (DISM /Apply-Image) ==
        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/2-WinPE/MainWinPE.cs MainWinPE]] Windows branch\nsetup.exe replaced with DISM /Apply-Image (setup.exe fails on Win11 25H2).
        NETSetup -> Computer: unattended = [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/2-WinPE/S4WindowsUnattended.cs GetDismUnattendedFileSubPath(host, config, computer)]]
        note over NETSetup: **Image resolution BEFORE wiping install disk:**\nbootRoot = Path.GetFullPath("/") — boot medium root (startnet.cmd cd'd here).\nif <bootRoot>\\NETSetup\\Images\\<OS>\\ exists → prebuilt offline ISO path, no download.\nelse if !isOnline → ExitFailure.\nelse → mark imagesNetsetup = W:\\NETSetup (download after partitioning).\nThe broken pre-clear copy to Z:\\ was removed — Z: lives on the install disk and\ngets wiped by Clear() a few lines below, so anything staged there would be lost.
        NETSetup -> Computer: installationDisk.Clear(GPT) + AddPartitions(S: ESP 1000 MB, W: remaining)
        opt image was NOT prebuilt on boot medium
            NETSetup -> Computer: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/2-WinPE/S2Images.cs S2Images.EnsureImageElseExit(services, host, computer, isOnline, W:\\NETSetup)]]\nDownloads <OS>.7z from Dropbox + sha256 sidecar, extracts to W:\\NETSetup\\Images\\<OS>\\.
        end
        NETSetup -> Computer: wimPath = imagesNetsetup\\Images\\<OS>\\sources\\install.wim (verified with FileExists)
        NETSetup -> Computer: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/2-WinPE/DismCommand.cs DismCommand.ApplyImage(wimPath, imageName, W:)]] — 60 min timeout
        NETSetup -> Computer: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/2-WinPE/BcdbootCommand.cs bcdboot W:\\Windows /s S: /f UEFI]] — bcdboot from applied image, not WinPE's
        NETSetup -> Computer: Copy unattend.xml → W:\\Windows\\Panther\\unattend.xml
        NETSetup -> Computer: Copy NETSetup folder → W:\\NETSetup (excludes Images, Logs)
        NETSetup -> Computer: Reboot()

        == PHASE 2: Windows Setup (runs from target disk) ==
        note over Computer: Windows installer is running from target disk.\nNo USB needed anymore. WinPE is gone.
        Computer -> Computer: UEFI boots Windows Setup from installationDisk
        Computer -> Computer: Windows auto setup via unattend.xml
        Computer -> Computer: Unattend adds scheduled task: run NETSetup on first login
        Computer -> Computer: Reboot()

        == PHASE 3: Windows + NETSetup (runs from target disk) ==
        note over Computer: Full Windows is running.\nScheduled task launches NETSetup.exe install.\nInstallCommand detects host.IsOnWindows() && host.HasNETSetup()\n-> [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/3-NETSetupWindows/Stage3Windows.cs Stage3Windows.InstallNETSetupWindows()]]
        Computer -> Computer: Boots Windows from installationDisk
        Computer -> NETSetup: Scheduled Task runs NETSetup.exe install
        NETSetup -> Computer: computer = config.IdentifyHost(host).CastTo<Computer>()
        NETSetup -> Computer: CheckOnline(), EnsureOnlineIfNeededAskForWifi()
        NETSetup -> Computer: PowerSettings: Disable screen timeout and sleep (powercfg)
        NETSetup -> Computer: InstallChocolatey() (needed for HP updates)
        NETSetup -> Computer: TryGetDrivers() from remote source + TryInstallDrivers()
        opt computer is on Proxmox
            NETSetup -> Computer: Install VirtIO drivers via pnputil + disable WU driver search
            NETSetup -> Computer: Install QEMU Guest Agent
        end
        opt computer is HP (host.IsOnHP())
            NETSetup -> Computer: HPUpdateHelper.UpdateHPMachine() — install HP Support Assistant via choco, run silent driver/firmware/BIOS update -> Reboot
        end
        alt computer is WindowsServer
            NETSetup -> Computer: SetIPv4Configuration(staticIP)
            NETSetup -> Computer: InstallADForest() -> Reboot
            NETSetup -> Computer: ConfigureDNSServer()
            NETSetup -> Computer: InstallDHCPServer() -> Reboot
            NETSetup -> Computer: DisableDHCPServer()
            NETSetup -> Computer: Enable Remote Desktop
            NETSetup -> Computer: WinFirewall configuration
            NETSetup -> Computer: Enable Wake-on-LAN
            NETSetup -> Computer: Set Wallpaper
            NETSetup -> Computer: net start netlogon
            NETSetup -> Computer: dcdiag /test:dns + dcdiag
            NETSetup -> Computer: Create AD Users + Groups
            NETSetup -> Computer: Deactivate Default Administrator
            NETSetup -> Computer: Finished DC Setup -> Reboot
            NETSetup -> Computer: RunUpdate (choco upgrade, software install, Windows Update) -> Reboot
        else computer is WindowsComputer (Client)
            NETSetup -> Computer: Enable FullDomainDNSRegistration
            NETSetup -> Computer: Set all networks to Private
            NETSetup -> Computer: Enable Remote Desktop
            NETSetup -> Computer: WinFirewall configuration
            NETSetup -> Computer: Enable Wake-on-LAN
            NETSetup -> Computer: DomainJoin(config.Domain) -> Reboot
            NETSetup -> Computer: Set Wallpaper
            NETSetup -> Computer: gpupdate /force
            NETSetup -> Computer: RunUpdate (choco upgrade, software install, Windows Update) -> Reboot
        end

    else operatingSystem is Linux (target)

newpage NETSetup Setup Process - Linux/Proxmox: WinPE prepares USB

        == PHASE 1 continued: WinPE prepares USB stick for Linux boot ==
        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/2-WinPE/MainWinPE.cs MainWinPE]] Linux branch.\nStill running in WinPE from USB RAM disk.\n**Target disk is NOT touched. Everything stays on USB #1.**

        alt operatingSystem is Proxmox

            note over NETSetup: **New architecture (single-USB prep):**\nNETSetup does NOT touch USB #2 at all.\nUSB #2 is a plain Rufus-flashed Proxmox installer stick that the agent\nbrought with them. NETSetup only modifies USB #1 by adding a small FAT32\nSYSTEM partition that holds the generated answer.toml.\nproxmox-fetch-answer will scan *all* attached partitions (including USB #1)\nby label, so answer.toml living on USB #1 still reaches the installer running\nfrom USB #2.

            NETSetup -> Computer: FindBootDiskNumber(host) — resolve USB #1 disk number from AppContext.BaseDirectory drive letter
            NETSetup -> Computer: UsbLinuxPartitionManager.EnsureUsbDataPartition(usbDiskNumber, 100 MB, label=SYSTEM, fs=fat32, assignDriveLetter=true)
            note over Computer: **On USB #1 (the booted NETSetup stick):**\n  1. Find largest partition (NTFS DATA partition 2)\n  2. Shrink it via diskpart (current - 100 MB)\n  3. Create new primary partition in freed space\n  4. Format FAT32 with label SYSTEM\n  5. Add-PartitionAccessPath -AssignDriveLetter -> first free letter\nIdempotent: if SYSTEM already exists, re-uses it and just re-assigns a letter.
            NETSetup -> Computer: netSetupConfig.ToProxmoxAutoInstall(host, liberator, setStaticNetworkConfiguration: true).WriteTomlFile({letter}:\answer.toml)
            note over Computer: answer.toml lives at {assignedLetter}:\answer.toml on USB #1 SYSTEM (FAT32).\nPath + full TOML content are logged via LogDebugToFile("WinPE_Proxmox_AnswerToml").
            NETSetup -> Agent: UserInteraction.AskRebootNow("You will need to boot from Proxmox USB Stick now...")
            note over Agent: **Agent must now reboot and pick USB #2 (Proxmox) from the boot menu**\n(NETSetup cannot force this — USB #1 is still the default boot device).\nUSB #1 stays plugged in so proxmox-fetch-answer can find SYSTEM:/answer.toml.

        else other Linux (NixOS, etc.)
            note over Computer: VoidPE stays on USB NTFS (not overwritten).\nWinPE writes linux config to SYSTEM partition.
            NETSetup -> Computer: Write linux config file to SYSTEM:\config
        end

        NETSetup -> Computer: ExitReboot()
        note over Computer: **USB #1 layout after WinPE prep (Proxmox case):**\n  Partition 1: FAT32 ESP (unchanged)\n  Partition 2: NTFS DATA (shrunk by 100 MB)\n  Partition 3: FAT32 SYSTEM — answer.toml\n**USB #2 is completely unchanged** (still a plain Rufus-flashed Proxmox ISO).\nTarget disk is completely untouched.

newpage NETSetup Setup Process - Boot Phase 2: Agent boots Proxmox USB

        == PHASE 2: Agent reboots and selects USB #2 (Proxmox) from boot menu ==
        Agent -> Computer: Power-cycle; hold F9 (or equivalent) to open UEFI boot menu
        Agent -> Computer: Select USB #2 "Proxmox" entry (NOT USB #1)
        note over Computer: **USB #1 stays plugged in** — proxmox-fetch-answer will scan all attached\npartitions (by label) and find SYSTEM:answer.toml on USB #1.\nSecure Boot must be OFF (Proxmox GRUBX64.EFI is not signed for SB).\nThe agent already disabled SB in BIOS before deployment.
        Computer -> Computer: UEFI loads Proxmox shim BOOTX64.EFI from USB #2 ESP (unmodified Rufus flash)
        Computer -> Computer: Proxmox shim loads GRUBX64.EFI, which loads the stock Proxmox GRUB.CFG from USB #2

        alt operatingSystem is Proxmox

            Computer -> Computer: Stock Proxmox GRUB menu appears — agent selects "Install Proxmox VE (Automated)"
            note over Computer: **No custom GRUB.CFG** — USB #2 is a plain Rufus-flashed Proxmox ISO.\nAgent picks the stock "Automated" entry manually, which passes\nproxmox-start-auto-installer to the kernel.
            Computer -> Computer: GRUB loads Proxmox kernel+initrd from USB #2 iso9660 partition

newpage NETSetup Setup Process - Phase 2 continued: Proxmox auto-installer

            == PHASE 2 continued: Proxmox auto-installer ==
            Computer -> Computer: Kernel boots, normal Proxmox /init runs
            Computer -> Computer: /init mounts squashfs (pve-base.squashfs, pve-installer.squashfs)
            Computer -> Computer: Creates overlayfs, switch_root -> /sbin/unconfigured.sh
            Computer -> Computer: proxmox-start-auto-installer triggers proxmox-auto-installer
            Computer -> Computer: proxmox-fetch-answer scans all attached partitions for answer.toml
            note over Computer: proxmox-fetch-answer uses blkid to scan every attached partition and reads\nthe first one containing answer.toml.\nFinds **USB #1 partition 3 (SYSTEM, FAT32, written by WinPE)**.\nUSB #1 must still be plugged in at boot time — agent keeps both sticks attached\nuntil the installer has read the answer.
            Computer -> Computer: Auto-installer partitions TARGET DISK, installs Proxmox
            note over Computer: Target disk touched for the FIRST TIME here.\nAuto-installer: sgdisk, mkfs, LVM, debootstrap.\nConfigures network, hostname, root password from answer.toml.\nBoth USB sticks stay completely intact.
            Computer -> Computer: Reboot into installed Proxmox

        else other Linux (VoidPE path)

            Computer -> Computer: GRUB selects "NETSetup VoidPE" entry
            Computer -> Computer: GRUB loads VoidPE kernel+initrd from LINUX partition
            note over Computer: VoidPE boots from USB LINUX partition.\ndracut live module finds squashfs on LABEL=LINUX.\nOverlayfs rootfs. /etc/rc.local runs NETSetup.
            Computer -> NETSetup: NETSetup.exe install (Stage4LinuxPE)
            NETSetup -> Computer: Partition target drive, format, unpack image
            NETSetup -> Computer: Update boot entry (GRUB/EFI), reboot into installed Linux

        end

newpage NETSetup Setup Process - Phase 3: Proxmox Host Creates VMs

        == PHASE 3: Proxmox Host + NETSetup (runs ON installed Proxmox) ==
        note over Computer: Proxmox VE is now installed and running.\n[[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Resources/netsetup-first-boot.sh netsetup-first-boot.sh]] (USB-only install, no network fallback):\n  0. **Log-exists gate** — if /NETSetup/first-boot.log exists → exit (idempotent)\n  1. Restore Windows Boot Manager on USB ESP (bootmgfw.efi.bak → BOOTX64.EFI)\n  2. Mount SYSTEM partition (label=SYSTEM, FAT32 on USB #1)\n  3. cp -p NETSetup binary from /mnt/system/NETSetup/NETSetup → /NETSetup/NETSetup (preserves mtime)\n  4. cp -r config files from /mnt/system/NETSetup/Config → /NETSetup/Config\n  5. Install systemd service + timer (OnBootSec=2min, OnUnitActiveSec=6h, Persistent=true)\n  6. systemctl enable netsetup.timer (**NOT --now** — fires only after reboot)\n  7. **systemctl reboot** (scheduled +5s so first-boot unit exits cleanly)\n**No inline `netsetup update`** — network flaky during first-boot (DNS/403 on GitHub).\nBinary handles self-update via GitHub Releases API on every update cycle (see 3a).
        Computer -> Computer: First-boot service runs (from Proxmox ISO, ordering=fully-up)
        Computer -> Computer: Restore USB ESP boot manager (so USB is reusable)
        Computer -> Computer: Mount SYSTEM partition → cp NETSetup binary + Config locally
        Computer -> Computer: systemctl enable netsetup.timer (timer armed, NOT fired)
        Computer -> Computer: **Reboot** — clean boot, full network, then timer fires
        note over Computer: **After reboot**, systemd timer fires 2 min in (Persistent=true catches missed runs).\nOn **every subsequent boot** the timer also re-fires `netsetup update`.
        Computer -> NETSetup: systemd timer → ExecStart=/NETSetup/NETSetup update

        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/CLI/Commands/UpdateCommands.cs UpdateCommands.cs]] → [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Helpers/UpdateMethods.cs UpdateMethods.RunUpdate]]\nDetermineUpdateStage(host) → host.IsProxmoxHost() → RunProxmoxUpdate.\nEvery step is a discrete NETCommand call so individual failures stay scoped\nand surface in the C# log (no monolithic shell scripts).

        == PHASE 3a: Self-Update via GitHub Releases ==
        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Helpers/UpdateMethods.cs EnsureLatestBinaryOrReexec]] — runs FIRST, before anything else:\n  1. Decrypt embedded GitHub PAT via osisa.Security.Contracts.EncryptionHelper\n     (AES-256-CBC, PBKDF2-SHA1, baked-in ciphertext invisible to strings(1))\n  2. curl GET /repos/osisa/netsetup/releases/latest with Bearer token\n  3. ParseReleaseInfo → (version, assetId)\n  4. CompareSemVer(local, remote) — same major.minor.patch ⇒ trailing build counter wins\n  5. Remote newer → curl /releases/assets/{id} (Accept: application/octet-stream),\n     mv -f → /NETSetup/NETSetup, chmod 755, spawn child `NETSetup update`,\n     Environment.Exit(child.IsSuccess ? 0 : 1) — re-execs into the new binary
        NETSetup -> Computer: Decrypt embedded PAT (Contents:Read on osisa/netsetup only)
        NETSetup -> Computer: curl /releases/latest → ParseReleaseInfo
        alt remoteSemVer > localSemVer
            NETSetup -> Computer: Download asset by id → mv -f → chmod 755
            NETSetup -> NETSetup: Spawn `/NETSetup/NETSetup update`, wait, Environment.Exit
            note over NETSetup: Re-execed into the new binary; rest of this cycle runs there.
        else equal or older
            NETSetup -> Computer: Continue with current binary
        end

        == PHASE 3b: EnsureProxmoxPostInstall (once-only, LogFileExists-gated) ==
        note over NETSetup: **Log-file gate** mirrors Windows Stage3 pattern:\n  `if (!host.Logger.LogFileExists("ProxmoxPostInstall"))` → run + LogInformationToFile → **reboot + Environment.Exit(0)**\n  → next boot the log exists → skip → continue with 3c.\nEach helper = one discrete NETCommand or IFileOperator call:\n  - DisableEnterpriseListRepos (PVE 8 .list — sed in-place)\n  - DisableEnterpriseDeb822Repos (PVE 9 .sources — fileop read+write)\n  - EnsureNoSubscriptionRepo (pveversion → write .list or .sources)\n  - InstallNagRemovalHook (write hook + apt post-invoke + chmod + run once)\n  - ConfigureBanners (clear /etc/motd + systemctl disable pvebanner + write /etc/issue with detected IP)
        alt /NETSetup/Logs/ProxmoxPostInstall.log missing (FIRST run)
            NETSetup -> Computer: EnsureProxmoxPostInstall (repos, nag, banners)
            NETSetup -> Computer: LogInformationToFile("ProxmoxPostInstall", ...) — writes gate marker
            NETSetup -> Computer: systemctl reboot + Environment.Exit(0) — rest of cycle skipped
            note over Computer: **Next boot** the timer re-fires `netsetup update`.\nlog exists → post-install skipped → continues with 3c-3e below.
        else log exists (subsequent runs)
            NETSetup -> Computer: log "skipping post-install, continuing update" — falls through to 3c
        end

        == PHASE 3c: apt update + full-upgrade ==
        NETSetup -> Computer: apt update (only succeeds AFTER enterprise repos disabled in 3b)
        NETSetup -> Computer: env DEBIAN_FRONTEND=noninteractive apt full-upgrade -y

        == PHASE 3d: EnsureCustomerVms + EnsureCustomerLxcs ==
        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Helpers/EnsureCustomerVms.cs EnsureCustomerVms.Run]] orchestrates per-host VM/LXC provisioning:\n  1. orderedVms = [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/5-NETSetupLinux/CreateVirtualMachines.cs GetOrderedVmsForHost]](config, host.SerialNumber)\n     IsForHost matches serial `<VMID>@<HostSerial>`; tests rewire suffix to real BIOS serial.\n  2. Classify: Nix (OS.NixNextcloud / OS.NixMail / OS.NixDns / OS.NixIPFS / OS.NixDocker / OS.NixGeneric) | Windows | PMG-LXC | Skip\n  3. If any Nix VM exists → [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Helpers/EnsureNixosIsoOnProxmoxHost.cs EnsureNixosIsoOnProxmoxHost]] (sha256-driven 7z sync from Dropbox NETSetup/BOOT/Images/NixOS.7z to /var/lib/vz/template/iso/nixos.iso)\n  4. Per-VM dispatch with fault isolation (Mail failure does NOT skip Dns/Pmg)
        NETSetup -> Computer: EnsureCustomerVms.Run(host, config, services)

        == PHASE 3d.1: NixOS VM provisioning ==
        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/5-NETSetupLinux/ProvisionNixosVm.cs ProvisionNixosVm.Run]] — applies to Nextcloud, Mail, Dns (any Nix* VM):\n  Per-template CPU/RAM/disk via [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Config/Nix/Templates/NixHostTemplate.cs NixHostTemplate]] virtuals:\n    - Nextcloud: 4c / 8 GB / 256 GB disk\n    - Mail/Dns/Generic: 2c / 2 GB / 32 GB disk\n  Steady-state swapfile (8 GB on /var/swapfile) keeps services from OOM under load.
        loop For each Nix VM in orderedVms (DC-first ordering)
            NETSetup -> Computer: qm create <vmid> with per-template cpu/mem/disk + 16G install-time swap on scsi1 + ide0 ISO (nixos.iso) + autoStart=false
            NETSetup -> Computer: qm set scsi1 (16G scratch) + qm start <vmid>
            NETSetup -> Computer: DiscoverVmIpViaQmAgent — poll qm guest-agent network-get-interfaces (5min timeout, filters lo/link-local)
            NETSetup -> Computer: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/5-NETSetupLinux/ProvisionNixosVm.cs EmitConfigBundle]] — writes config.nix + hardware.nix + sops secrets.yaml + .default.key + bakes netsetup-master.pub into root + admin authorizedKeys
            NETSetup -> Computer: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/NETCommand/NixRemoteInstallCommand.cs NixRemoteInstallCommand]] phases 1-3 over SSH (master key root):
            note over NETSetup: 1. ssh-keyscan target ip\n2. tar c <configDir> | ssh ... 'rm -rf /nixos-config && cp -r /iso/nixos-config /nixos-config && tar x -C /nixos-config --strip-components=1'\n3. SSHEnvironment + LsBlkCommand: detect largest non-removable disk = INSTALL, smallest = SWAP (virtio-scsi-single PCI ordering unstable, fixed sda/sdb broke run #23)\n4. SSHEnvironment + MkswapCommand + SwaponCommand + MountCommand: prep install-time swap on SWAP, remount /nix/.rw-store to 20G tmpfs\n5. SSHEnvironment + BashCommand: /iso/nixos-config/scripts/install.sh -h default -d <INSTALL>
            note over Computer: Inside the VM, install.sh runs disko-install which partitions INSTALL, writes bootloader (systemd-boot + /EFI/Boot/BOOTX64.EFI fallback), generates SOPS host age key, decrypts secrets.yaml -> luks_password.\nNon-interactive mode auto-reboots via `systemctl reboot --no-block` so VM exits live ISO and boots installed system.
            NETSetup -> Computer: qm set <vmid> -delete ide0 -delete scsi1 (detach installer ISO + scratch swap), qm stop <vmid>, qm start <vmid>
            note over Computer: VM boots from disk into installed NixOS.\nMail-only: mail-tls-bootstrap systemd unit generates snake-oil cert at /var/lib/acme/mail.<domain>/{fullchain.pem,key.pem} BEFORE postfix/dovecot/nginx start, so services come up even before Let's Encrypt issues real cert.
        end

        == PHASE 3d.2: PMG LXC provisioning ==
        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/5-NETSetupLinux/ProvisionPmgLxc.cs ProvisionPmgLxc.Run]] — Proxmox Mail Gateway as LXC container (not full VM, smaller footprint).\nUses [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/NETCommand/LxcCommand.cs LxcCommand]] (pct binary wrapper).
        loop For each PMG LXC in customer config
            NETSetup -> Computer: pct create <ctid> debian-12-standard.tar.zst — 8G rootfs, 2 cores, 2 GB RAM, dhcp on vmbr0, unprivileged + nesting=1, --password default4244$
            NETSetup -> Computer: pct start <ctid>; poll status until running (60s timeout)
            NETSetup -> Computer: pct exec <ctid> -- /bin/sh -c '<install_script>' — apt update + apt install proxmox-mailgateway, configure smarthost relay to mail.<domain>:26
        end

        == PHASE 3d.3: OPNsense router VM provisioning (gated, Option Y 5-NIC) ==
        note over NETSetup: **Gate**: only runs if `host.Description == "Liberator"` AND OPNsense VM in config for `host.SerialNumber`.\nPower Station / vanilla Proxmox / other -> entire OPNsense + Mikrotik + VLAN flow skipped.\nSee [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/docs/Liberator.adoc Liberator.adoc]] for role matrix.\n\n[[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/5-NETSetupLinux/ProvisionOpnsenseVm.cs ProvisionOpnsenseVm.Run]] - OPNsense router as Proxmox-hosted VM (FreeBSD, prebuilt raw disk image).\nClassify gate: OS == OS.OPNSense -> VmProvisionKind.Opnsense.\nImage source: /var/lib/vz/template/iso/opnsense.img (synced via [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Helpers/EnsureOpnsenseImageOnProxmoxHost.cs EnsureOpnsenseImage.EnsureOpnsenseImageOnProxmoxHost]] from Dropbox NETSetup/BOOT/Images/Opnsense.7z).\nOrdering: OPNsense first in [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/5-NETSetupLinux/CreateVirtualMachines.cs GetOrderedVmsForHost]].\n**5 tagged vNICs (Option Y)**: hypervisor enforces VLAN, defence in depth.
        alt host.Description == "Liberator" && hasOpnsenseVm
        loop For each OPNsense VM in orderedVms (host-first ordering)
            NETSetup -> Computer: qm create <vmid> --bios seabios --memory 2048 --cores 2 --agent enabled=1 --scsihw virtio-scsi-single\n  --net0 virtio,bridge=vmbr0\n  --net1 virtio,bridge=vmbr0,tag=10\n  --net2 virtio,bridge=vmbr0,tag=20\n  --net3 virtio,bridge=vmbr0,tag=250\n  --net4 virtio,bridge=vmbr0,tag=254
            NETSetup -> Computer: qm importdisk <vmid> /var/lib/vz/template/iso/opnsense.img <storage>
            NETSetup -> Computer: qm set <vmid> --scsi0 <storage>:vm-<vmid>-disk-0 --boot order=scsi0
            NETSetup -> Computer: qm start <vmid>
            NETSetup -> Computer: OpnsenseInstall.Install(host, machine, config) - first-boot only, gated by /conf/.netsetup-installed:\n  build config.xml with 5 if assignments (vtnet0..4), DHCP per VLAN,\n  static maps from config.Computers[].MAC, DNS forwarder -> NixDns,\n  FW deny-default inter-VLAN, admin user + api_key/api_secret\n  ssh root@<wanIp> + scp /conf/config.xml + configctl service reload all\n  persist api_key to /etc/netsetup/opnsense-api.env
        end
        else !runFullNetworkFlow (Power Station / Proxmox / other)
            note over NETSetup: OPNsense provisioning skipped entirely.\nFlat vmbr0 only. VMs still get provisioned (next sections).
        end

        == PHASE 3d.4: Windows VM provisioning ==
        note over NETSetup: [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/src/NETSetup/Stages/5-NETSetupLinux/ProvisionWindowsVmOnProxmox.cs ProvisionWindowsVmOnProxmox.Run]] — generates per-VM offline NETSetup ISO via MainISO.CreateOfflineProductionISO, scps to /var/lib/vz/template/iso/, qm create with VirtIO + answer.toml.
        loop For each Windows VM in orderedVms
            NETSetup -> Computer: MainISO.CreateOfflineProductionISO(vm.Os, vm.AnswerToml) -> /tmp/<vmname>.iso
            NETSetup -> Computer: scp -> /var/lib/vz/template/iso/<vmname>.iso
            NETSetup -> Computer: qm create <vmid> + scsi0 (32+G), ide2 cdrom (vm-iso), virtio net, autoStart=true
        end

        == PHASE 3e: Liberator network converge (gated, every cycle, idempotent) ==
        note over NETSetup: **Gate**: `runFullNetworkFlow = host.Description == "Liberator" && hasOpnsenseVm`.\nSkipped on Power Station / vanilla Proxmox / other.\nFour helpers run in this order, each idempotent + drift-correcting.\nSee [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/docs/Liberator.adoc Liberator.adoc]] + [[https://github.com/osisa/NETSetup/tree/feature/proxmoxvms/docs/Liberator-Network-Push.puml Liberator-Network-Push.puml]] for full diagram.
        alt runFullNetworkFlow
            NETSetup -> Computer: ConfigureOpnsenseVlans (REST API push)\n  probe /api/core/system/status\n  PUT /api/dhcpv4/static_map (per Computer in machine map)\n  PUT /api/dnsforwarder/settings (forward to NixDns .53)\n  PUT /api/firewall/alias/... (per-VLAN subnets, public services)\n  PUT /api/firewall/filter/... (deny default, allow VLAN20->VLAN10 AD/DNS/SMB/print/HTTPS)\n  POST /api/firewall/apply
            NETSetup -> Computer: ConfigureMikrotikVlans (REST API push)\n  bridge VLAN-aware on, VLANs 10/20/250/254\n  bonding1 LACP 802.3ad slaves=ether2,ether3\n  trunk tagged on bond, ether4/ether5 access VLAN20 untagged\n  WiFi SSIDs: Liberator(20), Liberator-Guest(254 open), admin-oob-lib(250 hidden WPA3)\n  Mikrotik mgmt IP VLAN250 .1\n  remove flat NAT, disable Mikrotik DHCP server
            NETSetup -> Computer: ConfigureProxmoxBridges (/etc/network/interfaces rewrite)\n  bond0 LACP enp1s0+enp2s0, bond-mode 802.3ad\n  vmbr0 bridge-vlan-aware yes, bridge-vids 10 20 250 254\n  vmbr0v250 host mgmt = .2\n  ifupdown2 reload safe-mode (commit/rollback if mgmt lost)
            NETSetup -> Computer: RewireVmNicsToVlans (drift correction)\n  for each existing VM, qm set <vmid> -netN ...,tag=<vlan> only if missing/wrong\n  NixDns/DC/NC/Mail/PMG -> tag=10\n  Win11 clients -> tag=20
        else !runFullNetworkFlow
            note over NETSetup: All 4 network converge helpers skipped.
        end

        == PHASE 3f: btrfs scrub ==
        NETSetup -> Computer: For each mounted btrfs filesystem -> btrfs scrub start -B
        note over Computer: systemd timer re-fires netsetup.service every 6h **and on every boot** (OnBootSec=2min).\nPhases 3c/3d/3e/3f run every cycle (apt, VMs, network converge, scrub all idempotent).\nPhase 3b runs ONCE (log-gated) and reboots on first run.\nPhase 3a swaps binary only when remote SemVer is strictly greater, logs outcome either way.\nPhase 3e gated on host.Description == "Liberator" && hasOpnsenseVm. Bare hosts skip it entirely.

    end
end

newpage Approval and Billing
Agent -> Customer: [[https://github.com/osisa/NETSetup/tree/develop/docs/NETSetup/400%20Abnahme config.CreateApprovalForm())]]
Customer -> Agent: fillOut(config.approvalForm)
Agent -> Accounting: [[https://github.com/osisa/NETSetup/blob/develop/docs/Infobl%C3%A4tter/Software/Sage/Sage%20Start/Softwareinfoblatt%20Sage%20Start.adoc commissionBill(config)]]
Accounting -> Agent: returnBill(config.bill)
Agent -> Customer: sendBill(config.bill)
Customer -> Accounting: payBill(config.bill)
Accounting -> Agent: notify(config.bill.paid)
Agent -> Ticketing: closeTicket()

@enduml

NEWS

2026-05-05: public mail stack auto-prov per customer

Per-customer public mail stack. Three new hosted devices land on customer Proxmox host plus PBS on host itself. All wired into existing EnsureCustomerVms / EnsureProxmoxPostInstall pipeline.

Reference docs
  • FSD: Public Mail Stack — functional spec, decisions locked, deployment prereqs

  • Blueprint: Public Mail Stack — C# Implementation — file map, class signatures, build order

  • netsetup-mail.puml — component + flow diagram (PBS, PMG LXC, mail VM, DNS VM, DDNS, ACME, smarthost)

    1. Mail VM, DNS VM, PMG LXC — one bundle. Mail VM (NixOS, OS NixMail): postfix submission 587 + smtpd 25 + internal LMTP-from-PMG on 26, dovecot imap 143/993 + lmtp unix socket, nginx + roundcube on 443. DNS VM (NixOS, OS NixDns): coredns auth zone + step-ca internal CA + lego LE DNS-01
      ddns-updater. PMG LXC (Debian + proxmox-mailgateway, OS PmgLxc): inbound filter on 25, relays to mail.<domain>:26. PBS installed apt-style directly on Proxmox host (no extra VM, no extra container) with datastore auto-created at /var/lib/proxmox-backup/datastore.

    2. Provider-pluggable DNS + ACME. New NETSetupConfig.DnsProvider POCO (Provider, CredentialsFile, CredentialsAttrs) feeds both lego (cert issuance) and ddns-updater (A-record sync) on the DNS VM. Supported provider list (intersection of nixpkgs services.ddns-updater and lego): cloudflare, duckdns, namecheap, porkbun, gandi, hetzner, godaddy, ovh, dyn, noip. Same provider + credentials drive both tools 95% of the time — one knob in customer config, two services configured downstream. Cloudflare is example, NOT hardcoded.

    3. Optional smarthost relay. New NETSetupConfig.MailRelay POCO (Host, Port default 587, User, PassFile). When set, postfix on the mail VM routes outbound through that smarthost (Mailgun, Brevo, SES, M365, whatever) with STARTTLS + SASL. When null, postfix does direct MX delivery. PassFile is a SOPS-encrypted secret path on the mail VM; NETSetup never reads its content.

    4. DNS records auto-derived from customer config. NixDnsTemplate scans every public-service VM hosted on the customer Proxmox host (NixNextcloud, NixMail, NixDns, NixIPFS, NixDocker, PmgLxc) and emits one A record per hostname under <CustomerDomain> zone, plus the PMG mx alias pointing at pmg.<domain>. Public hostnames (e.g. mail.<domain>, pmg.<domain>) become SANs on the LE certificate lego pulls via DNS-01. Adding a new hosted device means it shows up in DNS + cert SANs without touching the DNS template.

    5. New OS constants in NetsetupOS. NixMail, NixDns, PmgLxc. The two Nix-prefixed names satisfy IsNixBacked automatically and are routed by EnsureCustomerVmsNixHostTemplateFactory to the new NixMailTemplate / NixDnsTemplate (subclasses of NixHostTemplate). PmgLxc does NOT satisfy IsNixBacked — intentional, so EnsureCustomerVms skips it — and is routed by the new EnsureCustomerLxcsProvisionPmgLxc instead.

    6. LXC provisioning infrastructure — all new. LxcCommand.Builder wraps /usr/sbin/pct (subcommands: create, start, stop, destroy, exec, set, status) using the same AddNamed / AddBool pattern as every other NETCommand builder — never raw Add($"--flag {value}"). ProvisionPmgLxc is the LXC equivalent of ProvisionNixosVm (static class, Run + RunCore, delegate seams): pct create with --features nesting=1 --unprivileged 1, pct start, pct exec installs PMG apt repo + key + apt install proxmox-mailgateway + writes basic relay config pointing to mail.<CustomerDomain>:26.

    7. Wired into RunProxmoxUpdate. EnsureCustomerLxcs.Run runs after EnsureCustomerVms.Run — mandatory ordering, because the PMG relay config inside the LXC references mail.<domain>:26, so the mail VM must exist first. EnsurePbsInstalled is appended to EnsureProxmoxPostInstall after EnsureLocalStorageEnabled (apt install proxmox-backup-server, idempotent dpkg check, datastore auto-create). Both new steps are individual NETCommand calls so failures stay scoped — same per-step pattern as the rest of the post-install pipeline.

    8. TestCompany fixture additions. Three new hosted devices on the Liberator test host (host serial lib), inserted after LibWin11: mail@lib (VMID 102, NixMail), dns@lib (VMID 103, NixDns), pmg@lib (CTID 200, PmgLxc). CustomerDomain = "test01.iam.free" set on the test config; DnsProvider = cloudflare placeholder (provider swap is a one-line config change, not a code change).

    9. Manual e2e via info@osisa.com. After the automated test leaves the stack live, operator sends a real mail from info@osisa.com to <user>@test01.iam.free and verifies arrival in roundcube on https://mail.test01.iam.free/. Reply path back to info@osisa.com exercises the smarthost relay (or direct MX, depending on MailRelay). Automated e2e covers provisioning + service-up + DNS resolution: new test class ProxmoxPublicMailTests.Liberator ([Ignore("Local Tests")]) asserts PBS active on host, pct list shows pmg@lib, qm list shows mail+dns, dovecot/postfix/ nginx/coredns/step-ca/lego/ddns-updater all is-active, dig @localhost mail.test01.iam.free resolves, and a raw HttpClient GET of https://mail.test01.iam.free/ returns the roundcube login.

    10. DDNS provider hook. ddns-updater on the DNS VM polls the customer site’s current public IP every 60 s (whatismyip / opendns), compares with the last-pushed value, and on change PATCHes the configured DNS provider’s API for every configured A record (mail.<domain>, pmg.<domain>, mx.<domain>). MX record TTL pinned at 60 s for fast failover when the customer-edge dynamic IP rolls. Lego DNS-01 talks to the same provider for cert issuance + renewal, so a single provider account covers both record sync and ACME challenges.

2026-04-15: Proxmox host self-update via GitHub Releases

netsetup update on a Proxmox host now upgrades itself end-to-end without leaving the binary stale. The flow:

  1. First-boot is USB-only. netsetup-first-boot.sh copies the linux-x64 binary from the SYSTEM partition (label SYSTEM on USB #1) and cp -p preserves its mtime. No Dropbox / Pages fallback. No self-update.sh. No ExecStartPre — the systemd unit simply runs /NETSetup/NETSetup update.

  2. Self-update via GitHub Releases. UpdateMethods.EnsureLatestBinaryOrReexec runs as the first step of every netsetup update cycle. It decrypts an embedded fine-grained PAT (Contents:Read on osisa/netsetup only, AES-256-CBC via osisa.Security.Contracts.EncryptionHelper so strings(1) only sees a base64 blob), GETs /repos/osisa/netsetup/releases/latest, parses the response with ParseReleaseInfo, and compares versions with CompareSemVer. When the remote SemVer is strictly greater, it downloads the asset by id, swaps the binary atomically (mv -f), chmod 755, spawns the new binary as NETSetup update, waits, and `Environment.Exit`s with the child’s exit code.

  3. SemVer comparator with build-counter precedence. Same major.minor.patch? The trailing dot-segment of the pre-release tail (the GitVersion build counter, e.g. 249 in 0.9.0-proxmoxvms.249) is compared numerically — channel rename in CI does not invert the ordering. Per SemVer 2.0 a missing pre-release still outranks any present one (1.0.0 > 1.0.0-x).

  4. Post-install ran step-by-step in C#. EnsureProxmoxPostInstall no longer writes a giant bash script. Each side-effect (disable enterprise repos, write pve-no-subscription.sources, install nag-removal hook, configure banners) is a discrete NETCommand or IFileOperator call so failures stay scoped.

  5. Customer VM provisioning (e.g. Nextcloud + Win11). EnsureCustomerVms.Run is wired into RunProxmoxUpdate after apt full-upgrade. Machines whose serial format is <VMID>@<HostSerial> and whose OS is NixOS-backed (or Win10/11/Server) get provisioned on the local Proxmox host. The OfflineCompany test fixture has a Nextcloud server (100@lib) and a Win11 client (201@lib) hosted on the Liberator; test ProxmoxWithUSBStickTests.Liberator end-to-end (PASSED 2026-05-03, ~26 min) confirms post-install ran, both VMs were created, Nextcloud’s HTTPS endpoint serves on its DHCP IP (raw HttpClient probe — accepts the 400 "Untrusted domain" Nextcloud returns when reached by IP), and the LibWin11 VM has the expected qm config (bios=ovmf, efidisk0, agent=1, ide-iso, scsi-btrfs).

    LibWin11 ISO pre-stage. MainISO.CreateOfflineProductionISO requires the netsetup source tree + dotnet SDK and so cannot run on a Liberator/Proxmox host. Build the per-VM Win11 ISO on the customer’s NETSetup build machine and ship it to the Proxmox host’s iso storage at /var/lib/vz/template/iso/NETSetup-{ticketId}-{hostname}.iso (where ticketId is the int parse of ConfigName or its FNV-1a hash for non-numeric ConfigName`s). `ProvisionWindowsVmOnProxmox detects an existing file at that path and skips the source-dependent rebuild. The Liberator test follows the same pattern: PreStageLibWin11IsoOnLiberator builds locally + SCPs to Liberator before NETSetup runs.

    NixOS install on tmpfs-limited VMs. ProvisionNixosVm attaches a 16 G scsi1 swap disk pre-create and NixRemoteInstallCommand runs mkswap /dev/sdb && swapon /dev/sdb && mount -o remount,size=20G /nix/.rw-store before invoking install.sh. Without this the Nextcloud closure overruns the live-ISO tmpfs (RAM/2, ~4 G at 8 G RAM) with error: writing to file: No space left on device. Liberator host RAM (15 G) cannot fit a 16 G nested VM, so swap is the workaround. Post-install uses qm stop+qm start (not qm reboot) because the live-ISO installer doesn’t honor ACPI shutdown.

  6. GitHub Actions publishes the linux-x64 binary as a release asset on every push to main/develop (gh release upload v$SemVer NETSetup --clobber), so the self-update path always has a fresh build to pull.

2025-07-03

Updated the Flow to include the future Flow for Liberators.

Add Customer / Add Machine

Customer configs live in Customers/ as .NET 10 file-based .cs apps. Each one builds a Company, runs the translator, emits <Customer>.json next to itself. The JSON is what NETSetup reads at install time, keyed off the machine serial.

Workflow

  1. Checkout latest. Pull current HEAD so you start from the same source other devs see.

    git clone https://github.com/osisa/netsetup.git
    cd netsetup
    git checkout develop
    git pull
  2. Edit Customers/<Customer>.cs. Add/change .AddDevice(name, type).WithSerialNumber(…​) for a new machine. Add .AddEmployee(…​) for a new user. Override examples (admin password, fixed IP, public URI, serial swap) live in Customers/README.md.

  3. Regenerate JSON. Run the file-based app directly. Writes <Customer>.json next to the .cs, prints same JSON to stdout.

    dotnet run Customers\<Customer>.cs

    Rebuild all at once:

    Customers\_rebuild-all.cmd
  4. Publish to production. Production JSONs live in the osisa Dropbox at:

    %USERPROFILE%\osisa Dropbox\NETSetup\BOOT\Config\

    Copy the regenerated <Customer>.json into that folder. NETSetup installs pull the config from there (synced to the USB SYSTEM partition and baked into offline ISOs).

  5. Commit + push. Commit both the .cs change and the regenerated .json so the repo and Dropbox stay in lock-step.

NETSetup: Install using USB Stick

0. Requirements for Installation using NETSetup USB Stick

Read the entire Installation Instructions once from start to end before doing anything.

Target machine’s serial number has to be associated to a specific configuration. To do this goto the NETSetup.Tests Project and copy a customer file (e.g. Schnell.cs and at the bottom fill in all the details.The tests are supposed to be run in order to ensure the functionality of the configuration.One (by default ignored) test will upload the config files to the remote source, e.g. Dropbox).The most important methods are the ServerIP Range and the CreateCompany! (ALTERNATIVE to 1. for single Computers) Take #123.json from Dropbox and set the SerialNumber inside the desired type of Computer and ensure the {SerialNumber}#123.map File both exist.Copy them after creating the USB Stick in the NETSetup/Config Folder.

  1. Config has to be online of customer

  2. Physical access to the machine, which has internet cable, power, display and a keyboard connected.

  3. Press power button and hold the button labeled ESC until you see a menu

  4. Choose BIOS Setup / Computer Setup with arrow keys and press Enter

  5. Go through all Settings using the keys while looking for and setting the following settings (if you dont find the name, skip). In the end goto File > Save Changes and Exit and confirm yes and shutdown Machine with power button after reboot:

    • Secure Boot Configuration > Legacy Support / CSM / MBR Boot Disabled

    • USB / Removable Media Boot enabled

    • Secure Boot Configuration > Fast Boot Disabled

    • For Liberator VTx (Virtualization) Enabled

    • For Liberator Secure Boot Configuration > Secure Boot Disabled

    • For Liberator Boot Order > Push all entries with "USB" to the bottom

  6. A USB Stick of at least 16 GB (This USB Stick will be formatted and all data on it will be lost).

  7. For Proxmox installs you also need a SECOND USB Stick of at least 16 GB flashed with the Proxmox ISO using Rufus (same procedure as below, but select the Proxmox ISO instead of NETSetup.iso and use Rufus’s default "ISO Image mode" when prompted). Both sticks must be plugged into the target machine during installation — see Using the Install USB Stick (Proxmox).

  8. For Proxmox hosts the install disk must be at least 256 GB (NVMe / SSD). Each customer VM uses btrfs subvolumes on local-btrfs: Nextcloud needs ~30 GB (256 GB allocated), Mail ~7 GB (32 GB + 16 GB swap), Dns ~18 GB (32 GB), plus host OS, Proxmox templates, ISOs (nixos.iso ~1.4 GB), and headroom for nix-store builds. A 64 GB host disk fills to 100% within the first few VMs and Proxmox marks running VMs as io-error (qemu-guest-agent stops, writes fail).

  9. Goto your existing Personal Computer and login with an Administrator

  10. Press Win and X and open Terminal (Administrator) and confirm

  11. Install Chocolatey. Skip if choco already on PATH. Same one-liner used in chocolatey.adoc and in Setup-osisa-NETBase-Development-Machine.adoc:

    Set-ExecutionPolicy Bypass -Scope Process -Force; `
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; `
    iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
  12. Add osisa NETSetup choco source. Holds the netsetup package + customer-specific software builds (infoniqa prod, etc.). Same URL ChocolateyHelper.EnsureNETSetupSource adds at runtime, so adding it here is idempotent:

    choco source add --name=netsetup --source=http://netsetupprod.osisa.com:8080/chocolatey
  13. Install NETSetup (preferred path: choco). Pulls netsetup from the osisa source. Adds the shim at C:\ProgramData\chocolatey\bin\netsetup.exe so netsetup works from any shell, and EnsureLatestNETSetup can auto-upgrade later:

    choco install netsetup -y

    Also install rufus the same way:

    choco install rufus -y
  14. Fallback: direct .exe. Use only if the osisa choco source is unreachable (off-VPN, fresh box without network creds, etc.). Download NETSetup.exe from the release page into C:\NETSetup\. Optional: verify hash via Get-FileHash NETSetup.exe against:

    D1B899C2517BDF5D96716783CF9BFE89662A11763B4F0AF1A5E22B371C516530
  15. Build the ISO. Open an elevated PowerShell and run whichever matches the path above:

    netsetup create-iso                              # choco install (preferred)
    cd C:\NETSetup\ ; .\NETSetup.exe create-iso      # direct .exe fallback

    The ISO is written to C:\NETSetup\Iso\NETSetup.iso. Use that file in Rufus in the next section.

1. Making the USB Stick

  1. Plug in your USB Stick to your computer

  2. Press Win and R and type "rufus" and press enter and confirm

  3. Select your USB Stick from the list at the top "Laufwerk" (Drive):

Select drive
  1. Click the "AUSWAHL" (SELECT) button and select the "NETSetup.iso" file from your Downloads folder.

Select image
  1. Make sure that the following Items are set as expected:

    • "Partitionsschema" is set to "GPT"

    • "Zielsystem" is set to "UEFI (ohne CSM)"

    • "Dateisystem" is set to "NTFS"

Proper settings
  1. Click the "START" button.

Start button
  1. Rufus might show you a warning. Click "OK" to confirm that all existing data on the stick will be deleted.

Warning
  1. Wait for the green progress bar at the bottom of the program to fill. This is a good time to get a cup of coffee.

Progress
  1. Rufus will show the text "FERTIG" (FINISHED) in the filled progress bar once it is done. You can unplug and use the NETSetup USB Stick now, and you can close rufus.

Done

2. Using the Install USB Stick

  1. Plug the USB Stick in the target machine on the back side (directly into the motherboard and not a front header USB).

  2. Power on the machine and hold down the F9 key (or a different one depending on your machine to enter the boot menu, refer to your manufacturers manual) until you enter the boot menu.

  3. Use the arrow keys to select USB. If you have multiple USB shows up, pick the topmost occurence and press enter

  4. If it asks you to select something, type the answer using the keyboard and press enter.

  5. Wait for the installation to finish, it reboots several times

For Liberator (or other linux based system) enter the Boot Menu 2 times and boot the disk and not the USB Stick

Note: If you’re setting up a new Windows Server Domain/AD, wait for the server to finish all its tasks before installing the clients. ~30min should be enough for reasonably up-to-date hardware. Connect the new machines to the LAN and boot them the same way with the USB Stick from before or a different one that has been made the same way

Workaround for Liberator Use this to setup machines on Liberator

2a. Using the Install USB Stick — Proxmox (TWO-STICK PROCEDURE)

Proxmox installations require TWO USB sticks because NETSetup and Proxmox each need their own bootable media. NETSetup runs in WinPE from stick #1, prepares an answer file on a small FAT32 partition it adds to stick #1, and then tells you to reboot into stick #2. The stock Proxmox installer on stick #2 boots up, reads the answer file off stick #1 (by volume label), and installs Proxmox automatically.

Both sticks are created in advance with Rufus — NETSetup does not write anything to stick #2.

  1. Prepare Stick #1 (NETSetup) following section 1 above with NETSetup.iso.

  2. Prepare Stick #2 (Proxmox) the same way:

    • Plug in the second USB stick

    • Run Rufus

    • Select the second stick as the drive

    • Click "AUSWAHL" (SELECT) and pick your proxmox-ve_*.iso

    • Accept Rufus’s defaults (it will prompt to write in "ISO Image mode" — accept)

    • Click "START" and confirm data loss

    • Wait for "FERTIG"

  3. Plug BOTH sticks into the target machine. Both must stay plugged in for the entire installation.

  4. Power on and hold F9 (or the manufacturer’s boot-menu key) to open the UEFI boot menu.

  5. Select Stick #1 (NETSetup) — NOT the Proxmox one yet.

  6. NETSetup/WinPE runs, detects that the target OS is Proxmox, shrinks stick #1’s NTFS data partition by ~100 MB, creates a new FAT32 partition labeled SYSTEM, writes the generated answer.toml to it, and then prompts:

    You will need to boot from Proxmox USB Stick now... (Y/N)

    Press Y to reboot. Both sticks must still be plugged in.

  7. When the machine reboots, hold F9 again to open the boot menu a second time.

  8. This time select Stick #2 (Proxmox) — NOT NETSetup.

  9. The stock Proxmox GRUB menu appears. Pick the "Install Proxmox VE (Automated)" entry.

  10. Proxmox boots, proxmox-fetch-answer scans all attached partitions, finds answer.toml on stick #1’s SYSTEM partition, and runs the unattended installer. Both sticks stay plugged in until the installer finishes reading the answer.

  11. After Proxmox is installed and has rebooted into the installed system, you can unplug both USB sticks.

Troubleshooting Proxmox 2-stick flow:

  • "proxmox-fetch-answer: no answer file found" → You pulled stick #1 too early. Keep both sticks plugged in until the installer has loaded past the partition scan.

  • Stick #2 doesn’t appear in the boot menu → Secure Boot is still on; disable it (see [BIOS-SETTINGS]). Proxmox’s shim is fine but the GRUB payload inside the hybrid ISO is not SB-signed for every firmware.

  • Machine boots stick #1 again after the reboot → You didn’t hold F9. There’s no way for NETSetup to force UEFI to change boot order mid-install; you must pick stick #2 manually.

3. USB Stick Troubleshooting

  • I cant boot the USB Stick. Check that UEFI Boot is enabled and NOT CSM/MBR BOOT!!!

  • I get an error while creating the USB stick:

    => Ensure that the USB stick is working and that it has enough space.
  • I cannot enter the boot menu:

    => <<BIOS-SETTINGS,Disable "Fast Boot">> in the BIOS. Consult Google if you are unsure on how to do this.
  • The NETSetup fails unexpectedly:

    => Ensure that you have the correct version of NETSetup.iso file (check the SHA-256 hash).
    => Make sure you follow the directions in <<Using the USB Stick>> correctly and ensure that the target machine's serial number is correctly registered in the config.
  • Windows Server installation fails:

    => Windows Server installation REQUIRES to be connected to an ethernet environment (a router is enough, it doesn't have to be a complete internet-activated environment with multiple machines). If there is no response on the ethernet port while installing, Windows Server will not install and setup ethernet and it will fail.

Executable (Advanced Functions)

SHA-256 Hash of NETSetup.exe:
D1B899C2517BDF5D96716783CF9BFE89662A11763B4F0AF1A5E22B371C516530

Usage

NETSetup.exe can install and configure aspects of a given machine. During a regular NETSetup installation it need not be downloaded separately. The executable is provided here for advanced users.

Use NETSetup.exe [command] --help to get help for a specific command.

Installation & Configuration
install [--ticket <ticket>] [--hostname <hostname>]

Installs and configures the machine according to its NETSetup configuration. Looks up the config by the machine’s serial number. On WinPE/LinuxPE it runs the full OS installation pipeline. On an installed OS it configures software, domain, etc. Optional --ticket and --hostname parameters override automatic config detection.

update

Updates an installed machine’s software. Runs Chocolatey upgrade-all, ensures all config-specified packages with exact versions are installed, and refreshes Group Policy if domain-joined. Requires elevation.

create-iso [--offline] [--output <path>] [--ticket <ticket>] [--hostname <hostname>] [--images <path>] [--drivers <path>]

Creates a NETSetup install ISO with the config baked in. Use --offline to include OS images and drivers for offline installation. On Proxmox hosts, copies the ISO to the queried ISO storage.

create-binaries [--output <path>]

Publishes NETSetup binaries (win-x64 NETSetup.exe, linux-x64 NETSetup, linux-musl-x64 NETSetup.musl) into output/artifacts/ and writes SHA256 adoc files. No ISO. Used by the deployment workflow.

change-windows-edition --to <Home|Pro|Enterprise> [--product-key <key>]

Flips the running Windows edition. Upgrades (Home → Pro, Pro → Enterprise) use changepk.exe. Downgrade Pro → Home is UNSUPPORTED by Microsoft; implemented via registry rewrite (EditionID, ProductName, CompositionEditionID under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion) plus slmgr /ipk + /ato with a Home generic key. User data stays in place. A future Windows feature update may revert the edition marker. Reboot required. Requires elevation. If --product-key is omitted, a generic (non-activation) edition-switch key is used.

install-drivers --path <path> [--recursive]

Install all .inf drivers in <path> via pnputil /add-driver /install. With --recursive, walks subdirectories. Windows only. Requires elevation.

backup [--source <drive>] [--target <path>]

Mirrors \Users + selected \ProgramData (chocolatey, ssh, Microsoft\Crypto, Microsoft\UserAccountPictures) + 3rd-party drivers (DISM /Export-Driver /Image:) from an existing Windows install to the NETSetup USB stick. Backup layout: <usb>\NETSetup\Backup\<yyyyMMdd-HHmmss>\{Users,ProgramData,Drivers}\. Auto-detects Windows root (probe for \Windows\System32\config\SYSTEM) and USB stick (probe for \NETSetup\NETSetup.exe). Auto-runs in WinPE before disk wipe — no-op when no existing Windows present. Driver export critical for fresh-laptop downgrades (Wi-Fi/NIC drivers). Requires elevation.

restore [--source <drive>]

Pulls latest backup from <usb>\NETSetup\Backup\<latest>\ onto C:. Profiles restored via robocopy with NTUSER.DAT/UsrClass.dat/AppData\Local excluded (would corrupt fresh registry/cache). AppData\Roaming restored separately. ACLs reset per profile (icacls /reset + grant user/Admins/SYSTEM) since post-reinstall SIDs differ. Skips profiles whose local user doesn’t exist yet (warns to create + log in once first). Auto-runs in install flow on Windows side just before update phase. Idempotent via BackupRestored log marker. Requires elevation.

File Synchronization
ensure-file --file <file> --target <directory>

Ensures that the file exists in the target directory. If not present, it will be downloaded from the remote source. The --file path is relative to the remote NETSetup boot directory.

ensure-folder --source <folder> --target <directory>

Ensures that the folder with all its contents exists in the target directory. If not present, it will be downloaded from the remote source.

Network
set-ipv4 --ip <ip> --netmask <netmask> --gateway <gateway> --dns <dns>

Sets the machine’s IPv4 address, netmask, gateway, and DNS.

connect-wifi [--ssid <ssid>] [--password <password>]

Connects to a WiFi network with the given SSID and password.

check-online

Checks whether the machine has internet connectivity.

check-website-online --url <url> [--timeout-minutes <minutes>]

Checks whether a specific URL is reachable. Optional timeout in minutes.

enable-dhcp-server

Enables the DHCP server on this machine.

disable-dhcp-server

Disables the DHCP server on this machine.

Mikrotik
flash-mikrotik --admin-password <pw> [--mac <MAC>] [--ssid <ssid>] [--wifi-password <pw>] [--hostname <name>] [--flashed-new-admin-password <pw>]

Flash a MikroTik hAP-class device into router+AP+switch mode. ether1=WAN (dhcp-client + masquerade), bridge ether2-5+wifi1+wifi2 = LAN with static 10.0.0.1/24 + dhcp-server pool 10.0.0.2-10.0.0.254. --admin-password is required: the CURRENT admin password on the device (sticker pwd for stock units). --mac optional: when omitted, ARP discovery is skipped and the host goes directly to the factory-IP route, assuming the MikroTik is cabled directly to the host’s first ethernet adapter. Defaults: --ssid Liberator, --wifi-password 12345678, --hostname Lib_AP_<rand4>, --flashed-new-admin-password "default4244$". Factory route: picks first Up ethernet adapter (logs name+MAC+ifIndex+other-count), sets static 192.168.88.10/24 on it, 10s countdown, probes 192.168.88.1, exits with a clear message if unreachable; reverts NIC to DHCP after reset-configuration (and in finally on any failure).

Flow: ensures putty.install choco pkg (provides pscp) → resolves MAC→IP via ARP scan (fallback 192.168.88.1) → renders .rsc → pscp upload → /system reset-configuration keep-users no-defaults skip-backup run-after-reset=<file> → polls 10.0.0.1 until API responds (5 min timeout). Cable PC to ether2-5 before running.

Hardware Inventory
scan-hardware [--output <path>]

Probe local machine via CIM, dump JSON. Output defaults to {LogsRoot}/hwscan-<timestamp>.json. LogsRoot = appsettings.json LoggingOptions.LogsPathOverride else /NETSetup/Logs. If --output is a directory, default filename appended; if a file, used as-is.

scan-network [--hosts <csv>] [--range <cidr|a-b>] [--user <u>] [--password <p>] [--output <path>] [--no-self]

Probe current machine + remote Windows hosts via PowerShell Invoke-Command. --hosts = comma list of IPs/hostnames. --range = CIDR (10.0.0.0/24) or inclusive range (10.0.0.10-10.0.0.50); both flags may be combined. Credentials optional; omit for current Kerberos creds. --no-self skips the scanning machine. Unreachable hosts logged as failures but do not abort; result JSON includes per-host status. Output path same rules as scan-hardware.

Wake-on-LAN
wake-machine --mac-address <mac> [--broadcast <ip>] [--port <port>]

Sends a Wake-on-LAN magic packet to wake a single machine by its MAC address.

wake-machines --file-path <path> [--broadcast <ip>] [--port <port>]

Sends Wake-on-LAN magic packets to multiple machines listed in a file.

enable-wol --interface-name <name>

Enables Wake-on-LAN on the specified network interface.

query-wol-status --interface-name <name>

Queries the Wake-on-LAN status of the specified network interface.

Hashing & Verification
get-sha2-256 --file <file>

Gets the SHA-256 hash of a file.

get-sha2-384 --file <file>

Gets the SHA-384 hash of a file.

get-sha2-512 --file <file>

Gets the SHA-512 hash of a file.

get-md5 --file <file>

Gets the MD5 hash of a file.

get-crc32-le --file <file>

Gets the CRC-32 hash (little-endian) of a file.

get-crc32-be --file <file>

Gets the CRC-32 hash (big-endian) of a file.

get-xxh-32 --file <file>

Gets the xxHash-32 hash of a file.

get-xxh-64 --file <file>

Gets the xxHash-64 hash of a file.

get-xxh3-64 --file <file>

Gets the xxHash3-64 hash of a file.

get-xxh3-128 --file <file>

Gets the xxHash3-128 hash of a file.

check-sha2-256 --file <file> --hash <checksum>

Verifies the SHA-256 hash of a file matches the expected checksum.

check-sha2-512 --file <file> --hash <checksum>

Verifies the SHA-512 hash of a file matches the expected checksum.

check-xxh3-128 --file <file> --hash <checksum>

Verifies the xxHash3-128 hash of a file matches the expected checksum.

System Information
get-id

Gets the machine’s BIOS serial number (SMBIOS).

get-serial-number

Gets the machine’s serial number.

get-os

Gets the detected operating system.

get-win-key

Gets the Windows product key from the BIOS.

info

Displays NETSetup version and build information.

Disk Management
list-disk

Lists all disks on the machine.

list-usb

Lists all USB drives on the machine.

select-disk [--index <index>]

Selects a disk by index for installation.

Encryption
encrypt --text <text>

Encrypts a text string using the NETSetup encryption key (for use in config files).

decrypt --text <text>

Decrypts a NETSetup-encrypted text string.

Serving
serve-file --file <file> [--port <port>]

Serves a single file over HTTP. Default port: 80.

serve-folder [--path <path>] [--port <port>]

Serves a folder over HTTP. Default path: current directory. Default port: 80.

AI Assistant
assistant [--prompt <prompt>] [--model <model>]

Connects to the Claude API for NETSetup-related questions. Without --prompt, enters interactive REPL mode. With --prompt, sends a single question and exits. Uses the machine’s NETSetup config as context. Optional --model overrides the default model.

Debug / Development
example-log-output

Outputs example log messages at various log levels (for testing log configuration).

example-log-output-to-files

Outputs example log messages to log files (for testing file log configuration).

Examples
.\NETSetup.exe set-ipv4 --ip 192.168.1.123 --netmask 255.255.255.0 --gateway 192.168.1.1 --dns 8.8.8.8

.\NETSetup.exe get-sha2-256 --file c:\temp\document.docx

.\NETSetup.exe connect-wifi --ssid swisscom --password abcd-1234-efgh-5678-ijkl

.\NETSetup.exe install

.\NETSetup.exe update

.\NETSetup.exe create-iso --offline --ticket 12345 --hostname DC01

.\NETSetup.exe assistant --prompt "What software is configured for this machine?"

.\NETSetup.exe serve-folder --path C:\NETSetup --port 8080

.\NETSetup.exe wake-machine --mac-address 00:15:5D:AB:CD:EF --broadcast 192.168.1.255

Developer Info

When you push changes to the NETSetup repository, the corresponding Git Action tests, builds, and updates the executable and the ISO-Image automatically.

The entrypoint for the executable is CLI/NETSetup.cs/Main(). There it adds all the commands to the application.

The Install command is in CLI/Commands/Core/InstallCommand.cs. CLI commands are split by domain under CLI/Commands/<Domain>/ — see Core, Build, Info, Hash, Network, Disk, Windows, Files, Dev. Naming: XxxCommand.cs = single CLI cmd, XxxCommands.cs = multiple related cmds.

You can create an offline ISO with "__main__.cs > [TestMethod] CreateOffline"

If Offline Install ensure Image, Drivers and Config are copied onto USB Stick. If singular computer ensure the Config and MapFile as described in Requirements

Linux PE Notes

Void Linux PE (UnixPE)

Void Linux musl serves as the "Linux Pre-Execution Environment" (UnixPE) in NETSetup. It boots from a squashfs image on the target disk’s ESP partition and runs NETSetup to install Linux-based operating systems (Proxmox, NixOS, etc.).

Architecture Overview

USB Stick (WinPE)
  │
  ├─ NETSetup.exe install (Phase 1: WinPE)
  │   ├─ Partitions target disk: GPT, 4GB FAT32 ESP (label=SYSTEM)
  │   ├─ Copies linux/ folder → Z:\ (GRUB + Void squashfs)
  │   ├─ Copies NETSetup binary → Z:\NETSetup\NETSetup.musl
  │   ├─ Copies config + OS image (e.g. proxmox.iso) → Z:\NETSetup\
  │   └─ Reboots (USB can be removed)
  │
Target Disk ESP (label=SYSTEM)
  │
  ├─ /EFI/BOOT/BOOTX64.EFI          ← GRUB bootloader
  ├─ /boot/vmlinuz                    ← Void Linux kernel
  ├─ /boot/initrd                     ← Void Linux initramfs
  ├─ /boot/grub/grub.cfg → grub_void.cfg
  ├─ /LiveOS/squashfs.img             ← Void Linux rootfs (compressed)
  │   └─ LiveOS/ext3fs.img            ← Void Linux rootfs (ext3, ~3GB)
  │       └─ / (full Void musl system)
  │           └─ /etc/rc.local        ← entrypoint: searches for autoexecute.sh
  ├─ /NETSetup/NETSetup.musl          ← NETSetup binary (linux-musl-x64)
  ├─ /NETSetup/Config/*.json           ← customer config
  └─ /NETSetup/Images/{OS}/            ← OS installer ISO

Boot Chain

  1. UEFI firmware boots GRUB from ESP (/EFI/BOOT/BOOTX64.EFI)

  2. GRUB loads grub_void.cfg → boots "UnixPE" menu entry

  3. Kernel + initrd boot, dracut live module:

    1. Finds squashfs.img on partition LABEL=SYSTEM

    2. Mounts squashfs (read-only) → loop-mounts ext3fs.img

    3. Creates overlayfs (rw tmpfs on top of ro rootfs)

    4. Pivots root to overlayfs

  4. Void Linux init runs → /etc/rc.local executes

  5. rc.local scans all partitions for autoexecute.sh

  6. Finds it on the SYSTEM partition → runs it

  7. autoexecute.sh launches NETSetup.musl install

  8. NETSetup Stage 4 (LinuxPE) installs the target OS via GRUB chainload

Disk Layout on Target (after Phase 1)

/dev/sdX (or nvme0n1)
├─ Partition 1: FAT32 ESP, 4GB, label=SYSTEM
│   ├─ EFI/BOOT/BOOTX64.EFI
│   ├─ boot/ (vmlinuz, initrd, grub/)
│   ├─ LiveOS/squashfs.img
│   ├─ NETSetup/ (binary, config, images)
│   └─ answer.toml (for Proxmox auto-install)
└─ Partition 2: (created by Stage 4) raw ISO partition for OS installer

squashfs.img Structure

The squashfs contains a single file: LiveOS/ext3fs.img — a ~3GB ext3 filesystem with the full Void Linux musl rootfs.

Key files inside:

Path Purpose

/etc/rc.local

Boot entrypoint — scans drives for autoexecute.sh, runs NETSetup

/usr/bin/ntfs-3g

NTFS mount support (needed to read USB sticks)

/usr/lib/libstdc++.so.6

C++ standard library — must match .NET runtime requirements

How to Modify the Void PE Image

Prerequisites

  • WSL (Ubuntu) with squashfs-tools installed: sudo apt install squashfs-tools

  • The squashfs.img lives at: src/NETSetup/linux/LiveOS/squashfs.img

  • Helper scripts in the same folder: open-void-pe.sh, close-void-pe.sh, edit-netsetup-call.sh

Open (unsquash + mount)

From WSL Ubuntu, cd to the LiveOS/ folder:

cd /mnt/c/_gh/main/netsetup/src/NETSetup/linux/LiveOS

# Step 1: unsquash (creates void/LiveOS/ext3fs.img)
sudo unsquashfs -f -d void squashfs.img

# Step 2: copy ext3fs.img to Linux FS (loop mount doesn't work on /mnt/c)
sudo mkdir -p /mnt/void
sudo cp void/LiveOS/ext3fs.img /tmp/ext3fs.img
sudo mount -o loop /tmp/ext3fs.img /mnt/void
Note
Loop-mounting on /mnt/c (Windows FS via 9p/DrvFs) does not work. You MUST copy ext3fs.img to the native Linux filesystem (e.g. /tmp/) before mounting.

Modify files

# Edit rc.local
sudo nano /mnt/void/etc/rc.local

# Or browse the filesystem
ls /mnt/void/usr/lib/libstdc++*

Chroot (for package management)

Important
You CANNOT chroot from glibc Ubuntu into musl Void — the binaries will not run. Use static xbps instead:
# Download static xbps (runs on any Linux x86_64)
cd /tmp
wget https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz
tar xf xbps-static-latest.x86_64-musl.tar.xz

# Upgrade all packages in the mounted rootfs
sudo /tmp/usr/bin/xbps-install.static -r /mnt/void -Su

# Install a specific package
sudo /tmp/usr/bin/xbps-install.static -r /mnt/void -S <package-name>

# Query installed packages
sudo /tmp/usr/bin/xbps-query.static -r /mnt/void -l

Alternatively, if you have a real Void Linux musl system (VM, live USB, etc.), you can chroot /mnt/void directly and use normal xbps-install -Su.

Close (unmount + repack)

# Step 1: unmount
sudo umount /mnt/void

# Step 2: copy modified image back
sudo cp /tmp/ext3fs.img void/LiveOS/ext3fs.img

# Step 3: fsck
sudo e2fsck -p -f void/LiveOS/ext3fs.img

# Step 4: verify
dumpe2fs -h void/LiveOS/ext3fs.img | grep "Filesystem state"

# Step 5: repack squashfs (xz compression)
sudo rm squashfs.img
sudo mksquashfs void squashfs.img -comp xz -noappend

# Step 6: cleanup
sudo rm -rf void
sudo rm /tmp/ext3fs.img

How to Create a New Void PE Image from Scratch

When upgrading to a new Void Linux version or starting clean.

Step 1: Download Void Linux musl ROOTFS

cd /tmp
# Get the latest musl rootfs tarball from:
# https://voidlinux.org/download/ → "Base Tarballs" → x86_64-musl
wget https://repo-default.voidlinux.org/live/current/void-x86_64-musl-ROOTFS-<DATE>.tar.xz

Step 2: Create ext3fs.img

# Create a 3GB ext3 image (adjust size as needed)
dd if=/dev/zero of=/tmp/ext3fs.img bs=1M count=3072
mkfs.ext3 /tmp/ext3fs.img

# Mount it
sudo mkdir -p /mnt/void
sudo mount -o loop /tmp/ext3fs.img /mnt/void

# Extract rootfs
sudo tar xpf void-x86_64-musl-ROOTFS-*.tar.xz -C /mnt/void

Step 3: Install required packages

# Copy DNS config so xbps can resolve repos
sudo cp /etc/resolv.conf /mnt/void/etc/resolv.conf

# Use static xbps to install packages
cd /tmp
wget https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz
tar xf xbps-static-latest.x86_64-musl.tar.xz

# Update package database and install required packages
sudo /tmp/usr/bin/xbps-install.static -r /mnt/void -Syu
sudo /tmp/usr/bin/xbps-install.static -r /mnt/void -S \
    ntfs-3g \
    parted \
    dosfstools \
    e2fsprogs \
    grub-x86_64-efi \
    lsblk \
    udev \
    kmod \
    dracut \
    wifi-firmware \
    wpa_supplicant \
    dhclient

Step 4: Configure rc.local

Copy the rc.local from the existing image or write a new one. The rc.local must:

  1. Scan all partitions (lsblk) for autoexecute.sh

  2. Mount each partition (handle NTFS via ntfs-3g, other via mount -t)

  3. When found, copy to /root/autoexecute.sh, chmod +x, and exec it

See the current rc.local in the existing image for the full implementation.

Step 5: Extract kernel + initrd

# The kernel and initrd for GRUB must come from this rootfs
sudo cp /mnt/void/boot/vmlinuz-<version> /mnt/c/_gh/main/netsetup/src/NETSetup/linux/boot/vmlinuz
sudo cp /mnt/void/boot/initramfs-<version>.img /mnt/c/_gh/main/netsetup/src/NETSetup/linux/boot/initrd
Important
The kernel (vmlinuz) and initrd in linux/boot/ must match the kernel installed in the squashfs rootfs. If you update the kernel inside the image, you MUST also update these files.

Step 6: Pack into squashfs

sudo umount /mnt/void

# Create squashfs directory structure
mkdir -p /tmp/void-squash/LiveOS
mv /tmp/ext3fs.img /tmp/void-squash/LiveOS/ext3fs.img

# Pack
sudo mksquashfs /tmp/void-squash /mnt/c/_gh/main/netsetup/src/NETSetup/linux/LiveOS/squashfs.img -comp xz -noappend

# Cleanup
sudo rm -rf /tmp/void-squash

Updating libstdc++ (fixing .NET 10 compatibility)

NET 10’s native runtime components require libstdc++ from GCC 12+.

The current image has libstdc++.so.6.0.28 (GCC 10) which causes:

Error relocating NETSetup.musl: _ZSt28__throw_bad_array_new_lengthv: symbol not found

Fix: Update packages in existing image

Follow "How to Modify the Void PE Image" above, then:

# Upgrade all packages (pulls in GCC 12+ libstdc++)
sudo /tmp/usr/bin/xbps-install.static -r /mnt/void -Su

# Verify libstdc++ version (should be 6.0.32 or newer)
ls -la /mnt/void/usr/lib/libstdc++.so*

Then close/repack the image.

Updating the kernel + initrd

After a full system upgrade, the kernel version may change. You need to update the boot files in linux/boot/:

# Check which kernel version is installed
ls /mnt/void/boot/vmlinuz-*
ls /mnt/void/boot/initramfs-*.img

# Copy to the boot folder
sudo cp /mnt/void/boot/vmlinuz-<new-version> /mnt/c/_gh/main/netsetup/src/NETSetup/linux/boot/vmlinuz
sudo cp /mnt/void/boot/initramfs-<new-version>.img /mnt/c/_gh/main/netsetup/src/NETSetup/linux/boot/initrd

GRUB Configuration

grub.cfg (main)

Loads filesystem modules and sources grub_void.cfg:

insmod usbms
insmod part_gpt
insmod fat
insmod iso9660
insmod ntfs
insmod linux
search --file --no-floppy --set=voidlive "/boot/grub/grub_void.cfg"
source "(${voidlive})/boot/grub/grub_void.cfg"

grub_void.cfg (Void Linux boot entry)

menuentry "UnixPE" {
    linux (${voidlive})/boot/vmlinuz \
        root=live:LABEL=SYSTEM ro init=/sbin/init \
        rd.live.overlay.overlayfs=1 lockdown=none
    initrd (${voidlive})/boot/initrd
}

Key kernel parameters:

  • root=live:LABEL=SYSTEM — dracut live module finds squashfs on partition labeled SYSTEM

  • rd.live.overlay.overlayfs=1 — use overlayfs (writable tmpfs layer on top of read-only rootfs)

  • lockdown=none — allow unsigned modules

NETSetup adds installer entry at runtime

Stage 4 (LinuxPE) appends a second GRUB menu entry to grub_void.cfg that boots the OS installer ISO from partition 2 via rdinit= wrapper. See BootISOGrub2.cs and the process diagram in docs/NETSetup.puml.

rc.local Boot Flow

/etc/rc.local
  │
  ├─ Lock file check (/var/lock/autoexecute.lock)
  ├─ Scan all partitions (lsblk: sd*, nvme*, hd*, sr*)
  │   ├─ Skip: already mounted, no filesystem, swap
  │   ├─ Mount: NTFS via ntfs-3g, others via mount -t $fstype
  │   └─ Search for autoexecute.sh on each partition
  │
  ├─ Found autoexecute.sh:
  │   ├─ Copy to /root/autoexecute.sh (strip \r)
  │   ├─ chmod +x
  │   └─ exec → NETSetup.musl install
  │
  └─ Already has /root/autoexecute.sh:
      └─ Re-exec if not already running

File Inventory

File Purpose Tracked in git?

linux/EFI/BOOT/BOOTX64.EFI

GRUB EFI bootloader

Yes

linux/boot/vmlinuz

Void Linux kernel

Yes (LFS recommended)

linux/boot/initrd

Void Linux initramfs

Yes (LFS recommended)

linux/boot/grub/grub.cfg

GRUB main config

Yes

linux/boot/grub/grub_void.cfg

Void Linux boot menu

Yes

linux/boot/grub/splash.png

GRUB background

Yes

linux/LiveOS/squashfs.img

Void Linux rootfs (compressed)

Yes (LFS recommended)

linux/LiveOS/void/

Extracted squashfs (working dir)

No (.gitignore)

linux/LiveOS/open-void-pe.sh

Helper: unsquash + mount

Yes

linux/LiveOS/close-void-pe.sh

Helper: unmount + repack

Yes

linux/LiveOS/edit-netsetup-call.sh

Helper: edit rc.local

Yes

Troubleshooting

_ZSt28__throw_bad_array_new_lengthv: symbol not found

libstdc is too old for .NET 10. See <<Updating libstdc (fixing .NET 10 compatibility)>>.

Loop mount fails on /mnt/c

WSL’s DrvFs (9p) does not support loop devices. Copy ext3fs.img to native Linux FS (/tmp/) first.

Cannot chroot from Ubuntu into Void

Ubuntu uses glibc, Void uses musl — binaries are incompatible. Use static xbps (xbps-install.static -r /mnt/void) or a real Void Linux system.

Kernel panic on boot

Kernel (vmlinuz) and initrd in linux/boot/ don’t match the rootfs. After upgrading the kernel inside the squashfs, copy the new vmlinuz + initrd to linux/boot/.

osisa NETSetup Master

where what

What is NETSetup

NETSetup is a collection of processes and tools to run any aspect of an IT Company. It provides processes to onboard employees handling support cases, provide a process to get information from what the customer needs, to offering, ordering, implementing and supporting whole network infrastructures.

Purpose of this document

This is the starting root of all NETSetup o.s.i.s.a. documentations. Working through this documentation will provide any user of NETSetup with sufficent information to run the NETSetup proccess.

Pre-requisites to work with this documentation

Technologies to be used in NETSetup v3.0

These technologies are to be used in NETSetup 3.0:

  • Fileserver, Mailserver, Firewall, Webserver via Proxmox

  • File storage will use IPFS (interplanetary filesystem)

  • Name resolution will use IPNS (interplanetary nameserver)

  • Every business has a database with OrbitDB (Database with IPFS), local backups should exist

  • Software distribution will happen with our own "IPIS" (interplanetary Installation server)

    • This will also include our NETBase

    • When all this works, we should be able to "get rid" of our financial accounnting software because this will happen automatically

      • Eventually, the automated accounting will use wallets instead of bank accounts

Our business uses three main Repositories:

  • NETSetup: This repo is concerned with installing an OS / getting a computer up and running

  • NETBase: This repo contains all our business logic

  • Laufentaler: This repo contains the currency we intend to use in the future

Further read

Step by step guide on how to install Windows using osisa NETSetup HERE (gh Pages) and HERE (gh Pages source) and HERE (old documentation)

How to register a device in Autopilot

How to install KMULine

How To install Dropbox

How To update Alltron product data

How To assure the correct set up of a new development machine

How To create new GitHub Repository

addition from release 0.6.0

Osisa NETSetup Diagram

Explanation of terms:

Config: Whole customer configuration (unique per customer)

Setup: Specific Version of a customer hardware/workstation setup

Ensure Item: Either locate existing item or create new item

@startuml
!pragma teoz true



'Actors



'Customer Box

box Customer #lightyellow
actor Customer
end box

'Osisa Box

box osisa #lightblue
actor "Agent (public)" as Agent
actor "Ticketing (public)" as Ticketing
actor "Accounting (private)" as Accounting
actor "NETSetup (private)" as NETSetup
end box

'Suppliers Box

box Suppliers #lightgreen
actor "Supplier (Alltron)" as Supplier
actor "Software Vendor (Microsoft)" as SWVendor
actor "Hardware Vendor (HP)" as HWVendor
end box



'Variables



'Contact and Ticket

!$callAgent = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/050%20Kontaktaufnahme/Kontaktaufnahme%20V1.1.adoc callAgent() -> new order]]"
!$createTicket = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/050%20Kontaktaufnahme/Ticketverwaltung%20in%20Freshdesk.adoc createNETSetupTicket(order) -> new ticket]]"
!$notifyCustomer = "notifyCustomer(ticket)"

'Quote Process

!$requestCurrentMode = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/100%20Offerte/Offertprozess.adoc#ist-soll-analyse requestCurrentMode(ticket) -> ensure(ticket.customer) config<currentMode> ]]"
!$responseCurrentMode = "responseCurrentMode(ticket) -> update config<updatedCurrentMode>"
!$requestFutureMode = "requestFutureMode(config<updatedCurrentMode>)"
!$responseFutureMode = "responseFutureMode(config<updatedCurrentMode>) -> config<futureMode>"
!$createQuote = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/100%20Offerte/Offertprozess.adoc#offerterstellung createQuote(config<updatedCurrentMode>, config<futureMode>) -> new quote]]"
!$returnQuote = "returnQuote(quote)"
!$sendQuote = "sendQuote(quote)"
!$responseQuote = "responseQuote(quote)"

'Order Process

!$createOrder = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/230%20Beschaffung/Beschaffung.adoc createOrder(quote) -> new order]]"
!$confirmOrder = "confirmOrder(order)"
!$updateCustomer = "updateCustomer(customer)"
!$registerCustomer = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/250%20Setup/Kundenerfassung%20in%20NETSetup-Datenbank.adoc registerCustomer(databaseEntry<newCustomer>) -> new customer]]"
!$createSetup = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/250%20Setup/Softwareinfoblatt%20NETSetup-Datenbank.adoc createSetup(config<futureMode>) -> new setup]]"
!$returnSetup = "returnSetup(setup)"
!$deliverOrder = "deliverOrder(setup)"

'Setup Process Server

!$plugInServer = "plugInServer(setup)"
!$configureRaid = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/250%20Setup/Installation%20Server.adoc#festplatten-installieren-und-raid-konfigurieren configureRaid()]]"
!$setUpServer = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/250%20Setup/Installation%20Server.adoc#aufsetzen-per-netsetup setUpServer(setup)]]"
!$configureServer = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/250%20Setup/Installation%20Server.adoc#konfiguration configureServer()]]"
!$setUpFirewall = "firewallSetup()"
!$setUpAccessPoints = "accesspointsSetup()"

'Setup Process Client

!$plugInClient = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup.adoc plugInHardware(setup)]]"
!$registerHash = "registerHardwareHash(setup)"
!$rebootClient = "reboot(setup)"
!$requestLogin = "requestLogin()"
!$login = "login()"
!$osSetup = "[[https://github.com/osisa/NETSetup/tree/develop/NETSetup.WinPEBoot TargetOSSetup(setup)]]"
!$domainJoin = "domainJoin(setup)"

'Last Steps

!$backupCheck = "targetBackupCheck()"
!$backupRecoveryTest = "targetBackupRecoveryTest()"
!$backupSelfTest = "targetBackupSelfTest()"
!$requestCablingProtocol = "networkCablingMeasurementRequest(Protocol)"
!$returnCablingProtocol = "return(Protocol)"
!$validateCablingProtocol = "ProtocolValidate()"
!$setDeliveryDate = "setTicketDeliveryDate(setup.deliveryDate)"
!$deliverMaterial = "[[https://github.com/osisa/NETSetup/blob/develop/docs/NETSetup/300%20Auslieferung/Auslieferung.adoc deliverOrder]](setup and [[https://github.com/osisa/NETSetup/tree/develop/docs/NETSetup/400%20Abnahme setup.approvalForm]], training material and awareness test)"
!$fillOutMaterial = "fillOut(setup.approvalForm and awareness test)"

'Billing and Ending

!$comissionBill = "[[https://github.com/osisa/NETSetup/blob/develop/docs/Infobl%C3%A4tter/Software/Sage/Sage%20Start/Softwareinfoblatt%20Sage%20Start.adoc commissionBill(setup)]]"
!$returnBill = "returnBill(setup.bill)"
!$sendBill = "sendBill(setup.bill)"
!$payBill = "payBill(setup.bill)"
!$notifyAccounting = "notify(setup.bill.paid())"
!$closeTicket = "closeTicket()"



'Netsetup Diagram for Client
== Netsetup Diagram for Client ==



'Contact and Ticket

Customer -> Agent : $callAgent
Agent -> Ticketing : $createTicket
Ticketing -> Customer : $notifyCustomer

'Quote Process

Agent -> Customer : $requestCurrentMode
Customer -> Agent : $responseCurrentMode
group If customer not\nSatisfied: repeat
Agent -> Customer : $requestFutureMode
Customer -> Agent : $responseFutureMode
Agent -> Accounting : $createQuote
Accounting -> Agent : $returnQuote
Agent -> Customer : $sendQuote
Customer -> Agent : $responseQuote
end

'Order Process

Agent -> Supplier : $createOrder
Supplier -> Agent : $confirmOrder
alt if customer is already registered:
Agent -> NETSetup : $updateCustomer
else else
Agent -> NETSetup: $registerCustomer
end
Agent -> NETSetup : $createSetup
NETSetup -> Agent : $returnSetup
Supplier -> Customer : $deliverOrder

'Setup Process Client

loop for n clients do
Agent -> Agent: $plugInClient
Agent -> SWVendor : $registerHash
Agent -> SWVendor : $rebootClient
SWVendor -> Agent: $requestLogin
Agent -> SWVendor : $login
SWVendor -[#red]> Agent: $osSetup
Agent -> Agent : $domainJoin
end

'Last Steps

Agent -> Agent : $backupCheck
Agent -> Agent : $backupRecoveryTest
Agent -> Agent : $backupSelfTest
Agent -> Customer : $requestCablingProtocol
Customer -> Agent : $returnCablingProtocol
Agent -> Agent : $validateCablingProtocol
Agent -> Ticketing : $setDeliveryDate
Agent -> Customer : $deliverMaterial
Customer -> Agent : $fillOutMaterial

'Billing and Ending

Agent -> Accounting : $comissionBill
Accounting -> Agent : $returnBill
Agent -> Customer : $sendBill
Customer -> Accounting : $payBill
Accounting -> Agent : $notifyAccounting
Agent -> Ticketing : $closeTicket



'NETSetup Diagram for Network (Client + Server)
== Diagram for Network (Client + Server) ==



'Contact and Ticket

Customer -> Agent : $callAgent
Agent -> Ticketing : $createTicket
Ticketing -> Customer : $notifyCustomer

'Quote Process

Agent -> Customer : $requestCurrentMode
Customer -> Agent : $responseCurrentMode
group If customer not\nSatisfied: repeat
Agent -> Customer : $requestFutureMode
Customer -> Agent : $responseFutureMode
Agent -> Accounting : $createQuote
Accounting -> Agent : $returnQuote
Agent -> Customer : $sendQuote
Customer -> Agent : $responseQuote
end

'Order Process

Agent -> Supplier : $createOrder
Supplier -> Agent : $confirmOrder
alt if customer is already registered:
Agent -> NETSetup : $updateCustomer
else else
Agent -> NETSetup: $registerCustomer
end
Agent -> NETSetup : $createSetup
NETSetup -> Agent : $returnSetup
Supplier -> Customer : $deliverOrder

'Setup Process Server

Agent -> Agent : $plugInServer
Agent -> Agent : $configureRaid
Agent -> NETSetup : $setUpServer
Agent -> Agent : $configureServer
Agent -> Agent : $setUpFirewall
Agent -> Agent : $setUpAccessPoints

'Setup Process Client

loop for n clients do
Agent -> Agent: $plugInClient
Agent -> SWVendor : $registerHash
Agent -> SWVendor : $rebootClient
SWVendor -> Agent: $requestLogin
Agent -> SWVendor : $login
SWVendor -[#red]> Agent: $osSetup
Agent -> Agent : $domainJoin
end

'Last Steps

Agent -> Agent : $backupCheck
Agent -> Agent : $backupRecoveryTest
Agent -> Agent : $backupSelfTest
Agent -> Customer : $requestCablingProtocol
Customer -> Agent : $returnCablingProtocol
Agent -> Agent : $validateCablingProtocol
Agent -> Ticketing : $setDeliveryDate
Agent -> Customer : $deliverMaterial
Customer -> Agent : $fillOutMaterial

'Billing and Ending

Agent -> Accounting : $comissionBill
Accounting -> Agent : $returnBill
Agent -> Customer : $sendBill
Customer -> Accounting : $payBill
Accounting -> Agent : $notifyAccounting
Agent -> Ticketing : $closeTicket

@enduml



@enduml

Steps required to setup a fresh development machine

---

setup using netsetup

(you might need mermaid-cli aka mmdc, npm install -g @mermaid-js/mermaid-cli, and add "%USERPROFILE%\AppData\Roaming\npm" to path)

set-executionpolicy -executionpolicy bypass -scope localmachine -force

Set-ExecutionPolicy Bypass -Scope Process -Force; iex New-Object System.Net.WebClient).DownloadString('http://netsetupprod.osisa.com:8080/install.ps1'

choco source add --name=netsetup --source=http://netsetupprod.osisa.com:8080/chocolatey

cinst -y firefox git git-lfs vscode chocolateygui 7zip dotnetcore dotnet dropbox googlechrome infoniqaonestart2023_00 kmuline netsetupdb notepadplusplus office365netsetup teamviewer teams vlc windirstat asciidocfx putty ruby

2-machine firewall rdp wallpaper vpnToConfiguredVPNServer(connection to domain controller)

install-language en-us

set-winuilanguageoverride en-us

$LanguageList = Get-WinUserLanguageList ; $LanguageList.Add("en-US") ; $firstLang = $LanguageList[0].LanguageTag ; $LanguageList[0] = $LanguageList[-1] ; $LanguageList[-1] = $firstLang ; Set-WinUserLanguageList $LanguageList -force

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "dontdisplaylastusername" -Value 1

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "PromptOnSecureDesktop" -Value 0

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "shutdownwithoutlogin" -Value 0

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "ConsentPromptBehaviorAdmin" -Value "0"

Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "LocalAccountTokenFilterPolicy" -Value 1

Get-NetConnectionProfile | ForEach-Object { Set-NetConnectionProfile -NetworkCategory Private -Name $.Name Set-NetConnectionProfile -NetworkDiscoveryEnabled true -Name $.Name }

[System.Environment]::SetEnvironmentVariable('DOTNET_ENVIRONMENT','Development',[System.EnvironmentVariableTarget]::Machine)

[System.Environment]::SetEnvironmentVariable('MSBuildEnableWorkloadResolver','true',[System.EnvironmentVariableTarget]::Machine)

[System.Environment]::SetEnvironmentVariable('MSDOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLE','false',[System.EnvironmentVariableTarget]::Machine)

git config --global --add safe.directory /git/

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All

reboot

domain join

login as your user (must be an admin)

3-user

dotnet nuget remove source github

dotnet nuget add source --name github "https://nuget.pkg.github.com/osisa/index.json" -u soeren.maske@osisa.com -p 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 --store-password-in-clear-text

"./nuget.exe setapikey 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 -Source github"

dotnet tool install --global GitVersion.Tool

dotnet tool install --global gpr

dotnet tool install --global netbase.build --prerelease --no-cache

dotnet tool update --global netbase.build --prerelease --no-cache

gem install asciidoctor

gem install asciidoctor-diagram

asciidoctor -r asciidoctor-diagram -a diagrams docs/ghpages.adoc

Windows SDK

add newest win to path, eg C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64

Windows ADK Windows EDK

Install-Module -Name OpenSSL


systemmanagement.contracts

-> isoftware
-> icomputer
-> iuser

usermanagement.contracts

-> irole
-> igroup
-> imembership
-> idomain

human.contracts

-> icontact
-> inaturalperson
-> iperson

localization.contracts

-> ilocale
-> itimezone

enterprise.contracts

-> icompany
-> iorganizationalunit
-> iemployee
-> icustomer
-> isupplier
-> idevice
-> itask
-> iservice

netsetup.contracts

-> inetsetupsoftware [new: packagename]
-> inetsetupcontact [new: naturalperson]
-> inetsetupcustomer [new: conclusionDate,
							documentationLink]
-> inetsetupmanageduser [new: associated computers]
-> inetsetupdomain [new: groups replaced with functionality groups,
						computer list,
						contact list replaces person list]
-> iconfig has domain, contacts, software

BSP

funcionality = accounting

accounting.softwareversion[0] = infoniqa v2023

accounting.softwareversion[1] = office 365

accounting.membership[0] = remo.oser [role: head]

Wake-on-LAN (WOL) Integration with NETSetup

The NETSetup process can integrate with Wake-on-LAN (WOL) to remotely trigger machine setup or maintenance tasks. This is particularly useful for automated deployments.

The following outlines how WOL can be configured and utilized:

Prerequisites

Your network and hardware must support WOL. This typically involves:

  • BIOS/UEFI settings enabling WOL for the network adapter.

  • Network interface card (NIC) supporting WOL.

  • Proper network configuration to allow WOL packets.

Configuring WOL on Linux Targets

For Linux-based systems that netsetup might be deploying or managing, WOL can be configured as follows:

Required Packages

Install the necessary packages:

sudo apt-get update
sudo apt-get install build-essential libelf-dev ethtool

Identifying Network Interfaces

Network interfaces are often named enp1s0 and enp2s0 on systems like H4. Check your system’s interfaces with:

ip link

Checking and Enabling WOL

  • Check if WOL is enabled for an interface (e.g., enp1s0) using:

ethtool enp1s0 | grep Wake
  • Alternatively, check all interfaces:

grep . /sys/class/net/*/device/power/wakeup 2>/dev/null
  • Temporarily enable WOL for an interface:

sudo ethtool -s enp1s0 wol g

Persistent WOL Configuration

To enable WOL persistently across reboots, create a systemd service:

# /etc/systemd/system/wol@.service
[Unit]
Description=Wake-on-LAN for %i
Requires=network.target
# After=network.target
After=network-online.target

ExecStart=/bin/sh -c "ethtool -s %i wol g" Type=oneshot

WantedBy=multi-user.target

After creating the service file, enable and start it for your network interface (e.g., enp1s0):

sudo systemctl enable wol@enp1s0.service
sudo systemctl start wol@enp1s0.service

Note: The netsetup command itself may have options or configurations to trigger WOL or manage these settings on target machines. Further details on netsetup specific WOL commands would be found in its dedicated command-line documentation.

Proxmox VM provisioning during update

Feature ship. netsetup update on Proxmox host now spin customer VMs auto.

Three flow. Pick by Machine.OperatingSystem:

  • NixOS flow — os match NixNextcloud, NixIPFS, NixOS. Boot generic nixos.iso (qemu-guest-agent on, master pubkey bake in). Wait guest-agent report ipv4. Pick template via NixTemplateFactory.ForMachine(machine). Emit flake/default/hardware nix to /NETSetup/generated-nix/<vm>. scp config to VM /nixos-config/, ssh install.sh -h <name> -d <disk> via master key.

  • Windows flow — os match Windows10/Windows11/WindowsServer201x/202x. Build per-VM offline NETSetup-{ticket}-{host}.iso via existing create-iso pipeline. Upload to Proxmox iso storage. Stealth hardware iff Win11. Boot VM from ISO, unattended install run normal.

  • OPNsense flow (Liberator-gated) - os match OPNSense. Only runs when host.Description == "Liberator". Prebuilt raw disk image at /var/lib/vz/template/iso/opnsense.img (synced via EnsureOpnsenseImage.EnsureOpnsenseImageOnProxmoxHost from Dropbox NETSetup/BOOT/Images/Opnsense.7z). qm create SeaBIOS, 5 vNICs Option Y (net0 untagged WAN, net1 tag=10 server tier, net2 tag=20 LAN, net3 tag=250 MGMT, net4 tag=254 Guest), qm importdisk raw image, qm set --scsi0 …​ --boot order=scsi0, qm start. First-boot OpnsenseInstall SSHes in with master key, scps config.xml (5 if assignments, DHCP per VLAN, static maps from config.Computers[].MAC, DNS forwarder → NixDns, FW deny-default inter-VLAN), reloads services, persists api key. Order first in GetOrderedVmsForHost. See Liberator.adoc for full topology + role matrix.

Paths / keys / cadence:

  • Proxmox iso store: /var/lib/vz/template/iso/ (both nixos.iso and NETSetup-*.iso).

  • Generated nix configs: /NETSetup/generated-nix/<vm>. Rewrite each update cycle.

  • Master SSH key: pub bake into nixos.iso at src/NETSetup/nixos/netsetup-master.pub. Priv extract at runtime to /NETSetup/.ssh/netsetup-master.key (mode 0600).

  • Dropbox source of truth for nixos.iso: NETSetup/ISOs/nixos.iso.7z + nixos.iso.sha256. EnsureNixosIsoOnProxmoxHost check sha, download+7z-extract if miss/mismatch.

  • Cadence: every netsetup update call hit this path. Idempotent (skip existing VMs, skip ISO sync when sha match).

  • Fail mode: per-VM error log + continue. Aggregate throw at end list all fail VM name.

Entry point: UpdateMethods.RunProxmoxUpdate append EnsureCustomerVms.Run(host, config, services) after RunBtrfsScrub.

VM order: OPNsense first (router boots before downstream), then DC (WindowsServer), then everything else (CreateVirtualMachines.GetOrderedVmsForHost).

Gateway model: NETSetupConfig.GatewayIP (separate from IRouter device). Phase 1 = upstream port (today’s flash-mikrotik baseline). Phase 2 (shipped) = OPNsense LAN takeover via EnsureGatewayTakeover (rewrites /etc/network/interfaces gateway line + flips in-memory config.GatewayIP so NixOS VMs follow next cycle). Idempotent, best-effort, gated on OPNsense LAN ping success. Triggered on Liberator hosts only. See firewall.adoc for code map + Windows-DC follow-up. See Liberator.adoc for topology, VLAN map, cutover sequence, role gate.

Sequence diagram: see Diagrams section.

Host network converge (Phase 3e in NETSetup.puml) lives in 4 idempotent helpers, all gated on host.Description == "Liberator" && hasOpnsenseVm:

  • ConfigureOpnsenseVlans - REST API push (DHCP, DNS-forwarder, FW aliases + filter, apply)

  • ConfigureMikrotikVlans - REST API push (bridge VLAN-aware, LACP bond, VLANs 10/20/250/254, WiFi SSIDs, strip flat NAT + DHCP)

  • ConfigureProxmoxBridges - /etc/network/interfaces rewrite (bond0 LACP, vmbr0 VLAN-aware, vmbr0v250 host mgmt) with ifupdown2 safe-mode commit/rollback

  • RewireVmNicsToVlans - qm set <vmid> -netN …​,tag=<vlan> drift correction for existing VMs

Non-Liberator hosts (Power Station, vanilla Proxmox, other Description values) skip all 4 helpers + OPNsense provisioning entirely. VMs still provisioned on flat vmbr0 untagged via same GetOrderedVmsForHost serial-matching path.

Full design + topology + cutover sequence: Liberator.adoc.

Diagrams

Proxmox VM Provisioning sequence

@startuml NETSetupProvisionVms

title Proxmox VM Provisioning During `netsetup update`

actor Admin
participant "netsetup update\n(on Proxmox host)" as NS
participant "UpdateMethods\nRunProxmoxUpdate" as UM
participant "EnsureCustomerVms" as ECV
participant "EnsureNixosIso" as EI
participant "Dropbox\nNETSetup/ISOs" as DB
participant "Proxmox Host\n(corsinvest API)" as PH
participant "NixOS VM\n(generic nixos.iso)" as NIX
participant "Windows VM" as WIN

Admin -> NS: netsetup update
NS -> UM: RunProxmoxUpdate(host, config)
UM -> UM: apt update + full-upgrade
UM -> UM: btrfs scrub
UM -> ECV: EnsureCustomerVms(host, config)

group Nix path (any nix VM in config)
    ECV -> EI: EnsureNixosIsoOnProxmoxHost
    EI -> DB: GET nixos.iso.sha256
    EI -> EI: compare to local iso sha256
    alt mismatch or missing
        EI -> DB: GET nixos.iso.7z
        EI -> EI: verify sha256, 7z extract
        EI -> PH: place nixos.iso at\n/var/lib/vz/template/iso/
    end
end

loop for each VM (DC first)
    alt IsNixBacked(os)
        ECV -> PH: CreateVirtualMachine(nixos.iso)
        PH -> NIX: boot from nixos.iso\n(qemu-guest-agent enabled)
        ECV -> PH: qm guest network-get-interfaces\n(poll 2s, deadline 5m)
        PH --> ECV: ipv4
        ECV -> ECV: NixTemplateFactory.ForMachine\nemit flake.nix + default.nix + hardware.nix\nto /NETSetup/generated-nix/<vm>
        ECV -> NIX: scp -i netsetup-master.key\n-r <genDir> root@ip:/nixos-config/
        ECV -> NIX: ssh root@ip\ninstall.sh -h <name> -d <disk>
        NIX --> ECV: install OK, reboot
    else Windows
        ECV -> ECV: per-VM offline create-iso\nNETSetup-<ticket>-<host>.iso
        ECV -> PH: upload ISO to\n/var/lib/vz/template/iso/
        ECV -> PH: CreateVirtualMachine(NETSetup-ISO,\nstealth hw iff Win11)
        PH -> WIN: boot from NETSetup ISO\n(unattended install)
    else other
        ECV -> ECV: log + skip
    end
    ECV -> ECV: collect per-VM outcome
end

alt any failure
    ECV --> UM: throw NETSetupException\n(aggregate failed VM names)
else all success
    ECV --> UM: OK
end

UM --> NS: update complete
NS --> Admin: exit 0

@enduml

Liberator network topology

@startuml Liberator-Network

title Liberator network topology - hAP ax2 + Proxmox + OPNsense + VLANs

' Role discriminator = host.Description (DeviceTranslator.cs:297 pattern):
'   "Liberator"     -> full flow: Mikrotik upstream + OPNsense on host + VLANs
'   "Power Station" -> bare Proxmox, future cluster compute, no router/VLAN
'   "Proxmox" / other -> bare Proxmox, no router/VLAN
' Defensive double-gate: isLiberator && hasOpnsenseVm in config for host.SerialNumber.
'
' VLAN map: 10 = server tier, 20 = LAN clients, 250 = OOB/MGMT, 254 = Guest.
' WAN untagged on Mikrotik ether1.
' DHCP source of truth: OPNsense per VLAN. Static reservations from config.Computers[].MAC.
' DNS resolver: NixDns VM running Unbound. OPNsense forwards all clients to NixDns IP.

skinparam rectangle {
  BackgroundColor<<infra>> #E8F0FE
  BackgroundColor<<svc>>   #E6F4EA
  BackgroundColor<<fw>>    #FCE8E6
  BackgroundColor<<dns>>   #FEF7E0
  BackgroundColor<<net>>   #F3E8FD
}

cloud "ISP / GPON CPE\nuntagged DHCP" as NET

node "Mikrotik hAP ax2 (L2 only post phase 2)\nmgmt IP VLAN250 .1\nNO NAT, NO DHCP server" as MT <<net>> {
  rectangle "ether1 (2.5G) WAN\nuntagged DHCP" as MT_E1
  rectangle "bonding1 = ether2 + ether3\nLACP 802.3ad\ntrunk: 10,20,250,254" as MT_BOND
  rectangle "ether4 access\nVLAN20 untagged" as MT_E4
  rectangle "ether5 access (spare LAN port)\nVLAN20 untagged" as MT_E5
  rectangle "wlan 2.4 + 5 GHz\nSSID Liberator\nWPA2/3 -> VLAN20" as MT_W_LAN
  rectangle "wlan 2.4 GHz\nSSID Liberator-Guest\nopen, no portal -> VLAN254" as MT_W_GUEST
  rectangle "wlan 5 GHz hidden\nSSID admin-oob-lib\nWPA3 -> VLAN250" as MT_W_ADMIN
}

NET <--> MT_E1 : public IP

node "Proxmox host (Liberator)\nbond0 = enp1s0 + enp2s0 LACP\nvmbr0 VLAN-aware trunk\nhost mgmt = vmbr0v250 = .2" as PVE <<infra>> {

  rectangle "OPNsense VM (Option Y, 5 vNIC)\nserial 104@lib\n  net0 untagged   = WAN\n  net1 tag=10     = .1 server tier gw\n  net2 tag=20     = .1 LAN gw + DHCP\n  net3 tag=250    = .3 MGMT\n  net4 tag=254    = .1 Guest gw\nFW deny-default inter-VLAN\nNAT outbound\nDNS forwarder -> NixDns .53\nREST API key first-boot installed" as OPN <<fw>>

  rectangle "NixDns VM (Unbound)\nserial 103@lib\nvNIC tag=10 = .53\nVLAN10\nresolver + blocking +\nreverse + DDNS" as DNS <<dns>>
  rectangle "Win Server DC\nserial dc@lib\nvNIC tag=10 = .10\nVLAN10\nDNS = 127.0.0.1, .53" as DC <<svc>>
  rectangle "Nextcloud VM\nserial 100@lib\nvNIC tag=10 = .50\nVLAN10" as NC <<svc>>
  rectangle "Mail VM\nserial 102@lib\nvNIC tag=10 = .51\nVLAN10" as MAIL <<svc>>
  rectangle "PMG LXC\nctid 200@lib\nvNIC tag=10 = .52\nVLAN10" as PMG <<svc>>
  rectangle "Win11 client VM\nserial 201@lib\nvNIC tag=20\nDHCP from OPNsense VLAN20" as W11 <<svc>>
}

MT_BOND <==> PVE : LACP 802.3ad trunk\n10 / 20 / 250 / 254

OPN -[hidden]- DNS
OPN -[hidden]- DC

DNS --> OPN  : VLAN10
DC --> OPN   : VLAN10
NC --> OPN   : VLAN10
MAIL --> OPN : VLAN10
PMG --> OPN  : VLAN10
W11 --> OPN  : VLAN20

MT_E4 ..> OPN : VLAN20 access (LAN clients reach OPNsense via Mikrotik bridge)
MT_W_LAN ..> W11 : VLAN20 (WiFi clients)
MT_W_ADMIN ..> PVE : VLAN250 OOB (admin laptop -> Proxmox host independent of OPNsense)

note bottom of OPN
  **Option Y: 5 tagged vNICs**
  Hypervisor enforces VLAN, defence in depth.
  qm create:
    -net0 virtio,bridge=vmbr0
    -net1 virtio,bridge=vmbr0,tag=10
    -net2 virtio,bridge=vmbr0,tag=20
    -net3 virtio,bridge=vmbr0,tag=250
    -net4 virtio,bridge=vmbr0,tag=254
  Other VMs: single tagged vNIC each.
end note

note right of PVE
  Out-of-band mgmt path:
    laptop -> admin-oob-lib WiFi (hidden, VLAN250)
    -> Mikrotik bridges VLAN250 over LACP trunk
    -> Proxmox host .2 reachable
    even if OPNsense down.
end note

note left of MT
  **Phase 1 bootstrap** (today's flash-mikrotik):
    flat NAT, Mikrotik DHCP, single vmbr0 on host.
  **Phase 2 cutover** (additive then strip):
    add VLAN bridge alongside flat
    -> swing Proxmox + VMs
    -> enable OPNsense WAN VLAN
    -> strip Mikrotik NAT + DHCP.
  Re-pushed every netsetup update cycle (idempotent).
end note

note top of NET
  **Role gate**: host.Description == "Liberator"
  AND OPNsense VM in config for host.SerialNumber.
  Otherwise (Power Station / Proxmox / other):
  skip entire Mikrotik + OPNsense + VLAN flow.
  VMs still provisioned on flat vmbr0 untagged.
end note

@enduml

Liberator network push (cutover)

@startuml Liberator-Network-Push

title netsetup update push - Liberator network converge (every cycle, idempotent)

actor Admin
participant "netsetup update\n(on Proxmox host)" as NS
participant "UpdateMethods\nRunProxmoxUpdate" as UM
participant "EnsureCustomerVms" as ECV
participant "ProvisionOpnsenseVm" as POPN
participant "OpnsenseInstall\n(SSH first-boot)" as INST
participant "ConfigureOpnsenseVlans\n(REST API push)" as COV
participant "ConfigureMikrotikVlans\n(REST API push)" as CMV
participant "ConfigureProxmoxBridges\n(/etc/network/interfaces +\nsafe ifreload)" as CPB
participant "RewireVmNicsToVlans\n(qm set tag=...)" as RWN
participant "OPNsense VM" as OPN
participant "Mikrotik hAP ax2" as MT
participant "Other VMs (Nix / Win / PMG)" as VMS

Admin -> NS: netsetup update
NS -> UM: RunProxmoxUpdate(host, config)
UM -> UM: post-install gate (log file)\napt update + full-upgrade\nEnsureProxmoxResolvConfSecondaryDns

== Role gate ==
UM -> UM: isLiberator = host.Description == "Liberator"
UM -> UM: hasOpnsenseVm = GetOrderedVmsForHost(config, host.SerialNumber)\n  .Any(v => v.OS == OS.OPNSense)
UM -> UM: runFullNetworkFlow = isLiberator && hasOpnsenseVm

alt runFullNetworkFlow

  UM -> ECV: Run(host, config, services)

  == Phase 3d.3: OPNsense provision (Option Y, 5 vNIC) ==
  ECV -> POPN: provision (default raw image, first only)
  POPN -> OPN: qm create <vmid>\n  --net0 virtio,bridge=vmbr0\n  --net1 virtio,bridge=vmbr0,tag=10\n  --net2 virtio,bridge=vmbr0,tag=20\n  --net3 virtio,bridge=vmbr0,tag=250\n  --net4 virtio,bridge=vmbr0,tag=254\nqm importdisk + qm set --scsi0 + qm start

  alt /conf/.netsetup-installed missing
    ECV -> INST: OpnsenseInstall.Install(host, machine, config)
    INST -> INST: build config.xml:\n  vtnet0..4 assigned\n  DHCP per VLAN (10/20/254)\n  static maps from config.Computers[].MAC\n  DNS forwarder -> NixDns .53\n  FW deny-default inter-VLAN\n  allow VLAN20 -> VLAN10 (AD/DNS/SMB/print/HTTPS)\n  admin user + api_key/api_secret
    INST -> OPN: ssh root@<wanIp> (master key)\nscp config.xml -> /conf/\nconfigctl service reload all
    INST -> INST: persist api_key/secret to\n/etc/netsetup/opnsense-api.env
  end

  == Phase 3d.x: other VMs (single tagged vNIC each) ==
  ECV -> VMS: NixDns/DC/NC/Mail/PMG -> tag=10\nWin11 client -> tag=20\n(serial host matching unchanged)

  == Phase 3e: VLAN converge (every cycle, drift-correcting) ==

  UM -> COV: push OPNsense REST API
  COV -> OPN: probe /api/core/system/status (basic auth)
  COV -> OPN: PUT /api/dhcpv4/static_map\nPUT /api/dnsforwarder/settings\nPUT /api/firewall/alias/...\nPUT /api/firewall/filter/...\nPOST /api/firewall/apply

  UM -> CMV: push Mikrotik REST API
  CMV -> MT: bridge VLAN-aware on\nadd VLANs 10,20,250,254\nbonding1 LACP 802.3ad\n  slaves = ether2, ether3\ntrunk ports tagged\nether4, ether5 access VLAN20\nwifi SSIDs: Liberator(20)\n             Liberator-Guest(254)\n             admin-oob-lib(250 hidden)\nMikrotik IP VLAN250 .1\nremove flat NAT\ndisable Mikrotik DHCP server

  UM -> CPB: rewrite /etc/network/interfaces:\n  bond0 LACP enp1s0+enp2s0\n  vmbr0 bridge-vlan-aware\n  vmbr0v250 host mgmt = .2\nifupdown2 reload safe-mode\n(rollback if mgmt lost)

  UM -> RWN: for each existing VM, drift-check tag:\n  qm set <vmid> -netN ...,tag=<vlan>\nonly when tag missing or wrong\n(idempotent)

  == Phase 3f: btrfs scrub ==
  UM -> UM: btrfs scrub each mounted fs

else !runFullNetworkFlow (Power Station / vanilla Proxmox / other)
  UM -> ECV: Run(host, config, services)
  ECV -> VMS: VMs provisioned on flat vmbr0 untagged\n(serial host matching unchanged)
  note right
    Skipped:
      ProvisionOpnsenseVm
      OpnsenseInstall
      ConfigureOpnsenseVlans
      ConfigureMikrotikVlans
      ConfigureProxmoxBridges
      RewireVmNicsToVlans
    Bare Proxmox path. Power Station
    cluster wiring = future TODO.
  end note
  UM -> UM: btrfs scrub each mounted fs
end

UM --> NS: update complete
NS --> Admin: exit 0

note over UM
  Cutover sequence (additive then strip) handled by the
  ordering above: OPNsense API stages all interfaces first
  (VLAN children created), Mikrotik adds VLAN bridge alongside
  flat, Proxmox swings VM NICs to tags, OPNsense WAN VLAN if
  enabled, finally Mikrotik strips flat NAT + DHCP.
  Every step idempotent so re-running netsetup update
  is always safe and converges drift.
end note

@enduml

Public mail stack

@startuml

title Public Mail Stack Flow (per customer, on Proxmox host)

' Companion to netsetup.puml. Covers public mail stack added 2026-05-05:
' PBS on host, PMG LXC, mail VM (NixOS), dns VM (NixOS).
' All resources auto-prov via EnsureCustomerVms + EnsureCustomerLxcs +
' EnsureProxmoxPostInstall on the customer Proxmox host.

' Actors (external)
actor InternetSender as "Internet Sender\n(MX -> public IP)"
actor MUA as "MUA / Webmail User"
actor Registrar as "DNS Registrar API\n(provider per Customer)"
actor LECA as "Lets Encrypt CA"
actor Smarthost as "Optional Smarthost\n(Mailgun / Brevo / SES / M365 / ...)"

' Customer site
node "Customer Edge Router" as Edge {
  [NAT 25 -> Liberator]
  [NAT 80 -> Liberator]
  [NAT 443 -> Liberator]
  [NAT 587 -> Liberator]
}

' Proxmox host (Liberator)
node "Proxmox Host (Liberator)" as Host {
  component "PBS (apt pkg)" as PBS
  note bottom of PBS
    /var/lib/proxmox-backup/datastore
    Installed by EnsurePbsInstalled
    (EnsureProxmoxPostInstall step)
  end note

  node "LXC: pmg@<hostSerial>" as PMG {
    [proxmox-mailgateway]
    [postfix in 25]
    [postfix out 26 -> mail VM]
  }
  note bottom of PMG
    Provisioned by ProvisionPmgLxc
    pct create --features nesting=1 --unprivileged 1
    Routed by EnsureCustomerLxcs (OS = PmgLxc)
  end note

  node "VM: mail@<hostSerial>\n(NixOS, OS = NixMail)" as Mail {
    [postfix smtpd 25]
    [postfix submission 587]
    [postfix LMTP-from-PMG 26]
    [dovecot imap 143/993]
    [dovecot lmtp unix sock]
    [nginx + roundcube 443]
    [opendkim (keys only)]
  }
  note bottom of Mail
    Provisioned by ProvisionNixosVm
    via NixMailTemplate (NixHostTemplateFactory)
  end note

  node "VM: dns@<hostSerial>\n(NixOS, OS = NixDns)" as Dns {
    [coredns auth zone]
    [step-ca internal CA]
    [lego LE DNS-01]
    [ddns-updater]
  }
  note bottom of Dns
    Provisioned by ProvisionNixosVm
    via NixDnsTemplate
    Provider knob: NETSetupConfig.DnsProvider
  end note
}

' Inbound mail path
InternetSender --> Edge : "MX lookup ->\nmx.<domain> ->\npublic IP"
Edge --> PMG : "TCP 25\n(NAT forward)"
PMG --> Mail : "TCP 26\n(filtered, LMTP/SMTP)"
Mail --> Mail : "dovecot lmtp ->\nmaildir"

' Outbound mail path (two branches)
MUA --> Edge : "TCP 587 (submission)\nSTARTTLS + SASL"
Edge --> Mail : "NAT 587 -> mail VM"
Mail --> Smarthost : "if MailRelay set:\nSTARTTLS + SASL"
Smarthost --> InternetSender : "smarthost delivers"
Mail --> InternetSender : "if MailRelay null:\ndirect MX lookup"

' Webmail
MUA --> Edge : "HTTPS 443\nmail.<domain>"
Edge --> Mail : "NAT 443 -> nginx"
Mail --> Mail : "php-fpm + roundcube ->\nIMAP local 143 STARTTLS"

' DNS + ACME flow (provider-pluggable)
Dns --> Registrar : "ddns-updater PATCH\nA records (mail/pmg/mx)\nevery 60 s if IP changed"
Dns --> LECA : "lego DNS-01\n(challenge via Registrar)"
LECA --> Dns : "cert for\nmail.<domain>, pmg.<domain>"
Dns --> Mail : "SSH-push cert\non systemd path unit"
Dns --> PMG : "SSH-push cert\non systemd path unit"

' Internal DNS (LAN clients)
node "LAN Clients" as Lan
Lan --> Dns : "internal A records\n(coredns split-horizon)"

' Backup pull
Mail ..> PBS : "proxmox-backup-client\npush backups"
PMG ..> PBS : "proxmox-backup-client\npush backups"

@enduml

Documentation Index

All NETSetup-relevant docs in this repo. Some are already include::'d below; xref’d here too for quick nav. # prefixed = ignored / OLD.

Specs / FSDs / proposals
OS / Boot images
Customer process
Diagrams (PlantUML)

solution: src/NETSetup.sln project: NETSetup testProject: NETSetup.Tests language: csharp testCommand: dotnet test src/NETSetup.sln --nologo --verbosity minimal ---

In Development

nixos-vm-provisioning

GOAL: Proxmox host running NETSetup provisions customer VMs per NETSetupConfig during netsetup update. NixOS-backed VMs (Nextcloud, IPFS, generic NixOS) boot generic nixos.iso, NETSetup SSHes in using master key, scps C#-generated flake + host config, runs install.sh remotely. Windows VMs use existing per-VM offline create-iso pipe. Result: fully hands-off VM deploy from a single netsetup update on the Proxmox host.

Decisions:

  • ISO artifact: nixos.iso on Proxmox ISO storage. Dropbox holds nixos.iso.7z + nixos.iso.sha256 (ArchiveSync + SevenZipCommand pattern — see Serena compressed_image_driver_sync).

  • Bootstrap SSH: single master NETSetup keypair. Pubkey committed at src/NETSetup/nixos/netsetup-master.pub, baked into iso/iso.nix root authorized_keys. Privkey shipped with NETSetup binary under src/NETSetup/Resources/netsetup-master.key (embedded resource, extracted to /NETSetup/.ssh/netsetup-master.key chmod 0600 on use).

  • OS trigger: Machine.OperatingSystem.Name.StartsWith("Nix") → nix-backed. OS.NixOS (contracts) routes to NixHostTemplate. New NETSetup-local OS constants NixNextcloud, NixIPFS defined in NETSetup.Config.Nix.NetsetupOS (NOT in contracts lib).

  • Templates: abstract NixHostTemplate base + concrete NixNextcloudTemplate, NixIpfsTemplate. Templates expose GenerateDefaultNix() (host config body) and GenerateHardwareNix(). Content derived from src/NETSetup/nixos/hosts/default-nextcloud/default.nix and default-ipfs/default.nix. GenerateFlakeNix() exists but is no longer called by ProvisionNixosVm — see post-implementation pivot below.

  • Per-VM emit shape (post-pivot, 2026-04-30): flat config.nix + hardware.nix + sops secrets.yaml + .default.key at the per-VM generated dir root. No emitted flake.nix — the base nix tree at /iso/nixos-config/flake.nix already exposes nixosConfigurations.default = mkHost "default" ./config.nix (nixos/hosts.nix:113). Overlaying our flake stripped disko + lanzaboote + sops modules from sharedHostModules, causing disko-install to crash with artifacts[1]: unbound variable (no diskoScript artifact when disko module isn’t loaded). Reusing the base flake keeps every shared module in scope.

  • End-to-end test ISO bootstrap: ProxmoxNixosNextcloudTests self-bootstraps every prerequisite in [TestInitialize] plus nixos.iso per run:

    • EnsureChocoPackageCommand (nested NETCommand-style wrapper around choco install <pkg> -y, idempotent) ensures sops is on PATH for SopsCommand.

    • EnsureWslDistroCommand (nested NETCommand-style wrapper around wsl --import) ensures the NixOS WSL distro is registered. IsDistroRegistered short-circuits on wsl -l -q so already-imported distros skip the Dropbox fetch. When absent, the rootfs tarball is pulled from Images/NixOSWsl.tar.gz on Dropbox (TestContext.GetImageRemoteSource() prefers the local Dropbox folder for instant copy, falls back to the API). The Dropbox sub-path is encoded as a const on the test class so it’s the single point of update if the image moves.

    • BuildNixosIsoCommand (wsl.exe -d NixOS — nix build .#iso against the local src/NETSetup/nixos flake → C:\NETSetupDEV\nixos.iso) builds the ISO, then pveHost.UploadISO ships it sha-driven so unchanged builds skip the wire transfer.

  • Why wsl --import instead of wsl --install -d NixOS? The Microsoft Store catalogue version is not always the pinned NixOS-WSL build the flake expects (nixpkgs lock + cached flake inputs). Importing a tarball we publish ourselves keeps WSL state reproducible across dev boxes.

  • install.sh invoked with -h default (not the per-VM hostname). Per-VM OS hostname identity preserved via networking.hostName set inside the emitted config.nix by NixHostTemplate.

  • Installer SOPS age key: single committed pair at src/NETSetup/nixos/.sops/installer.{key,pub}. Public key pinned as ProvisionNixosVm.InstallerAgePublicKey constant. Private key shipped via csproj <Content CopyToOutputDirectory="Always" CopyToPublishDirectory="Always">, deployed to /NETSetup/.sops/installer.key (700/600) by netsetup-first-boot.sh section 4c. ProvisionNixosVm.EmitConfigBundle generates a random 32-byte hex LUKS password, encrypts it via SopsCommand to a per-VM secrets.yaml, and copies the installer private key into .default.key so install.sh L156 can decrypt during disko-install. Local generated dir is wiped on success (TryCleanupConfigDir).

  • Disk: plain btrfs (no LUKS). Default /dev/sda; VirtIO VMs use /dev/vda. NixHostTemplate takes device param.

  • Entry point: extend UpdateMethods.RunProxmoxUpdate (append EnsureCustomerVms(host, config) at end). InstallCommand.InstallStage.ProxmoxVMs stays a stub.

  • New commands live in NETSetup/NETCommand/ — no changes to shared osisa.NETCommand. NixRemoteInstallCommand composes LinuxCommandBase for ssh/scp. QmGuestCommand wraps Proxmox-API nodes/<node>/qemu/<vmid>/agent via corsinvest client (reuse existing Proxmox/Host wrappers from osisa.SystemManagement.Proxmox).

  • No System.IO: use AbsolutePath + PhysicalFileOperator. No raw Process: use NETCommand builders.

  • VM identification: reuse CreateVirtualMachines.IsForHost + GetOrderedVmsForHost (DC-first ordering preserved).

  • Local emission path for generated nix configs: AbsolutePath("/NETSetup/generated-nix") / vmName (Linux Proxmox host), wiped + rewritten each update cycle.

    • ✓ Add NETSetup-local OS constants and nix-detection extension: create src/NETSetup/Config/Nix/NetsetupOS.cs with public static readonly OperatingSystem NixNextcloud = OperatingSystem.From("NixNextcloud", OperatingSystemFamily.Linux_GNU) and NixIPFS = OperatingSystem.From("NixIPFS", OperatingSystemFamily.Linux_GNU). Create src/NETSetup/Extensions/NixOsExtensions.cs with public static bool IsNixBacked(this OperatingSystem os) returning true when os == OS.NixOS OR os.Name.StartsWith("Nix", StringComparison.OrdinalIgnoreCase). Assertions:

    • NetsetupOS.NixNextcloud.Name.Should().Be(SomeNixNextcloudName)

    • NetsetupOS.NixNextcloud.Family.Should().Be(OperatingSystemFamily.Linux_GNU)

    • NetsetupOS.NixIPFS.Name.Should().Be(SomeNixIpfsName)

    • OS.NixOS.IsNixBacked().Should().BeTrue()

    • NetsetupOS.NixNextcloud.IsNixBacked().Should().BeTrue()

    • NetsetupOS.NixIPFS.IsNixBacked().Should().BeTrue()

    • OS.Windows11.IsNixBacked().Should().BeFalse()

    • OS.Proxmox.IsNixBacked().Should().BeFalse()

    • OS.Unknown.IsNixBacked().Should().BeFalse()

    • ✓ Extend src/NETSetup/nixos/iso/iso.nix with qemu-guest-agent + master pubkey: add services.qemuGuest.enable = true; and users.users.root.openssh.authorizedKeys.keys = [ (lib.fileContents ../netsetup-master.pub) ]; (create src/NETSetup/nixos/netsetup-master.pub placeholder committed with a real ed25519 pubkey). Keep services.openssh.enable = true, ensure PermitRootLogin = "prohibit-password" (or "yes" during bootstrap). Preserve all existing options (volumeID, contents, install-nixos.sh script, supportedFilesystems). Assertions:

    • isoNixContent.Should().Contain("services.qemuGuest.enable = true")

    • isoNixContent.Should().Contain("users.users.root.openssh.authorizedKeys.keys")

    • isoNixContent.Should().Contain("netsetup-master.pub")

    • isoNixContent.Should().Contain("services.openssh.enable = true")

    • pubFileExists.Should().BeTrue()

    • pubFileContent.Should().StartWith("ssh-ed25519 ")

    • ✓ Create build-nixos-image skill + standalone script: add .claude/skills/build-nixos-image/SKILL.md (description: "Build NixOS installer ISO, 7z, sha256, upload to Dropbox") plus src/NETSetup/nixos/scripts/build-nixos-image.sh that (1) cd to nixos dir, git add -A (flakes see only tracked), (2) nix build .#iso --out-link /tmp/nixos-iso-result, (3) resolve ISO file via symlink, (4) 7z a -mx=9 /tmp/nixos.iso.7z <iso>, (5) sha256sum /tmp/nixos.iso.7z > /tmp/nixos.iso.sha256, (6) cp both to "$DROPBOX/NETSetup/ISOs/". Script must be idempotent, detect missing nix/7z and error clearly, and exit non-zero on any step failure. Skill instructs running under WSL distro nixos (modeled after update-nixos-image skill). Assertions:

    • skillFileExists.Should().BeTrue()

    • skillContent.Should().Contain("build-nixos-image")

    • skillContent.Should().Contain("WSL")

    • scriptFileExists.Should().BeTrue()

    • scriptContent.Should().Contain("nix build .#iso")

    • scriptContent.Should().Contain("7z a")

    • scriptContent.Should().Contain("sha256sum")

    • scriptContent.Should().Contain("set -euo pipefail")

    • scriptIsExecutable.Should().BeTrue() (file mode check)

    • ✓ Implement C# Nix templates under src/NETSetup/Config/Nix/Templates/: abstract NixHostTemplate with properties Hostname (MachineName), DiskDevice (string, default /dev/sda), UserName (string, default admin), TimeZone (string, default UTC), AuthorizedKeys (IReadOnlyList<string>) and virtual methods GenerateFlakeNix(), GenerateHardwareNix(), abstract GenerateDefaultNix(). Concrete NixNextcloudTemplate and NixIpfsTemplate override GenerateDefaultNix() with content ported from src/NETSetup/nixos/hosts/default-nextcloud/default.nix and default-ipfs/default.nix (literal string). Factory NixTemplateFactory.ForMachine(Machine machine) returns concrete template based on machine.OperatingSystem: NixNextcloud → NixNextcloudTemplate, NixIPFS → NixIpfsTemplate, NixOSNixHostTemplate generic subclass NixGenericTemplate, other → throws NETSetupException. Assertions:

    • nextcloudTemplate.GenerateDefaultNix().Should().Contain("services.nextcloud.enable = true")

    • nextcloudTemplate.GenerateDefaultNix().Should().Contain(SomeHostname)

    • nextcloudTemplate.GenerateFlakeNix().Should().Contain("description")

    • nextcloudTemplate.GenerateHardwareNix().Should().Contain(SomeDiskDevice)

    • ipfsTemplate.GenerateDefaultNix().Should().Contain("services.ipfs.enable = true")

    • NixTemplateFactory.ForMachine(nextcloudMachine).Should().BeOfType<NixNextcloudTemplate>()

    • NixTemplateFactory.ForMachine(ipfsMachine).Should().BeOfType<NixIpfsTemplate>()

    • NixTemplateFactory.ForMachine(nixosMachine).Should().BeOfType<NixGenericTemplate>()

    • act.Should().Throw<NETSetupException>() for non-nix machine

    • template.GenerateDefaultNix().Should().Contain(SomeAuthorizedKey)

    • ✓ Implement Proxmox guest-agent IP discovery: src/NETSetup/Extensions/QmGuestAgentExtensions.cs with public static IPAddress DiscoverVmIpViaQmAgent(this Host host, int vmId, TimeSpan deadline, ILogger logger). Uses corsinvest _client.Nodes[<node>].Qemu[vmId].Agent.NetworkGetInterfaces.NetworkGetInterfaces() (or equivalent), parses JSON data.result[].ip-addresses[] filtering ip-address-type == "ipv4", excluding 127.0.0.0/8 and 169.254.0.0/16. Polls every 2s until first match or deadline. Throws NETSetupException with vmId in message on timeout. Assertions:

    • result.Should().Be(IPAddress.Parse(SomeValidIp))

    • result.AddressFamily.Should().Be(AddressFamily.InterNetwork)

    • (loopback-only scenario) act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains(SomeVmId.ToString()))

    • (link-local-only scenario) act.Should().Throw<NETSetupException>()

    • (timeout with no response) act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains("timeout"))

    • (multiple ipv4) result.Should().Be(IPAddress.Parse(SomeFirstNonLoopbackIp))

    • ✓ Implement NixRemoteInstallCommand under src/NETSetup/NETCommand/NixRemoteInstallCommand.cs using NETCommand builder pattern. Builder exposes WithIp(IPAddress), WithHostname(string), WithMasterKey(AbsolutePath keyPath), WithConfigDir(AbsolutePath localGeneratedDir), WithDiskDevice(string) (default /dev/sda), CopyHostSshKeys(bool). Execute() runs: (1) ssh-keyscan target → known_hosts, (2) scp -r <localDir>/* root@<ip>:/nixos-config/, (3) ssh root@<ip> "/nixos-config/scripts/install.sh -h <name> -d <device>" with 30min timeout. Each phase is a LinuxCommandBase internally. Returns NixRemoteInstallResult with Success, StandardOutput, StandardError, Phase (KeyScan/Scp/Install) on failure. Assertions:

    • builder.WithIp(SomeIp).WithHostname(SomeHostname).WithMasterKey(SomeKeyPath).WithConfigDir(SomeConfigDir).Build().Should().NotBeNull()

    • act.Should().Throw<ArgumentException>() when WithIp not called

    • act.Should().Throw<ArgumentException>() when WithHostname not called

    • command.RenderedScpArgs.Should().Contain("root@" + SomeIp)

    • command.RenderedScpArgs.Should().Contain("/nixos-config/")

    • command.RenderedSshArgs.Should().Contain("install.sh -h " + SomeHostname)

    • command.RenderedSshArgs.Should().Contain("-d " + SomeDiskDevice)

    • command.RenderedSshArgs.Should().Contain("-i " + SomeKeyPath)

    • result.Phase.Should().Be(InstallPhase.Install) on successful full run

    • ✓ Implement EnsureNixosIsoOnProxmoxHost(ISystemHost host, IFileProvider dropbox): checks /var/lib/vz/template/iso/nixos.iso existence AND sha256 match against Dropbox NETSetup/ISOs/nixos.iso.sha256. If missing or mismatch: downloads nixos.iso.7z to temp, verifies sha256, extracts via SevenZipCommand to /var/lib/vz/template/iso/nixos.iso, logs action. Idempotent — returns silently if match. Lives at src/NETSetup/Helpers/EnsureNixosIso.cs. Assertions:

    • act.Should().NotThrow() when ISO present + hash matches

    • result.Action.Should().Be(EnsureAction.Downloaded) when missing

    • result.Action.Should().Be(EnsureAction.Replaced) when hash mismatch

    • result.Action.Should().Be(EnsureAction.UpToDate) when already current

    • act.Should().Throw<NETSetupException>() when Dropbox sha256 file missing

    • act.Should().Throw<NETSetupException>() when downloaded archive sha256 fails verification

    • isoPath.FileExists().Should().BeTrue() after successful run

    • ✓ Implement ProvisionNixosVm(Host proxmoxHost, Machine machine, NETSetupConfig config, ISystemHost sysHost) in src/NETSetup/Stages/5-NETSetupLinux/ProvisionNixosVm.cs: (1) build VirtualMachine entity from Machine (name, id from serial prefix, CPU/RAM/disk from machine hardware, IsoName = "nixos.iso"), (2) skip if VM already exists with matching serial description, (3) call proxmoxHost.CreateVirtualMachine(vm, autoStart: true), (4) DiscoverVmIpViaQmAgent(vm.Id, TimeSpan.FromMinutes(5)), (5) pick template via NixTemplateFactory.ForMachine(machine), emit files to AbsolutePath("/NETSetup/generated-nix") / machine.Name, (6) run NixRemoteInstallCommand builder with generated dir + master key path, (7) log result, throw NETSetupException on failure. Assertions:

    • (new VM scenario) proxmoxHost.Received().CreateVirtualMachine(Arg.Is<VirtualMachine>(vm ⇒ vm.IsoName == SomeNixosIsoName))

    • (existing VM scenario) proxmoxHost.DidNotReceive().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • result.VmIp.Should().Be(IPAddress.Parse(SomeDiscoveredIp))

    • result.GeneratedConfigDir.Should().Be(SomeExpectedGeneratedDir)

    • act.Should().Throw<NETSetupException>() when DiscoverVmIpViaQmAgent times out

    • act.Should().Throw<NETSetupException>() when NixRemoteInstall fails

    • generatedDir.DirectoryExists().Should().BeTrue() after successful emission (during install — wiped on success post-pivot)

    • (generatedDir / "config.nix").FileExists().Should().BeTrue()

    • (generatedDir / "hardware.nix").FileExists().Should().BeTrue()

    • (generatedDir / "secrets.yaml").FileExists().Should().BeTrue()

    • (generatedDir / ".default.key").FileExists().Should().BeTrue()

    • ✓ Implement ProvisionWindowsVmOnProxmox(Host proxmoxHost, Machine machine, NETSetupConfig config, ISystemHost sysHost, IServiceProvider services) in src/NETSetup/Stages/5-NETSetupLinux/ProvisionWindowsVmOnProxmox.cs: (1) skip if VM exists, (2) derive ticket + hostname from machine.Name + config, (3) invoke existing CreateIsoCommand offline path (MainISO.CreateOfflineProductionISO + CopyToProxmoxStorageIfApplicable) to produce NETSetup-{ticket}-{host}.iso in /var/lib/vz/template/iso/, (4) build VirtualMachine with that ISO as main, stealth hardware iff ShouldUseStealthHardware(machine), (5) proxmoxHost.CreateVirtualMachine(vm, autoStart: true). Reuses existing CreateVirtualMachines.ShouldUseStealthHardware. Assertions:

    • (new Windows 11 VM) proxmoxHost.Received().CreateVirtualMachine(Arg.Is<VirtualMachine>(vm ⇒ vm.IsoName.StartsWith("NETSetup-")))

    • (WindowsServer VM) proxmoxHost.Received().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (existing VM) proxmoxHost.DidNotReceive().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • producedIsoPath.Should().Be(ProxmoxDefaultIsoStoragePath + "/NETSetup-" + SomeTicket + "-" + SomeHostname + ".iso")

    • result.UsedStealthHardware.Should().BeTrue() when machine is Windows 11

    • result.UsedStealthHardware.Should().BeFalse() when machine is WindowsServer

    • act.Should().Throw<NETSetupException>() when offline ISO creation fails

    • ✓ Wire VM provisioning into UpdateMethods.RunProxmoxUpdate: add new method EnsureCustomerVms(ISystemHost host, NETSetupConfig config, IServiceProvider services) called at end of RunProxmoxUpdate (after RunBtrfsScrub). Method: (1) connect to local Proxmox via DI, (2) GetHostAsync(host.MachineName), (3) call EnsureNixosIsoOnProxmoxHost iff any nix-backed machine targets this host, (4) GetOrderedVmsForHost(config, host.SerialNumber) — iterate with DC first, (5) dispatch: IsNixBackedProvisionNixosVm, else Windows → ProvisionWindowsVmOnProxmox, other → log skip. Errors per-VM are logged + continue (one bad VM must not block others). Final aggregate throws if any VM failed. Assertions:

    • (no VMs for host) proxmoxHost.DidNotReceive().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (mixed nix+windows VMs) proxmoxHost.Received(SomeExpectedCount).CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (DC-first ordering) firstCreatedVmSerial.Should().StartWith(SomeDcVmIdPrefix)

    • (all nix, no windows) ensureNixosIsoCalled.Should().BeTrue()

    • (only windows, no nix) ensureNixosIsoCalled.Should().BeFalse()

    • (one VM fails, others succeed) proxmoxHost.Received(SomeTotalCount).CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (any failure) act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains(SomeFailedVmName))

    • (all success) act.Should().NotThrow()

    • ✓ Documentation: add section "Proxmox VM provisioning during update" to netsetup.adoc (after existing Proxmox sections) describing the two flows (NixOS SSH-deploy + Windows ISO-deploy), required keys/paths, update cadence. Add PUML sequence diagram src/NETSetup/docs/NETSetupProvisionVms.puml showing: netsetup-update → EnsureNixosIso → CreateVirtualMachine → qm guest-agent → scp config → ssh install.sh → reboot. Include in netsetup.adoc via include::docs/NETSetupProvisionVms.puml[]. All narrative in caveman full per CLAUDE.md. Assertions:

    • adocContent.Should().Contain("Proxmox VM provisioning")

    • adocContent.Should().Contain("nixos.iso")

    • adocContent.Should().Contain("NETSetupProvisionVms.puml")

    • pumlFileExists.Should().BeTrue()

    • pumlContent.Should().Contain("@startuml")

    • pumlContent.Should().Contain("EnsureNixosIso")

    • pumlContent.Should().Contain("qm guest")

    • pumlContent.Should().Contain("scp")

    • pumlContent.Should().Contain("install.sh")

In Development

osisa-mikrotik

GOAL: Provide a self-contained MikroTik RouterOS REST API client library (src/osisa.Mikrotik/) mirroring the osisa.OPNSense architecture (CRUD repos + UoW + DataStoreReference + Contracts-only public surface + IMikrotikConverge entry point). Covers broad resource families (bridges, VLANs, interfaces, bonding, IP/DHCP/DNS, firewall NAT+filter, WiFi v7+, user, system). Includes NETSetup-side bridge classes (MikrotikApiCredentials, MikrotikDesiredStateBuilder, MikrotikConvergeBridge) wired into RunProxmoxUpdate for the Liberator VLAN cutover flow. MikroTik REST API uses standard HTTP verbs (GET=print, PUT=add, PATCH=set, DELETE=remove), Basic Auth, paths mirror console menu (/rest/<menu>), all values encoded as strings, resource IDs use *N format.

Decisions:

  • Full CRUD/UoW shape mirroring osisa.OPNSense - same DataStoreReference/UnitOfWork/Repository pattern for future osisa.Rest.Repository migration path

  • Project lives in netsetup repo: src/osisa.Mikrotik/ + src/osisa.Mikrotik.Tests/, added to src/NETSetup.sln

  • OPNsense-aligned repo method names: GetAllAsync, GetAsync, AddAsync, SetAsync, DeleteAsync

  • WiFi v7+ only (/interface/wifi package, not legacy /interface/wireless) - hAP ax2 runs RouterOS 7 only

  • Batch-then-push UoW: changes buffered in-memory, CommitAsync pushes all pending PUT/PATCH/DELETE in sequence. No rollback on failure - throw MikrotikException with Applied[] + Failed[] delta

  • Entity base tracks both .id (*N format) and Name (nullable) for human-readable converge diffs

  • Typed C# properties with custom MikrotikStringConverter (System.Text.Json) handling MikroTik’s "all values are strings" encoding (string to bool/int/long deserialization)

  • Auth via env file /etc/netsetup/mikrotik-api.env (MIKROTIK_URL, MIKROTIK_USER, MIKROTIK_PASSWORD) - same KEY=VALUE pattern as OPNsense

  • MikrotikDesiredState is flat typed DTO with arrays per resource family (BridgeSpec[], BridgePortSpec[], etc.)

  • v1 MikrotikDesiredStateBuilder populates full Liberator VLAN cutover: bridges, VLANs, ports, WiFi SSIDs, DHCP disable, NAT disable, bonding, system identity

  • Converge safety guard: only delete entries matching desired state or prefixed with "netsetup-" - never delete user-created resources

  • Contracts-only public surface: NETSetup sees only IMikrotikConverge + MikrotikConnection + MikrotikDesiredState + *Spec records. All repo/entity/UoW types are internal (InternalsVisibleTo osisa.Mikrotik.Tests)

  • MikroTik REST API reference: https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API

Status (2026-05-12, commits 77a43f1e + 4f94c1b9 + 03316fc6 + be41eafe + d9af2b05 + 311e7421 + 94c3e555 + 5525a0a7 + 7c0b9064 + 848a97d5 on feature/proxmoxvms, mikrotik feature COMPLETE: all 9 RALPH tasks landed, 249 mikrotik tests green = 213 in osisa.Mikrotik.Tests + 36 NETSetup-side helper tests (10 MikrotikApiCredentialsTests + 18 MikrotikDesiredStateBuilderTests + 8 MikrotikConvergeBridgeTests). Plus 17 PhysicalMikrotikIntegrationTests gated on real hardware (not in CI). See mikrotik_feature_summary.md for the architecture-at-a-glance.):

  • Scaffold landed: src/osisa.Mikrotik/ + src/osisa.Mikrotik.Tests/ wired into src/NETSetup.sln. osisa.Mikrotik.csproj targets $(DefaultNetTargetFramework), pulls Microsoft.Extensions.DependencyInjection.Abstractions + Microsoft.Extensions.Logging.Abstractions + osisa.Validation, exposes internals via <InternalsVisibleTo Include="osisa.Mikrotik.Tests" />.

  • Public surface (namespace osisa.Mikrotik.Contracts): MikrotikConnection, MikrotikDesiredState, IMikrotikConverge, MikrotikConvergeResult, MikrotikResourceDelta, plus the 16 *Spec records (BridgeSpec, BridgePortSpec, BridgeVlanSpec, InterfaceSpec, BondingSpec, IpAddressSpec, IpRouteSpec, DhcpServerSpec, DhcpLeaseSpec, DnsSpec, FirewallNatRuleSpec, FirewallFilterRuleSpec, WifiConfigurationSpec, WifiSecuritySpec, UserSpec, SystemIdentitySpec). DI: osisa.Mikrotik.MikrotikServiceCollectionExtensions.AddMikrotik(IServiceCollection). Exception: osisa.Mikrotik.Exceptions.MikrotikException (carries StatusCode + ErrorMessage + Detail?, message format "MikroTik {status}: {message}" or "MikroTik {status}: {message} ({detail})").

  • Transport (Client/MikrotikHttpClient, internal sealed IDisposable): real impl. Basic auth "Basic " + base64(user + ":" + pw) exposed on internal AuthorizationHeader test seam. HttpClient.BaseAddress = BaseUrl + "/", paths built via "rest/" + path.TrimStart('/'). Verbs: GetAsync<T> (list, [] on empty), GetSingleAsync<T>, PutAsync<T> (add), PatchAsync(path,id,body), DeleteAsync(path,id), PostAsync<T> (action endpoints). 4xx-5xx responses parse {error,message,detail}MikrotikException; non-JSON body keeps ReasonPhrase as message. Self-signed cert skip via HttpClientHandler.ServerCertificateCustomValidationCallback (default on). Shared JsonSerializerOptions with PropertyNamingPolicy=null + DefaultIgnoreCondition.WhenWritingNull + MikrotikStringConverter. Test-only constructor accepts a pre-wired HttpClient for FakeHttpMessageHandler.

  • JSON (Json/MikrotikStringConverter, internal sealed JsonConverterFactory): real impl. CanConvert claims exactly bool | int | long | string. CreateConverter yields per-type nested converters. Read tolerates BOTH the MikroTik all-strings form ("true", "42") AND raw JSON values (true, 42) - resilient to per-version drift. Write ALWAYS emits a quoted JSON string (writer.WriteStringValue); numeric values formatted with InvariantCulture to avoid German-locale thousands-separator regressions. String converter coerces raw True/False/Number tokens into "true"/"false"/decimal-string so MikroTik fields that occasionally come back as a raw number (e.g. pvid) still bind to a string? property.

  • Entity base (Entities/MikrotikEntity.cs, internal abstract): real impl. Two writable string properties - [JsonPropertyName(".id")] string? Id (literal MikroTik wire key ".id", dot-prefixed - non-negotiable) and [JsonPropertyName("name")] string? Name. Both nullable - new entities pre-PUT have Id == null, families without a name field leave Name == null. Concrete entities (task 4) inherit this base + add family-specific string?/bool?/int?/long? properties with [JsonPropertyName("kebab-case")] where MikroTik’s wire key diverges from C# PascalCase.

  • Repository base (Repositories/MikrotikRepositoryBase<T> : IMikrotikRepository<T> where T : MikrotikEntity, internal abstract): real impl. Concrete subclasses only override abstract string ResourcePath (e.g. "interface/bridge"). GetAllAsync delegates HttpClient.GetAsync<T>(ResourcePath, ct); GetAsync(id, ct)HttpClient.GetSingleAsync<T>(ResourcePath + "/" + id, ct). Add/Set/Delete buffer PendingOperation records into a List<PendingOperation>; IsDirty == Pending.Count > 0. FlushAsync(ct) iterates pending ops in insertion order: Add → PutAsync (returns saved entity, Applied[] captures saved.Id ?? ""), Set → PatchAsync(ResourcePath, op.Id, body), Delete → DeleteAsync(ResourcePath, op.Id). Per-op try/catch over Exception captures into MikrotikFlushFailure(Id, Error); Pending.Clear() runs unconditionally after the loop. Result type MikrotikFlushResult(IReadOnlyList<string> Applied, IReadOnlyList<MikrotikFlushFailure> Failed). Pending op carries PendingOperationKind enum (Add | Set | Delete) + id (empty string for Add until device assigns) + nullable object? body. No retry, no rollback - caller decides via Applied/Failed split. GetAllAsync + Add + Set + Delete are virtual so singleton + read-only subclasses can override.

  • Entities + repositories (17 resource families): all internal sealed. Entities inherit MikrotikEntity and decorate properties with [JsonPropertyName("kebab-case")] where the MikroTik wire key diverges. The catalog (with REST paths + kebab keys + repo kind): Bridge (interface/bridge, vlan-filtering, protocol-mode); BridgePort (interface/bridge/port, bridge, interface, pvid, tagged); BridgeVlan (interface/bridge/vlan, bridge, tagged, untagged, vlan-ids); RouterInterface (interface - renamed to dodge the C# keyword clash; type, default-name, disabled, running, mac-address); Bonding (interface/bonding, slaves, mode, transmit-hash-policy, lacp-rate); IpAddress (ip/address, address, network, interface, disabled, comment); IpRoute (ip/route, dst-address, gateway, distance, comment); DhcpServer (ip/dhcp-server, interface, disabled, address-pool); DhcpLease (ip/dhcp-server/lease, address, mac-address, comment, disabled); DnsSettings SINGLETON (ip/dns, servers, allow-remote-requests); FirewallNatRule (ip/firewall/nat, chain, action, out-interface, disabled, comment); FirewallFilterRule (ip/firewall/filter, chain, action, protocol, dst-port, in-interface, comment, disabled); WifiConfiguration (interface/wifi, ssid, country, channel, security); WifiSecurity (interface/wifi/security, authentication-types, passphrase); User (user, password, group); SystemIdentity SINGLETON (system/identity, hostname via inherited Name); SystemResource SINGLETON + READ-ONLY (system/resource, platform, board-name, version, uptime, free-memory : long?, total-memory : long? - Add/Set/Delete throw NotSupportedException). Singleton repos override GetAllAsync to wrap the single object in a one-entry array. Repository <→ Contracts spec name drift to bridge in the mapper (task 6): Bridge.Protocol → protocol-mode vs spec VlanFiltering (spec keeps only Name + VlanFiltering); BridgePort.Tagged (entity, literal wire key) vs BridgePortSpec.TaggedVlans (caller-friendly semantic).

  • Unit of work (UnitOfWork/IMikrotikUnitOfWork + MikrotikUnitOfWork, internal): real impl. Exposes one IMikrotikRepository<T> getter per resource family (17 props), all backed by a single MikrotikHttpClient injected in the ctor. CommitAsync(ct) iterates the 17 repos in declaration order via a private FlushIfDirtyAsync<T> helper: skips repos with !IsDirty, otherwise awaits FlushAsync and appends to the result list. Sequential (never parallel - shared HttpClient + MikroTik’s REST engine misorders rapid PATCH bursts). Caller is responsible for dependency ordering inside the desired state (bridges before bridge ports, etc.). Result is MikrotikCommitResult(IReadOnlyList<MikrotikFlushResult> Results) with HasFailures ⇒ Results.Any(r ⇒ r.Failed.Count > 0). Empty Results (no repos were dirty) != failure.

  • Data store + reference (DataStore/, all internal): real impl. MikrotikDataStore : IMikrotikDataStore, IDisposable is the canonical HttpClient owner - production ctor (MikrotikConnection) builds a fresh MikrotikHttpClient(connection, NullLogger.Instance) + a MikrotikUnitOfWork(httpClient) and disposes the HttpClient on Dispose() (idempotent via _disposed flag). Internal test-seam ctor accepts a pre-built HttpClient for FakeHttpMessageHandler. MikrotikDataStoreReference : IMikrotikDataStoreReference carries a MikrotikConnection and a Func<MikrotikConnection, MikrotikHttpClient> factory (defaults to new MikrotikHttpClient(connection, NullLogger.Instance); internal ctor lets tests inject). ExistsAsync(ct) probes GET /rest/system/resource via a throw-away HttpClient (using var http = _httpFactory(_connection)), returns true on any successful deserialization, false on any Exception (auth failure, timeout, JSON parse, cancellation - all reduce to "not reachable"). OpenDataStore() builds a fresh HttpClient + MikrotikDataStore (caller owns disposal). MikrotikDataStoreReferenceProvider is a stateless factory returning a new reference per call. Provider’s GetReference return type tightened from object to IMikrotikDataStoreReference in this commit.

  • Entity mapper (Mapping/MikrotikEntityMapper, internal static): real impl. 32 static methods - one ToEntity(XxxSpec) + one ToSpec(Xxx entity) per family for 16 spec families (SystemResource excluded - read-only, no Spec counterpart). Mechanical copy-property-to-property, no business logic. ToEntity projects spec record positionals to entity nullable properties as-is. ToSpec collapses entity nullables to spec record positionals via entity.X ?? string.Empty / entity.X ?? false / entity.Pvid ?? 0 (spec records are non-nullable positional records). The notable bridge: BridgePortSpec.TaggedVlans <→ BridgePort.Tagged (semantic spec name vs literal wire-key entity property). Bridge.Protocol is NOT in BridgeSpec and does not round-trip - the mapper writes nothing for it; future extension is additive.

  • Converge orchestrator (MikrotikConverge, internal sealed : IMikrotikConverge): real impl, replacing the scaffold-time NotImplementedException stub. Ctor takes IMikrotikDataStoreReferenceProvider + ILogger<MikrotikConverge>. RunAsync flow: (1) Precondition.NotNull on connection + desired; (2) reachability gate via reference.ExistsAsync(ct) → if false, log warning + return MikrotikConvergeResult(Reachable=false, Deltas=Array.Empty); (3) using var store = reference.OpenDataStore() → 16-repo UoW; (4) 16 RunResourceAsync(label, () ⇒ ConvergeXxxAsync(…​)) calls in fixed declaration order (Bridge → BridgePort → BridgeVlan → Interface → Bonding → IpAddress → IpRoute → DhcpServer → DhcpLease → FirewallNatRule → FirewallFilterRule → WifiConfiguration → WifiSecurity → User → Dns → SystemIdentity), each captured into the deltas list; (5) uow.CommitAsync(ct) wrapped in try/catch (warn + swallow on failure); (6) return MikrotikConvergeResult(true, deltas.ToArray()) with all 16 entries. RunResourceAsync<T> is the per-resource isolation seam: catches every Exception, logs warning with the family label, returns MikrotikResourceDelta(label, 0, 0, 0) so failures appear as zero-delta entries (3-layer failure pattern: per-op via FlushAsync.Failed, per-family via RunResourceAsync, global commit via the outer try/catch). Match keys per family: Name for Bridge/Interface/Bonding/Wifi*/User/DhcpServer; composite Bridge+Interface for BridgePort; composite Bridge+VlanIds for BridgeVlan; Address for IpAddress; composite DstAddress+Gateway for IpRoute; MacAddress (case-insensitive!) for DhcpLease; composite Chain+Comment for Firewall*; FirstOrDefault for Dns/SystemIdentity singletons. Delete safety guard via private const string NetsetupPrefix = "netsetup-" + IsNetsetupManaged(label): only 5 families have a delete loop (Bridge, Bonding, IpAddress, FirewallNatRule, FirewallFilterRule) and they only delete entries whose Name or Comment starts with netsetup- - manually-created MikroTik resources NEVER get touched. Singleton converges (ConvergeDnsAsync + ConvergeSystemIdentityAsync) treat null desired spec as a no-op zero-delta (so non-Liberator hosts can leave MikrotikDesiredState.Dns = null and SystemIdentity = null and the family is visited but does nothing). Interface and DhcpServer converges are Set-only (no Add path - device boots with a fixed set, converge toggles Disabled). IpRoute, FirewallFilterRule, WifiSecurity, User are Add-only (no Set, no Delete - reflects Liberator cutover semantics).

  • osisa.Mikrotik library is FEATURE-COMPLETE for adoc tasks 1-6 (Contracts + transport + JSON + entity catalog + UoW/DataStore + mapper + converge). NETSetup-side tasks 7 (credentials parser) + 8 (desired-state builder) + 9 (converge bridge + EnsureCustomerVms wire-in) are also done. All 9 RALPH tasks landed - feature COMPLETE. Single known follow-up: the task 9 wire-in chose EnsureCustomerVms.Run (paired with OpnsenseConvergeBridge.Run at lines 177-182) instead of the adoc-spec "explicit Phase 3e step 4 in RunProxmoxUpdate`" - operationally equivalent today since `EnsureCustomerVms runs inside RunProxmoxUpdate; revisit if/when explicit ConfigureProxmoxBridges + RewireVmNicsToVlans helpers are added.

  • NETSetup credentials parser (src/NETSetup/Helpers/MikrotikApiCredentials.cs, public static): real impl. public const string ApiEnvPath = "/etc/netsetup/mikrotik-api.env". TryLoad()MikrotikConnection? reads the env file via (AbsolutePath)ApiEnvPath + path.FileExists()/path.ReadAllText() (osisa IO conventions); returns null if the file is missing. Parse(string)MikrotikConnection? is a pure helper: empty/whitespace content returns null; per-line iteration over Split('\n', RemoveEmptyEntries) with Trim() strips CRLF, skip-on-blank-or-#, split on first =, value gets Trim('"') to support KEY="value" shell-style env files. Recognized keys: MIKROTIK_URL, MIKROTIK_USER, MIKROTIK_PASSWORD. Unknown keys silently ignored (forward-compat). Missing required key THROWS NETSetupException("<KEY> missing in mikrotik-api.env") - intentional divergence from OpnsenseApiCredentials (returns null on incomplete) because a present-but-malformed file means flash-mikrotik wrote it wrong, which is a real bug to surface. The resulting MikrotikConnection.AllowSelfSignedCertificate keeps its default true. NETSetup.csproj has a ProjectReference to osisa.Mikrotik.csproj enabling Contracts visibility. 10/10 tests green in MikrotikApiCredentialsTests covering valid parse + 3 missing-key throws + comments + quoted values + empty content + nonexistent file + unknown extra keys.

  • NETSetup converge bridge (src/NETSetup/Helpers/MikrotikConvergeBridge.cs, public static): real impl, FINAL task. public static void Run(ISystemHost host, NETSetupConfig config, IServiceProvider? services = null). Synchronous (.GetAwaiter().GetResult() on IMikrotikConverge.RunAsync) to match the synchronous EnsureCustomerVms flow. Best-effort: every failure path log + swallow, NEVER throws past the catch-all Exception handler. Phases: (1) ArgumentNullException on null host/config; (2) CredentialsProvider() (defaults to MikrotikApiCredentials.TryLoad, internal static Func<MikrotikConnection?> test seam) - null → info log "creds not found" + return; (3) LiberatorGate.IsFullLiberatorFlow(host, config) ? "Liberator" : "" + MikrotikDesiredStateBuilder.Build(config, hostDescription); (4) private IsEmptyState 4-property heuristic (Bridges.Length == 0 && SystemIdentity is null && WifiConfigurations.Length == 0 && Users.Length == 0) → info log "skipping …​ empty desired state" + return; (5) ResolveConverge(services) (DI lookup → fallback to self-built ServiceCollection with AddLogging() + AddMikrotik() if IMikrotikConverge not registered); (6) inside try: converge.RunAsync(connection, desired).GetAwaiter().GetResult()!result.Reachable → warn "unreachable" + return, otherwise iterate result.Deltas info-logging "MikroTik converge {Resource}: +{Added}/~{Updated}/-{Deleted}"; (7) catch-all Exception → warn "converge threw - continuing" with exception attached. The LiberatorGate check lives INSIDE the bridge body (caller minimalism - EnsureCustomerVms calls unconditionally). Wired into src/NETSetup/Helpers/EnsureCustomerVms.cs at lines 177-182, immediately after OpnsenseConvergeBridge.Run, NOT inside RunProxmoxUpdate as the adoc spec suggested - the two REST-converge bridges paired side-by-side is more discoverable, and operational cadence is identical because EnsureCustomerVms itself runs inside RunProxmoxUpdate. 8/8 tests green covering null-creds skip, non-Liberator empty-state skip, unreachable warn, exception swallow, DI-vs-fallback ResolveConverge.

  • NETSetup desired-state builder (src/NETSetup/Helpers/MikrotikDesiredStateBuilder.cs, public static): real impl. Pure mapper, zero IO. public const string ManagedPrefix = "netsetup-" (matches delete-safety prefix) + public const string DefaultIdentityName = "Lib_AP". Build(NETSetupConfig config, string hostDescription)MikrotikDesiredState. Non-Liberator (anything other than literal "Liberator" with StringComparison.Ordinal) returns new MikrotikDesiredState() = all-empty arrays + null singletons (no-op converge). Liberator branch emits the recipe: Bridges = [BridgeSpec("bridge1", "yes")] (VLAN filtering on, non-prefixed well-known name); BridgePorts 4 entries (ether2 + ether3 trunk with Pvid=1 + tagged "10,20,250,254"; ether4 + ether5 access with Pvid=20 + empty tagged); BridgeVlans 4 entries (VLAN 10/20/250/254 on bridge1, tagged on ether2+ether3, VLAN 20 also untagged on ether4+ether5); IpAddresses = [IpAddressSpec("10.0.250.1/24", "bridge1", "netsetup-mgmt")]; DhcpServers + FirewallNatRules + FirewallFilterRules empty (OPNsense owns DHCP/NAT/filter); WifiConfigurations 3 entries on wifi1/wifi2/wifi3 for SSIDs Liberator + Liberator-Guest + admin-oob-lib, country CH, channel 2ghz-onlyn,5ghz-ax / 2ghz-onlyn / 5ghz-ax, each Security field referencing a WifiSecuritySpec.Name; WifiSecurities 3 entries with PSK profiles (wpa2-psk,wpa3-psk for Liberator + open for Guest + wpa3-psk for admin-oob), passwords from config.LiberatorWifiPassword + config.MikrotikAdminPassword (null → empty placeholder); Users = [UserSpec("admin", adminPassword, "full")]; SystemIdentity = new SystemIdentitySpec(config.SelectedHostname ?? "Lib_AP"). New NETSetupConfig fields (added in this commit): LiberatorWifiPassword + MikrotikAdminPassword (both public string?; latter doubles as admin user pw AND admin-oob-lib WPA3 PSK - single source of truth). Adoc-spec drift: spec mentioned MachineName.Value, impl uses the real NETSetupConfig.SelectedHostname field. 18/18 tests green.

  • Tests: 213 green (42 in `94c3e555`). Contracts + records (Connection, DesiredState, ConvergeResult, ResourceDelta, all 16 spec records, exception) + consolidated `MikrotikContractsTests` for DI + serialization shape. `MikrotikHttpClientTests` covers Basic auth header, 4xx/5xx -> exception mapping, JSON read-write round-trip, self-signed-cert toggle. `MikrotikStringConverterTests` covers raw-and-quoted Read and always-quoted Write per primitive. `MikrotikEntityTests` covers `.id` + `name` JSON round-trip and nullable defaults. `MikrotikRepositoryBaseTests` covers IsDirty transitions, GetAll/Get routing, FlushAsync verb dispatch (Add->PUT/Set->PATCH/Delete->DELETE), partial-fail capture (continue on per-op exception, Applied vs Failed split), Pending unconditional clear, empty-batch no-op. `MikrotikFlushResultTests` covers the result + failure record shapes. `MikrotikRepositoriesTests` (+32 in `d9af2b05`) asserts 17 `ResourcePath` strings + per-family entity JSON deserialization through `MikrotikStringConverter` (kebab-case keys, bool/long round-trip) + singleton-wrap behaviour for Dns/SystemIdentity/SystemResource + read-only `NotSupportedException` guard for SystemResource Add/Set/Delete. `MikrotikUnitOfWorkTests` ( in 311e7421) covers 17-repo getter non-null + CommitAsync with 0/1/3 dirty repos + sequential flush order + HasFailures propagation. MikrotikCommitResultTests covers HasFailures computation across mixed inner outcomes. MikrotikDataStoreReferenceTests covers ExistsAsync true on 200, false on refused/401/timeout/non-JSON, OpenDataStore returns a usable store, factory seam exercised. MikrotikServiceCollectionExtensionsTests asserts AddMikrotik registers IMikrotikConverge + IMikrotikDataStoreReferenceProvider as singletons (idempotent on multiple calls). MikrotikEntityMapperTests (+ in 94c3e555) covers ToEntity/ToSpec round-trip per family + null projection (string → "", bool → false, int → 0) + the BridgePortSpec.TaggedVlans <→ BridgePort.Tagged drift bridge. MikrotikConvergeTests (+ in 94c3e555) covers reachability-false short-circuit, golden-path mixed Add/Set/Delete, per-resource failure isolation (one family throws, others still run with correct deltas), netsetup--prefix delete safety, singleton Dns/SystemIdentity Set behaviour, null desired Dns = no-op zero-delta, all 16 families appear in the result. PhysicalMikrotikIntegrationTests (17 tests, Skip = "Requires a physical MikroTik device.") runs manually against a real hAP ax2 during commissioning - NOT executed in CI. TestInfrastructure/FakeHttpMessageHandler.cs route-keyed (mirrors osisa.OPNSense.Tests); TestInfrastructure/TestValues.cs hosts shared constants. (Test agent renamed the auth-header property assertion AuthHeaderAuthorizationHeader to match the shipped name.)

    • ✓ Create project scaffold + MikrotikHttpClient + MikrotikStringConverter + MikrotikException: Create src/osisa.Mikrotik/osisa.Mikrotik.csproj (net10, <InternalsVisibleTo Include="osisa.Mikrotik.Tests" />) and src/osisa.Mikrotik.Tests/osisa.Mikrotik.Tests.csproj (MSTest + FluentAssertions). Add both to src/NETSetup.sln. Create Client/MikrotikHttpClient.cs (internal) wrapping HttpClient with: Basic auth header (Authorization: Basic base64(username:password)), self-signed cert skip via HttpClientHandler.ServerCertificateCustomValidationCallback, typed methods GetAsync<T>(string path) returning T (GET /rest/{path}), GetSingleAsync<T>(string path) for singleton resources, PutAsync<T>(string path, T entity) returning T with .id (PUT /rest/{path}), PatchAsync(string path, string id, object body) (PATCH /rest/{path}/{id}), DeleteAsync(string path, string id) (DELETE /rest/{path}/{id}), PostAsync<T>(string path, object body) returning T (POST /rest/{path}). Error responses (HTTP 4xx/5xx) parsed from JSON {"error": N, "message": "…​", "detail": "…​"} into MikrotikException. Create Json/MikrotikStringConverter.cs - custom System.Text.Json.Serialization.JsonConverter factory handling MikroTik’s all-strings encoding: deserializes "true"/"false" to bool, numeric strings to int/long, preserves string passthrough. Registered on the shared JsonSerializerOptions used by MikrotikHttpClient. Create Exceptions/MikrotikException.cs with int StatusCode, string ErrorMessage, string? Detail. Create src/osisa.Mikrotik.Tests/TestInfrastructure/FakeHttpMessageHandler.cs (same pattern as osisa.OPNSense.Tests) with route-keyed response dictionary. Assertions:

    • httpClient.AuthHeader.Should().Be("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(SomeUser + ":" + SomePassword)))

    • getResult.Should().HaveCount(ExpectedCount)

    • getResult[0].Id.Should().Be(SomeEntityId)

    • putResult.Id.Should().NotBeNullOrEmpty()

    • act.Should().ThrowAsync<MikrotikException>().Where(e ⇒ e.StatusCode == 404 && e.ErrorMessage == SomeErrorMessage)

    • MikrotikStringConverter deserializing "true" .Should().Be(true)

    • MikrotikStringConverter deserializing "42" .Should().Be(42)

    • MikrotikStringConverter deserializing "ether1" .Should().Be("ether1")

    • MikrotikStringConverter serializing true .Should().Be("true")

    • MikrotikStringConverter serializing 42 .Should().Be("42")

    • ✓ Create Contracts public surface types: Create Contracts/ namespace in src/osisa.Mikrotik/ with all public types. MikrotikConnection record: string BaseUrl, string Username, string Password, bool AllowSelfSignedCertificate = true. IMikrotikConverge interface: Task<MikrotikConvergeResult> RunAsync(MikrotikConnection connection, MikrotikDesiredState desired, CancellationToken ct). MikrotikConvergeResult record: bool Reachable, MikrotikResourceDelta[] Deltas. MikrotikResourceDelta record: string ResourceType, int Added, int Updated, int Deleted. Desired state spec records (all in Contracts/): BridgeSpec(string Name, string VlanFiltering), BridgePortSpec(string Bridge, string Interface, int Pvid, string TaggedVlans), BridgeVlanSpec(string Bridge, string Tagged, string Untagged, string VlanIds), InterfaceSpec(string Name, bool Disabled), BondingSpec(string Name, string Slaves, string Mode, string TransmitHashPolicy, string LacpRate), IpAddressSpec(string Address, string Interface, string Comment), IpRouteSpec(string DstAddress, string Gateway, string Comment), DhcpServerSpec(string Name, bool Disabled), DhcpLeaseSpec(string Address, string MacAddress, string Comment), DnsSpec(string Servers, bool AllowRemoteRequests), FirewallNatRuleSpec(string Chain, string Action, string OutInterface, bool Disabled, string Comment), FirewallFilterRuleSpec(string Chain, string Action, string Protocol, string DstPort, string InInterface, string Comment, bool Disabled), WifiConfigurationSpec(string Name, string Ssid, string Country, string Channel, string Security), WifiSecuritySpec(string Name, string AuthenticationTypes, string Passphrase), UserSpec(string Name, string Password, string Group), SystemIdentitySpec(string Name). MikrotikDesiredState record: typed array properties for each spec (BridgeSpec[] Bridges, BridgePortSpec[] BridgePorts, BridgeVlanSpec[] BridgeVlans, InterfaceSpec[] Interfaces, BondingSpec[] Bondings, IpAddressSpec[] IpAddresses, IpRouteSpec[] IpRoutes, DhcpServerSpec[] DhcpServers, DhcpLeaseSpec[] DhcpLeases, DnsSpec? Dns, FirewallNatRuleSpec[] FirewallNatRules, FirewallFilterRuleSpec[] FirewallFilterRules, WifiConfigurationSpec[] WifiConfigurations, WifiSecuritySpec[] WifiSecurities, UserSpec[] Users, SystemIdentitySpec? SystemIdentity). MikrotikServiceCollectionExtensions.AddMikrotik(this IServiceCollection services) registering IMikrotikConverge + provider as singletons. Assertions:

    • new MikrotikConnection(SomeUrl, SomeUser, SomePassword).BaseUrl.Should().Be(SomeUrl)

    • new MikrotikConnection(SomeUrl, SomeUser, SomePassword).AllowSelfSignedCertificate.Should().BeTrue()

    • new MikrotikDesiredState with empty arrays .Bridges.Should().BeEmpty()

    • new BridgeSpec(SomeBridgeName, SomeVlanFiltering).Name.Should().Be(SomeBridgeName)

    • new MikrotikConvergeResult(true, Array.Empty<MikrotikResourceDelta>()).Reachable.Should().BeTrue()

    • new MikrotikResourceDelta(SomeResourceType, 1, 2, 0).Added.Should().Be(1)

    • ✓ Create MikrotikEntity base + MikrotikRepositoryBase<T> with batch buffering: Create Entities/MikrotikEntity.cs (internal) - base class with [JsonPropertyName(".id")] string? Id { get; set; } and string? Name { get; set; }. All MikroTik entity types will inherit from this. Create Repositories/IMikrotikRepository.cs (internal) with: Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct), Task<T> GetAsync(string id, CancellationToken ct), void Add(T entity) (buffers), void Set(string id, T entity) (buffers), void Delete(string id) (buffers), Task<MikrotikFlushResult> FlushAsync(CancellationToken ct), bool IsDirty. Create Repositories/MikrotikRepositoryBase.cs (internal abstract): stores pending operations in a List<PendingOperation> (discriminated union of Add/Set/Delete), GetAllAsync delegates to MikrotikHttpClient.GetAsync<List<T>>(ResourcePath), GetAsync delegates to MikrotikHttpClient.GetSingleAsync<T>(ResourcePath + "/" + id), Add/Set/Delete append to pending list, FlushAsync iterates pending ops pushing via HttpClient (PUT for adds, PATCH for sets, DELETE for deletes) collecting Applied[] and Failed[] into MikrotikFlushResult, clears pending on success. Abstract string ResourcePath { get; }. Create Repositories/MikrotikFlushResult.cs (internal): IReadOnlyList<string> Applied, IReadOnlyList<(string Id, Exception Error)> Failed. Assertions:

    • entity.Id.Should().Be(SomeEntityId)

    • entity.Name.Should().Be(SomeEntityName)

    • repo.IsDirty.Should().BeFalse() initially

    • repo.Add(someEntity); repo.IsDirty.Should().BeTrue()

    • getAllResult.Should().HaveCount(ExpectedCount) (via FakeHttpMessageHandler)

    • getResult.Id.Should().Be(SomeEntityId)

    • flushResult.Applied.Should().HaveCount(ExpectedAppliedCount) after Add+Set+Delete

    • flushResult.Failed.Should().BeEmpty() on success

    • repo.IsDirty.Should().BeFalse() after successful FlushAsync

    • flushResult on mid-batch failure: flushResult.Applied.Should().HaveCount(SomePartialCount) and flushResult.Failed.Should().HaveCount(1)

    • ✓ Create all concrete entity types + repository implementations for 17 MikroTik resource families: In Entities/ create internal entity classes inheriting MikrotikEntity, each with typed properties (via MikrotikStringConverter) matching the MikroTik REST API JSON fields. Bridge.cs: string? VlanFiltering, string? Protocol. BridgePort.cs: string? Bridge, string? Interface, int? Pvid, string? TaggedVlans (mapped from tagged). BridgeVlan.cs: string? Bridge, string? Tagged, string? Untagged, string? VlanIds (mapped from vlan-ids). RouterInterface.cs: string? Type, string? DefaultName, bool? Disabled, string? Running, string? MacAddress. Bonding.cs: string? Slaves, string? Mode, string? TransmitHashPolicy (mapped from transmit-hash-policy), string? LacpRate (mapped from lacp-rate). IpAddress.cs: string? Address, string? Network, string? Interface, bool? Disabled, string? Comment. IpRoute.cs: string? DstAddress (mapped from dst-address), string? Gateway, string? Distance, string? Comment. DhcpServer.cs: string? Interface, bool? Disabled`, string? AddressPool. DhcpLease.cs: string? Address, string? MacAddress (mapped from mac-address), string? Comment, bool? Disabled. DnsSettings.cs: string? Servers, bool? AllowRemoteRequests (mapped from allow-remote-requests). FirewallNatRule.cs: string? Chain, string? Action, string? OutInterface (mapped from out-interface), bool? Disabled, string? Comment. FirewallFilterRule.cs: string? Chain, string? Action, string? Protocol, string? DstPort (mapped from dst-port), string? InInterface (mapped from in-interface), string? Comment, bool? Disabled. WifiConfiguration.cs: string? Ssid, string? Country, string? Channel, string? Security. WifiSecurity.cs: string? AuthenticationTypes (mapped from authentication-types), string? Passphrase. User.cs: string? Password, string? Group. SystemIdentity.cs (singleton). SystemResource.cs (read-only): string? Platform, string? BoardName, string? Version, string? Uptime, long? FreeMemory, long? TotalMemory. In Repositories/ create concrete repos: BridgeRepository (path interface/bridge), BridgePortRepository (interface/bridge/port), BridgeVlanRepository (interface/bridge/vlan), InterfaceRepository (interface), BondingRepository (interface/bonding), IpAddressRepository (ip/address), IpRouteRepository (ip/route), DhcpServerRepository (ip/dhcp-server), DhcpLeaseRepository (ip/dhcp-server/lease), DnsSettingsRepository (ip/dns) - override GetAllAsync for singleton (GET returns single object, wrap in array), FirewallNatRuleRepository (ip/firewall/nat), FirewallFilterRuleRepository (ip/firewall/filter), WifiConfigurationRepository (interface/wifi), WifiSecurityRepository (interface/wifi/security), UserRepository (user), SystemIdentityRepository (system/identity) - singleton override, SystemResourceRepository (system/resource) - read-only (Add/Set/Delete throw NotSupportedException). Use [JsonPropertyName("kebab-case")] attributes on entity properties where MikroTik JSON key differs from C# PascalCase. Assertions:

    • bridgeRepo.ResourcePath.Should().Be("interface/bridge")

    • ipAddressRepo.ResourcePath.Should().Be("ip/address")

    • firewallNatRepo.ResourcePath.Should().Be("ip/firewall/nat")

    • wifiConfigRepo.ResourcePath.Should().Be("interface/wifi")

    • systemResourceRepo.ResourcePath.Should().Be("system/resource")

    • Bridge deserialized from {“.id”:"*1","name":"bridge1","vlan-filtering":"true"}: result.Id.Should().Be("*1"), result.Name.Should().Be("bridge1"), result.VlanFiltering.Should().Be("true")

    • IpAddress deserialized from sample JSON: result.Address.Should().Be(SomeAddress), result.Interface.Should().Be(SomeInterface)

    • DnsSettingsRepository.GetAllAsync wraps singleton response in array: result.Should().HaveCount(1)

    • SystemResourceRepository.Add should throw NotSupportedException

    • FirewallFilterRule deserialized with dst-port mapped to DstPort property

    • ✓ Create UoW + DataStore + DataStoreReference + Provider + DI registration: In UnitOfWork/ create IMikrotikUnitOfWork.cs (internal): exposes all 17 repos as read-only properties (IMikrotikRepository<Bridge> Bridges, IMikrotikRepository<BridgePort> BridgePorts, etc.) + Task<MikrotikCommitResult> CommitAsync(CancellationToken ct). Create MikrotikCommitResult.cs (internal): IReadOnlyList<MikrotikFlushResult> Results, bool HasFailures. Create MikrotikUnitOfWork.cs (internal): constructor takes MikrotikHttpClient, instantiates all 17 concrete repos, CommitAsync iterates repos where IsDirty == true, calls FlushAsync on each, aggregates into MikrotikCommitResult. In DataStore/ create IMikrotikDataStore.cs (internal): IMikrotikUnitOfWork UnitOfWork { get; }, IDisposable. Create MikrotikDataStore.cs: creates MikrotikHttpClient from MikrotikConnection, passes to MikrotikUnitOfWork. Create IMikrotikDataStoreReference.cs (internal): Task<bool> ExistsAsync(CancellationToken ct), IMikrotikDataStore OpenDataStore(). Create MikrotikDataStoreReference.cs: ExistsAsync probes GET /rest/system/resource - returns true on 200, false on any exception (connection refused, timeout, auth failure). Create IMikrotikDataStoreReferenceProvider.cs (internal): IMikrotikDataStoreReference GetReference(MikrotikConnection connection). Create MikrotikDataStoreReferenceProvider.cs. In root, update MikrotikServiceCollectionExtensions.AddMikrotik: register IMikrotikDataStoreReferenceProviderMikrotikDataStoreReferenceProvider (singleton), IMikrotikConvergeMikrotikConverge (singleton). Assertions:

    • uow.CommitAsync with no dirty repos: result.Results.Should().BeEmpty()

    • uow.Bridges.Add(someBridge); uow.CommitAsync: result.Results.Should().HaveCount(1)

    • uow.CommitAsync with 3 dirty repos: result.Results.Should().HaveCount(3)

    • uow.CommitAsync with one repo failing: result.HasFailures.Should().BeTrue()

    • dataStoreRef.ExistsAsync returns true on 200 response

    • dataStoreRef.ExistsAsync returns false on connection refused

    • provider.GetReference(someConn).Should().NotBeNull()

    • AddMikrotik DI: services.BuildServiceProvider().GetRequiredService<IMikrotikConverge>().Should().NotBeNull()

    • AddMikrotik DI: services.BuildServiceProvider().GetRequiredService<IMikrotikDataStoreReferenceProvider>().Should().NotBeNull()

    • ✓ Create MikrotikEntityMapper + MikrotikConverge orchestrator: Create Mapping/MikrotikEntityMapper.cs (internal static) with bidirectional mapping methods between Contracts *Spec records and internal entity types. One ToEntity/ToSpec method pair per resource family (e.g., Bridge ToEntity(BridgeSpec spec), BridgeSpec ToSpec(Bridge entity)). Handle nullable fields. Create MikrotikConverge.cs (internal, implements IMikrotikConverge): RunAsync flow: (1) provider.GetReference(connection), (2) ExistsAsync - if false, return MikrotikConvergeResult(Reachable: false, Deltas: []), (3) OpenDataStore() → UoW, (4) per resource family: GetAllAsync current state, diff against desired by name/key matching (name for bridges/interfaces/bondings/wifi/users/identity, address for IPs, chain+comment for firewall rules, mac-address for DHCP leases), queue Add for missing, Set for changed (compare all properties), Delete for entries present on device but not in desired state AND (name starts with "netsetup-" OR matches an entry in desired state that was removed - safety guard: never delete user-created resources), (5) uow.CommitAsync(), (6) build MikrotikConvergeResult with per-resource MikrotikResourceDelta, (7) per-resource failures captured as warnings in result (same isolation pattern as osisa.OPNSense) - one resource family failing must not block others. DnsSettings and SystemIdentity are singletons: diff by comparing current vs desired, Set if different, no Add/Delete. Assertions:

    • mapper: MikrotikEntityMapper.ToEntity(someBridgeSpec).Name.Should().Be(SomeBridgeName)

    • mapper: MikrotikEntityMapper.ToSpec(someBridgeEntity).VlanFiltering.Should().Be(SomeVlanFiltering)

    • converge unreachable: result.Reachable.Should().BeFalse()

    • converge golden path (add 2 bridges + set 1 IP + delete 1 NAT rule): result.Deltas.Should().Contain(d ⇒ d.ResourceType == "Bridge" && d.Added == 2)

    • converge empty desired state: result.Deltas.Should().AllSatisfy(d ⇒ d.Added == 0 && d.Updated == 0 && d.Deleted == 0)

    • converge per-resource failure: bridges fail, IPs succeed → result still contains IP delta with correct counts

    • converge safety guard: existing "manual-rule" not in desired state → NOT deleted. existing "netsetup-old-rule" not in desired state → deleted

    • converge singleton (SystemIdentity): current "MikroTik" + desired "Lib_AP" → delta Updated == 1

    • ✓ Create NETSetup MikrotikApiCredentials env file parser: Create src/NETSetup/Helpers/MikrotikApiCredentials.cs with static Parse(string content) returning MikrotikConnection. Reads KEY=VALUE format (one per line, # comments, empty lines skipped): MIKROTIK_URLBaseUrl, MIKROTIK_USERUsername, MIKROTIK_PASSWORDPassword. Missing required field (URL, USER, PASSWORD) throws NETSetupException with field name in message. Add TryReadFromDisk(AbsolutePath path, ILogger logger)MikrotikConnection?: returns null if file doesn’t exist (info log), otherwise reads + Parse. Env file path constant: /etc/netsetup/mikrotik-api.env. Test class in src/NETSetup.Tests/HelperTests/MikrotikApiCredentialsTests.cs. Assertions:

    • Parse valid content: result.BaseUrl.Should().Be(SomeMikrotikUrl)

    • Parse valid content: result.Username.Should().Be(SomeMikrotikUser)

    • Parse valid content: result.Password.Should().Be(SomeMikrotikPassword)

    • Parse missing MIKROTIK_URL: act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains("MIKROTIK_URL"))

    • Parse missing MIKROTIK_USER: act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains("MIKROTIK_USER"))

    • Parse with comments and empty lines: result.BaseUrl.Should().Be(SomeMikrotikUrl)

    • TryReadFromDisk with nonexistent file: result.Should().BeNull()

    • ✓ Create NETSetup MikrotikDesiredStateBuilder for Liberator VLAN cutover: Create src/NETSetup/Helpers/MikrotikDesiredStateBuilder.cs with Build(NETSetupConfig config, string hostDescription) returning MikrotikDesiredState. For hostDescription == "Liberator" (role gate from ): Bridges = one "bridge1" with VlanFiltering="yes". BridgePorts = ether2 pvid=1 tagged 10,20,250,254 (trunk to Proxmox), ether3 pvid=1 tagged 10,20,250,254 (trunk to Proxmox), ether4 pvid=20 (LAN access), ether5 pvid=20 (spare LAN). BridgeVlans = VLAN 10,20,250,254 on bridge1 with appropriate tagged/untagged ports. IpAddresses = 10.0.250.1/24 on bridge1 VLAN 250 (OOB/MGMT). DhcpServers = all found servers set Disabled=true (OPNsense handles DHCP). FirewallNatRules = masquerade rule set Disabled=true (OPNsense handles NAT). FirewallFilterRules = empty (OPNsense handles filtering). WifiConfigurations = "Liberator" on wifi1 (2.4+5 GHz) VLAN 20, "Liberator-Guest" on wifi2 open VLAN 254, "admin-oob-lib" hidden WPA3 VLAN 250. WifiSecurities = WPA2/3 PSK for Liberator, WPA3 for admin-oob-lib, none for Guest. SystemIdentity = hostname from config or "Lib_AP". Users = admin with config.MikrotikAdminPassword (from flash-mikrotik flashed password). For non-Liberator hostDescription: return MikrotikDesiredState with all empty arrays and null singletons (no-op converge). WiFi passwords sourced from NETSetupConfig fields (existing WiFi password config or flash-mikrotik defaults). Test class in src/NETSetup.Tests/HelperTests/MikrotikDesiredStateBuilderTests.cs. Assertions:

    • Liberator: result.Bridges.Should().HaveCount(1)

    • Liberator: result.Bridges[0].Name.Should().Be("bridge1")

    • Liberator: result.Bridges[0].VlanFiltering.Should().Be("yes")

    • Liberator: result.BridgePorts.Should().HaveCount(4) (ether2, ether3, ether4, ether5)

    • Liberator: result.BridgePorts.First(p ⇒ p.Interface == "ether2").Pvid.Should().Be(1)

    • Liberator: result.BridgeVlans.Should().Contain(v ⇒ v.VlanIds == "10")

    • Liberator: result.DhcpServers.Should().AllSatisfy(s ⇒ s.Disabled.Should().BeTrue())

    • Liberator: result.FirewallNatRules.Should().AllSatisfy(r ⇒ r.Disabled.Should().BeTrue())

    • Liberator: result.WifiConfigurations.Should().HaveCount(3)

    • Liberator: result.WifiConfigurations.Should().Contain(w ⇒ w.Ssid == "Liberator")

    • Liberator: result.WifiConfigurations.Should().Contain(w ⇒ w.Ssid == "Liberator-Guest")

    • Liberator: result.WifiConfigurations.Should().Contain(w ⇒ w.Ssid == "admin-oob-lib")

    • Liberator: result.SystemIdentity.Should().NotBeNull()

    • non-Liberator: result.Bridges.Should().BeEmpty()

    • non-Liberator: result.SystemIdentity.Should().BeNull()

    • ✓ Create NETSetup MikrotikConvergeBridge + wire into RunProxmoxUpdate: Create src/NETSetup/Helpers/MikrotikConvergeBridge.cs with Run(ISystemHost host, NETSetupConfig config, IServiceProvider services, ILogger logger). Best-effort pattern (same as OpnsenseConvergeBridge): (1) MikrotikApiCredentials.TryReadFromDisk - null → info log "no mikrotik credentials" + return, (2) MikrotikDesiredStateBuilder.Build(config, host.Description) - empty desired state (non-Liberator) → info log + return, (3) resolve IMikrotikConverge from services (build one-shot ServiceCollection with AddMikrotik() if not pre-registered), (4) converge.RunAsync(connection, desired, ct), (5) if !result.Reachable → warn log "MikroTik unreachable at {url}" + return, (6) log each delta, (7) catch all exceptions → warn log + continue (never throw). Wire into src/NETSetup/Helpers/UpdateMethods.cs RunProxmoxUpdate as step 4 from Liberator design (after EnsureCustomerVms/OpnsenseConvergeBridge, before ConfigureProxmoxBridges), gated on runFullNetworkFlow (host.Description == "Liberator" && hasOpnsenseVm). Test class in src/NETSetup.Tests/HelperTests/MikrotikConvergeBridgeTests.cs. Assertions:

    • missing creds file: should not throw, logger should contain "no mikrotik credentials"

    • non-Liberator host: should not throw, should not call IMikrotikConverge.RunAsync

    • unreachable MikroTik: should not throw, logger should contain "unreachable"

    • successful converge: IMikrotikConverge.RunAsync.Received(1)

    • IMikrotikConverge.RunAsync throws: should not throw (caught), logger should contain warning

    • RunProxmoxUpdate calls MikrotikConvergeBridge.Run when isLiberator && hasOpnsenseVm

    • RunProxmoxUpdate skips MikrotikConvergeBridge.Run when !isLiberator