Version: 0.9.0.401
Branch: develop
Release Date: 05/30/2026 11:37:24
1. Goal
1.1. 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.
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!").
1.2. About 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:
-
Chocolatey Package Manager is installed.
-
Visual Studio Code is installed.
Overall description of the complete NETSetup process landscape (master documentation).
1.3. Vision: 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
2. Install
2.1. Windows - Chocolatey (preferred)
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
3. Step by Step
Build one customer in four phase, run in order: Assembly → Prepare → Setup → Install. Assembly = hardware on bench + flash MikroTik. Prepare = customer config workflow (included below). Setup = iway internet-order activation + MikroTik WAN/WiFi. Install = Liberator / Power Station / client + deliver + bill. Full standalone runbook (with images): docs/step-by-step.adoc.
3.1. Process flow
What is a NETSetup Config: Whole customer configuration (unique per customer), which is based on the Company Structure Assets and Requirements.
3.2. Phase 2 - Prepare
Trigger: customer reach out (phone 061 763 00 00, support@osisa.com, or web). End state: the customer config is written, published to Dropbox, and committed - ready to drive install.
-
Create ticket in Freshdesk. Every new customer start here. Open osisa Freshdesk, new ticket, fill mandatory fields, note ticket number - it tag all later artifacts. See Ticketverwaltung in Freshdesk.
-
Ist-Soll analysis. Capture current mode + future mode of customer network. See Offertprozess and Netzwerk Ist-Soll-Analyse.
-
No current environment, from scratch
-
Already an existing environment
-
-
Quote. Build quote from future mode, send, get acceptance. Loop on customer feedback. See Offertprozess. Acceptance triggers Assembly (order hardware).
-
Document the future mode. First write the target network in the customer documentation - it drives config + install. Copy Kunden-Netzwerkdokumentation template (Staudt scheme), fill per customer (Kontakte, Remote-Zugriff, Netzwerk, Firewall, Domains, E-Mail, Drucker, Server und Computer). Render with render-customer-doc.ps1 (or asciidoctor) to PDF/DOCX.
-
Create + publish the customer config. From the documented future mode write
Customers/<Customer>.cs(employees, devices, serials), regenerate JSON, publish to Dropbox, commit. Full procedure:-
Customer configs live in NETSetup Repository
Customers/as .NET 10 file-based.csapps. Each one builds aCompany, runs the translator, emits<Customer>.jsonnext to itself. The JSON is what NETSetup reads at install time, keyed off the machine serial. Use thenetsetup-customer-configskill; override examples inCustomers/README.md. -
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 -
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 inCustomers/README.md. Template:#:project ../src/NETSetup/NETSetup.csproj #:package osisa.UserManagement.Contracts using System.Runtime.CompilerServices; using System.Text.Json; using Nuke.Common.IO; using osisa.FileProviders.Physical; using osisa.IO.Contracts; using NETSetup.Config.Translators; using NETSetup.Entities; using NETSetup.Extensions; using osisa.Enterprise.Contracts; using osisa.Enterprise.Entities; using osisa.Security.Contracts; using osisa.SystemManagement.Contracts.Elements; using osisa.SystemManagement.Contracts.Names; using osisa.SystemManagement.Entities; using osisa.UserManagement.Contracts; var company = Company.CreateBuilder("laufentaler.org", "CH", "Roeschenz") // Dyndns (homelab run): No-IP DDNS Key. Updating all.ddnskey.com pushes every hostname // assigned to the key (laufentaler.ddns.net must be in the key's group). ddns-updater // on the dns VM learns the WAN IP via curl echo and pushes it here. // WARNING: live DDNS-Key creds - do NOT commit/push this file; revert to a placeholder // before commit or rotate the key in No-IP afterward. .AddDomainProvider( "noip", "35BWoCUF7nRS".ToSecretString(), new[] { "all" }, username: "9pvx4ey", secretKey: "password", domain: "ddnskey.com") .AddDevice("Liberator", DeviceTypes.Server) // Root Server .WithSerialNumber("lib") .WithDescription("Liberator") .AddDevice("Power-1", DeviceTypes.Server) .WithSerialNumber("power-1") .WithDescription("Power Station") // MikroTik hAP ax2 (WAN-facing AP/router for the Liberator stack). DeviceType // AccessPoint -> NETSetupMikrotik. Description carries the admin password. Presence // seeds the 'wifi' user + drives the Mikrotik converge. OPNsense itself is gated on // the Description="Liberator" host (already present), not on this device. .AddDevice("Mikrotik", DeviceTypes.AccessPoint) .WithSerialNumber("mt") .WithDescription("MikrotikAdmin4242$") // OPNsense / Nextcloud / Mail / Dns / PMG VMs are now injected implicitly by // LiberatorWithWinDCTranslator on the single Description="Liberator" host. Do NOT // re-add explicit AddDevice blocks for them - the translator throws on conflict. // Implicit VMIDs (10xx range, Proxmox-safe): OPNSense=1001, Dns=1002, Nextcloud=1020, Mail=1090, PMG=1091. .AddDevice("DC", DeviceTypes.Server) .WithSerialNumber("dc") .WithDescription("Windows Server") .AddDevice("DefaultClient1", DeviceTypes.Client) .WithSerialNumber("client1") .AddDevice("DefaultClient2", DeviceTypes.Client) .WithSerialNumber("client2") .AddTask(CommonTasks.Support) .WithDevice("osisasupport", DeviceTypes.Software) .AddTask(CommonTasks.Office) .WithDevice("office365netsetup", DeviceTypes.Software) .AddOrganizationalUnit("local") .AddEmployee("A") .HasTask(CommonTasks.Support) .HasTask(CommonTasks.Office) .UsesDevice((DeviceName)"DefaultClient1") .UsesDevice((DeviceName)"DefaultClient2") .AddEmployee("B") .HasTask(CommonTasks.Support) .UsesDevice((DeviceName)"DefaultClient1") .UsesDevice((DeviceName)"DefaultClient2") .Build(); // --- Translate to Config --- // IP scheme = per-OU 10.X.0.0/24 auto (server .200-.240, client/DHCP .1-.199, gateway .254). // Test bench leaves GatewayIP unset; VirtualHyperVTestBase fills it from the upstream NIC. var translator = new LiberatorWithWinDCTranslator(company); translator.Translate(); var config = translator.Config; // Outbound relay via Brevo transactional SMTP (587, SASL). Chosen over M365 SMTP AUTH: // M365 client submission only sends as the ONE authenticated mailbox, so on-prem a@/b@ // bounce with "554 5.2.252 SendAsDenied". Brevo sends as any address on the authenticated // domain (no SendAs wall). See docs/dyndns-domain-setup.adoc -> "Mail Relay (Outbound // Smarthost)" Method T. The SASL Login is the generated <id>@smtp-brevo.com value from // Brevo's SMTP page, NOT the account email (account email -> 535 auth failed). // !!! LIVE CREDS - DO NOT COMMIT. Rotate the Brevo SMTP key or swap to a placeholder // before committing this file (same hazard as the live No-IP DDNS-Key creds below). !!! config.AddMailRelay( "smtp-relay.brevo.com", "ac2c7d001@smtp-brevo.com", "xsmtpsib-d71a6a436ab4915d4b1ac3c1c2746a135f3ed3b42383f650f9e55ce4e4f42cea-d1YXzkaw8HYX64a8".ToSecretString()); // Stub upstream gateway so ToProxmoxAutoInstall has a non-None GatewayIP. // VirtualHyperVTestBase overrides this with the real test-bench upstream. // Probably is this gateway override wrong //config.GatewayIP = IP.Parse("10.0.0.1"); config.ThrowIfNamesAreNotValid(); // --- Output --- var json = JsonSerializer.Serialize(config, NETSetupConfig.JsonSerializerOptions); Console.WriteLine(json); AbsolutePath scriptDir = (AbsolutePath)GetScriptDir(); var fileOp = new PhysicalFileOperator(scriptDir, Microsoft.Extensions.Logging.Abstractions.NullLogger<PhysicalFileOperator>.Instance); fileOp.WriteAllTextAsync("Customer.json", json, CancellationToken.None).Wait(); Console.Error.WriteLine($"Written to {scriptDir / "Customer.json"}"); static string GetScriptDir([CallerFilePath] string? path = null) => Path.GetDirectoryName(path)!; -
Regenerate JSON. Run the file-based app directly. Writes
<Customer>.jsonnext to the.cs, prints same JSON to stdout.dotnet run Customers\<Customer>.csRebuild all at once:
Customers\_rebuild-all.cmd -
Publish to production. Production JSONs live in the osisa Dropbox at
%USERPROFILE%\osisa Dropbox\NETSetup\BOOT\Config\. Copy the regenerated<Customer>.jsoninto that folder. NETSetup installs pull the config from there (synced to the USB SYSTEM partition and baked into offline ISOs). -
Commit + push. Commit both the
.cschange and the regenerated.jsonso the repo and Dropbox stay in lock-step.
-
3.3. NETSetup: Install using USB Stick
3.3.1. 0. Liberator Pre-Step: Flash Mikrotik FIRST
|
Important
|
Setting up a base Liberator? The Mikrotik hAP ax2 MUST be flashed BEFORE you touch the Proxmox host. The Proxmox auto-install expects Do this on the install laptop, NOT the Liberator box. |
-
Power the Mikrotik. Plug an Ethernet cable from your laptop directly into Mikrotik port 2 (any LAN port works; port 1 is WAN - do NOT use it). No upstream switch, no intermediary.
-
Grab the admin password from the sticker on the bottom of the Mikrotik unit. New units no longer ship empty - the sticker password is mandatory.
-
Run on the laptop (elevated PowerShell):
netsetup flash-mikrotik -AdminPassword "<sticker-password>"The command sets your NIC to
192.168.88.10/24, talks to the Mikrotik at factory192.168.88.1, uploads the.rsc, resets-with-config, and reverts your NIC to DHCP. Post-flash the Mikrotik lives at10.0.0.1. -
Wait for the verify-alive ping pass (~4 min total: 2 min grace + 2 min poll). Unplug the laptop. Now proceed to the USB stick steps below.
3.3.2. Precondition: Making the USB Stick
-
Goto your existing Personal Computer and login with an Administrator
-
Press Win and X and open Terminal (Administrator) and confirm
-
Ensure NETSetup itself is installed + Rufus. Preferred path is the osisa Chocolatey source; the direct-exe fallback and the
appsettings.jsonrequirement are covered in the main NETSetup docs Install section. Quick version (nochocoyet? install Chocolatey first from https://chocolatey.org/install):choco source add --name=netsetup --source=http://netsetupprod.osisa.com:8080/chocolatey choco install netsetup -y -
Build the ISO. Open an elevated PowerShell and run whichever matches your install path:
netsetup create-iso # choco install (preferred) cd C:\NETSetup\ ; .\NETSetup.exe create-iso # direct .exe fallbackThe ISO is written to
C:\NETSetup\Iso\NETSetup.iso.
-
Plug in your USB Stick to your computer
-
Press Win and R and type "rufus" and press enter and confirm
-
Select your USB Stick from the list at the top "Laufwerk" (Drive):
-
Click the "AUSWAHL" (SELECT) button and select the "NETSetup.iso" file from your C:\NETSetup\Iso\ folder.
-
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"
-
-
Click the "START" button.
-
Rufus might show you a warning. Click "OK" to confirm that all existing data on the stick will be deleted.
-
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.
-
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.
-
In case you want to install Liberator (which is proxmox), you need to also flash second different USB stick with our proxmox iso, flash in DD Mode with Rufus, FileType: FAT. TODO: When running netsetup command create-iso auto ensure newest proxmox.iso is in C:\NETSetup\Iso\
3.3.3. 1. Ensure Requirements for Installation using NETSetup USB Stick
-
Config has to be online of customer
-
Physical access to the machine, which has internet cable, power, hdmi/displayport cable and display and a keyboard connected.
-
Press power button and hold the button labeled ESC until you see a menu
-
You should see Aptio Setup AMI (Bios Setup)
-
Security > Secure Boot > Ensure Secure Boot disabled
-
Boot > Ensure Fast Boot Disabled
-
Chipset > PCH-IO > Ensure Wake on LAN Enabled and State after G3 = S0 State (Auto Power on)
-
Main Set system date and time to approximatly right
-
F4 Save and exit (to save changes and reboot)a
-
3.3.4. 2. Using the Install USB Stick
-
Plug the USB Stick in the target machine on the back side (directly into the motherboard and not a front header USB).
-
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.
-
Use the arrow keys to select USB. If you have multiple USB shows up, pick the topmost occurence and press enter
-
If it asks you to select something, type the answer using the keyboard and press enter.
-
Wait for the installation to finish, it reboots several times
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
Reminder for base Liberator: if you skipped the Mikrotik flash, the Proxmox auto-install will brick on first boot. See Section 0 - Flash Mikrotik FIRST.
3.3.5. 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.
-
Prepare Stick #1 (NETSetup) following section 1 above with
NETSetup.iso. -
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"
-
-
Plug BOTH sticks into the target machine. Both must stay plugged in for the entire installation.
-
Power on and hold F9 (or the manufacturer’s boot-menu key) to open the UEFI boot menu.
-
Select Stick #1 (NETSetup) — NOT the Proxmox one yet.
-
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 generatedanswer.tomlto 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.
-
When the machine reboots, hold F9 again to open the boot menu a second time.
-
This time select Stick #2 (Proxmox) — NOT NETSetup.
-
The stock Proxmox GRUB menu appears. Pick the "Install Proxmox VE (Automated)" entry.
-
Proxmox boots,
proxmox-fetch-answerscans all attached partitions, findsanswer.tomlon stick #1’sSYSTEMpartition, and runs the unattended installer. Both sticks stay plugged in until the installer finishes reading the answer. -
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.3.6. 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.
4. Usage
SHA-256 Hash of NETSetup.exe:
D1B899C2517BDF5D96716783CF9BFE89662A11763B4F0AF1A5E22B371C516530
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.
4.1. 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
--ticketand--hostnameparameters 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
--offlineto 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-x64NETSetup, linux-musl-x64NETSetup.musl) intooutput/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,CompositionEditionIDunderHKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion) plusslmgr /ipk+/atowith 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-keyis omitted, a generic (non-activation) edition-switch key is used. install-drivers--path <path> [--recursive]-
Install all
.infdrivers in <path> viapnputil /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 withNTUSER.DAT/UsrClass.dat/AppData\Localexcluded (would corrupt fresh registry/cache).AppData\Roamingrestored 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 ininstallflow on Windows side just before update phase. Idempotent viaBackupRestoredlog marker. Requires elevation.
4.2. 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
--filepath 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.
4.3. 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.
4.4. 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. WAN = dual-WAN auto-detect: dhcp-client on BOTH untagged
ether1AND taggedether1-vlan10(VLAN 10 sub-iface, constWanVlanId="10"); both join theWANinterface-list; masquerade usesout-interface-list=WAN. Box auto-works on a plain DHCP net (untagged lease) OR behind an XGS-PON modem (VLAN10-tagged lease) - same wire so only one ever leases. bridge ether2-5+wifi1+wifi2 = LAN with static10.0.0.1/24+ dhcp-server pool10.0.0.2-10.0.0.254.--admin-passwordis required: the CURRENT admin password on the device (sticker pwd for stock units).--macoptional: 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 static192.168.88.10/24on it, 10s countdown, probes192.168.88.1, exits with a clear message if unreachable; reverts NIC to DHCP afterreset-configuration(and infinallyon any failure).Flow: ensures
putty.installchoco pkg (providespscp) → resolves MAC→IP via ARP scan (fallback192.168.88.1) → renders .rsc →pscpupload →/system reset-configuration keep-users no-defaults skip-backup run-after-reset=<file>→ polls10.0.0.1until API responds (5 min timeout). Cable PC to ether2-5 before running.Manual winbox equivalent (verify on site / fallback for non-flashed boxes) + the full iway internet-order activation (OTO slot, modem, line MAC registration, WAN VLAN10, WiFi, TV box): Step-by-step → Setup phase.
4.5. Hardware Inventory
scan-hardware[--output <path>]-
Probe local machine via CIM, dump JSON. Output defaults to
{LogsRoot}/hwscan-<timestamp>.json.LogsRoot=appsettings.jsonLoggingOptions.LogsPathOverrideelse/NETSetup/Logs. If--outputis 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-selfskips the scanning machine. Unreachable hosts logged as failures but do not abort; result JSON includes per-host status. Output path same rules asscan-hardware.
4.6. 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.
4.7. 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.
4.8. 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.
4.9. 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.
4.10. 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.
4.11. 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.
4.12. 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--modeloverrides the default model.
4.13. 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).
4.14. 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
5. How it works
5.1. 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 genericnixos.iso(qemu-guest-agent on, master pubkey bake in). Wait guest-agent report ipv4. Pick template viaNixTemplateFactory.ForMachine(machine). Emit flake/default/hardware nix to/NETSetup/generated-nix/<vm>.scpconfig to VM/nixos-config/,sshinstall.sh -h <name> -d <disk>via master key. -
Windows flow — os match
Windows10/Windows11/WindowsServer201x/202x. Build per-VM offlineNETSetup-{ticket}-{host}.isovia 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 whenhost.Description == "Liberator". Prebuilt raw disk image at/var/lib/vz/template/iso/opnsense.img(synced viaEnsureOpnsenseImage.EnsureOpnsenseImageOnProxmoxHostfrom DropboxNETSetup/BOOT/Images/Opnsense.7z).qm createSeaBIOS, 5 vNICs Option Y (net0untagged WAN,net1tag=10 server tier,net2tag=20 LAN,net3tag=250 MGMT,net4tag=254 Guest),qm importdiskraw image,qm set --scsi0 … --boot order=scsi0,qm start. First-bootOpnsenseInstallSSHes in with master key, scpsconfig.xml(5 if assignments, DHCP per VLAN, static maps fromconfig.Computers[].MAC, DNS forwarder → NixDns, FW deny-default inter-VLAN), reloads services, persists api key. Order first inGetOrderedVmsForHost. See Liberator.adoc for full topology + role matrix.
Paths / keys / cadence:
-
Proxmox iso store:
/var/lib/vz/template/iso/(bothnixos.isoandNETSetup-*.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.EnsureNixosIsoOnProxmoxHostcheck sha, download+7z-extract if miss/mismatch. -
Cadence: every
netsetup updatecall 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/interfacesrewrite (bond0 LACP, vmbr0 VLAN-aware, vmbr0v250 host mgmt) withifupdown2safe-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.
5.2. Wake-on-LAN integration
NETSetup can integrate with Wake-on-LAN (WOL) to remotely trigger machine setup or maintenance. The netsetup CLI exposes wake-machine, wake-machines, enable-wol, and query-wol-status (see the Wake-on-LAN commands in the Usage section). Network + hardware must support WOL: BIOS/UEFI WOL enabled for the adapter, NIC supports WOL, and the network passes WOL packets.
Full setup guide - required packages, enabling WOL on Linux targets with ethtool, and a persistent systemd unit - lives in WOL.adoc.
5.3. Linux PE notes
NETSetup installs Linux-based operating systems (Proxmox, NixOS) from a Void Linux musl "Pre-Execution Environment" (UnixPE) - a squashfs image booted from the target disk’s ESP. WinPE (Phase 1) partitions the disk and lays down the Void squashfs + GRUB + the NETSetup.musl binary; on reboot Void boots, rc.local finds autoexecute.sh, and runs NETSetup.musl install to install the target OS via GRUB chainload.
Full architecture, boot chain, image build/modify procedures, and troubleshooting: void-linux-pe.adoc.
5.4. Diagrams
5.4.1. Proxmox VM Provisioning sequence
5.4.2. Liberator network topology
5.4.3. Liberator network push (cutover)
5.4.4. Public mail stack
6. Reference
6.1. Documentation index
All NETSetup-relevant docs in this repo. Some are already include::'d above; xref’d here too for quick nav. # prefixed = ignored / OLD.
-
FSD: Public Mail Stack — public mail stack (postfix + dovecot + roundcube + PMG LXC + DNS VM + PBS + DDNS), provider-pluggable
-
Blueprint: Public Mail — C# — file map, class signatures, build order, conventions checklist
-
Mail Domain Setup — per-customer runbook: standalone vs smarthost, provider account+token steps, DNS, gotchas, code examples
-
Void Linux PE (also include::'d above)
-
NETSetup main flow (include::'d above via .pages.adoc)
-
netsetup-mail — public mail stack components + flow
-
NETSetupProvisionVms (include::'d above via .pages.adoc)
6.2. Further reading
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
7. Changelog (NEWS)
7.1. 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.
-
FSD: Public Mail Stack — functional spec, decisions locked, deployment prereqs
-
Blueprint: Public Mail Stack — C# Implementation — file map, class signatures, build order
-
Mail Domain Setup — standalone vs smarthost runbook, per-provider account/token steps, DNS records, ISP gotchas, customer-builder code examples
-
netsetup-mail.puml — component + flow diagram (PBS, PMG LXC, mail VM, DNS VM, DDNS, ACME, smarthost)
-
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, OSNixDns): coredns auth zone + step-ca internal CA + lego LE DNS-01
ddns-updater. PMG LXC (Debian +proxmox-mailgateway, OSPmgLxc): inbound filter on 25, relays tomail.<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. -
Provider-pluggable DNS + ACME. New
NETSetupConfig.DnsProviderPOCO (Provider,CredentialsFile,CredentialsAttrs) feeds both lego (cert issuance) and ddns-updater (A-record sync) on the DNS VM. Supported provider list (intersection of nixpkgsservices.ddns-updaterand 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. -
Optional smarthost relay. New
NETSetupConfig.MailRelayPOCO (Host,Portdefault 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.PassFileis a SOPS-encrypted secret path on the mail VM; NETSetup never reads its content. -
DNS records auto-derived from customer config.
NixDnsTemplatescans 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 PMGmxalias pointing atpmg.<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. -
New OS constants in
NetsetupOS.NixMail,NixDns,PmgLxc. The two Nix-prefixed names satisfyIsNixBackedautomatically and are routed byEnsureCustomerVms→NixHostTemplateFactoryto the newNixMailTemplate/NixDnsTemplate(subclasses ofNixHostTemplate).PmgLxcdoes NOT satisfyIsNixBacked— intentional, soEnsureCustomerVmsskips it — and is routed by the newEnsureCustomerLxcs→ProvisionPmgLxcinstead. -
LXC provisioning infrastructure — all new.
LxcCommand.Builderwraps/usr/sbin/pct(subcommands:create,start,stop,destroy,exec,set,status) using the sameAddNamed/AddBoolpattern as every other NETCommand builder — never rawAdd($"--flag {value}").ProvisionPmgLxcis the LXC equivalent ofProvisionNixosVm(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 tomail.<CustomerDomain>:26. -
Wired into
RunProxmoxUpdate.EnsureCustomerLxcs.Runruns afterEnsureCustomerVms.Run— mandatory ordering, because the PMG relay config inside the LXC referencesmail.<domain>:26, so the mail VM must exist first.EnsurePbsInstalledis appended toEnsureProxmoxPostInstallafterEnsureLocalStorageEnabled(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. -
TestCompany fixture additions. Three new hosted devices on the Liberator test host (host serial
lib), inserted afterLibWin11: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 = cloudflareplaceholder (provider swap is a one-line config change, not a code change). -
Manual e2e via
info@osisa.com. After the automated test leaves the stack live, operator sends a real mail frominfo@osisa.comto<user>@test01.iam.freeand verifies arrival in roundcube onhttps://mail.test01.iam.free/. Reply path back toinfo@osisa.comexercises the smarthost relay (or direct MX, depending onMailRelay). Automated e2e covers provisioning + service-up + DNS resolution: new test classProxmoxPublicMailTests.Liberator([Ignore("Local Tests")]) asserts PBS active on host,pct listshows pmg@lib,qm listshows mail+dns, dovecot/postfix/ nginx/coredns/step-ca/lego/ddns-updater allis-active,dig @localhost mail.test01.iam.freeresolves, and a rawHttpClientGET ofhttps://mail.test01.iam.free/returns the roundcube login. -
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.
-
7.2. 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:
-
First-boot is USB-only.
netsetup-first-boot.shcopies the linux-x64 binary from the SYSTEM partition (labelSYSTEMon USB #1) andcp -ppreserves its mtime. No Dropbox / Pages fallback. Noself-update.sh. NoExecStartPre— the systemd unit simply runs/NETSetup/NETSetup update. -
Self-update via GitHub Releases.
UpdateMethods.EnsureLatestBinaryOrReexecruns as the first step of everynetsetup updatecycle. It decrypts an embedded fine-grained PAT (Contents:Read onosisa/netsetuponly, AES-256-CBC viaosisa.Security.Contracts.EncryptionHelpersostrings(1)only sees a base64 blob), GETs/repos/osisa/netsetup/releases/latest, parses the response withParseReleaseInfo, and compares versions withCompareSemVer. 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 asNETSetup update, waits, and `Environment.Exit`s with the child’s exit code. -
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.249in0.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). -
Post-install ran step-by-step in C#.
EnsureProxmoxPostInstallno longer writes a giant bash script. Each side-effect (disable enterprise repos, writepve-no-subscription.sources, install nag-removal hook, configure banners) is a discrete NETCommand orIFileOperatorcall so failures stay scoped. -
Customer VM provisioning (e.g. Nextcloud + Win11).
EnsureCustomerVms.Runis wired intoRunProxmoxUpdateafterapt 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. TheOfflineCompanytest fixture has a Nextcloud server (100@lib) and a Win11 client (201@lib) hosted on the Liberator; testProxmoxWithUSBStickTests.Liberatorend-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 (rawHttpClientprobe — 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.CreateOfflineProductionISOrequires 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(whereticketIdis the int parse ofConfigNameor its FNV-1a hash for non-numericConfigName`s). `ProvisionWindowsVmOnProxmoxdetects an existing file at that path and skips the source-dependent rebuild. The Liberator test follows the same pattern:PreStageLibWin11IsoOnLiberatorbuilds locally + SCPs to Liberator before NETSetup runs.NixOS install on tmpfs-limited VMs.
ProvisionNixosVmattaches a 16 Gscsi1swap disk pre-create andNixRemoteInstallCommandrunsmkswap /dev/sdb && swapon /dev/sdb && mount -o remount,size=20G /nix/.rw-storebefore invokinginstall.sh. Without this the Nextcloud closure overruns the live-ISO tmpfs (RAM/2, ~4 G at 8 G RAM) witherror: 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 usesqm stop+qm start(notqm reboot) because the live-ISO installer doesn’t honor ACPI shutdown. -
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.
7.3. 2025-07-03
Updated the Flow to include the future Flow for Liberators.
8. Developer
8.1. Build & CI
When you push changes to the NETSetup repository, the corresponding Git Action tests, builds, and updates the executable and the ISO-Image automatically.
On push to main / develop, the same netbase-build Push --sln src/netsetup.sln step also packs + pushes NuGet packages to GitHub Packages (https://nuget.pkg.github.com/osisa): osisa.Mikrotik + osisa.OPNSense (libraries) and NETSetup (dotnet tool, command netsetup). Pack trigger = <DeploymentMethod>Library</DeploymentMethod>. Libs get it by importing src/Package.Build.props; the CLI sets it inline plus PackAsTool. CLI single-file stays RID-gated (create-binaries keeps emitting NETSetup.exe / NETSetup / NETSetup.musl). Push needs the PAT (MASKE_GHPAT) to carry write:packages scope.
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
8.2. 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). Flash resulting NETSetup.iso to USB.
|
Important
|
Direct exe need
appsettings.json next to itChoco install drop Needs
Grab the file from a choco install (
|
8.3. Dev machine setup (legacy scratch)
|
Note
|
Legacy scratch notes. Prefer the maintained Setup osisa NETBase dev machine and Verify dev-machine setup docs. Kept here for reference only. Any token below is a placeholder - use your own credentials, never a shared secret. |
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
[OSISA NUGET PACKAGE TODO]
dotnet nuget remove source github
dotnet nuget add source --name github "https://nuget.pkg.github.com/osisa/index.json" -u <your-email> -p <github-pat> --store-password-in-clear-text
"./nuget.exe setapikey <github-pat> -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
[TODO INSTALL VS FROM CONFIG + COPILOT & RESHARPER]
Windows SDK
https://developer.microsoft.com/en-us/windows/downloads/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
8.4. Domain model notes (legacy)
|
Note
|
Legacy design brain-dump of the planned contracts / entity model. Kept for reference. |
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]
8.5. In-development backlog
|
Note
|
Live feature backlog (RALPH task list), maintained in src/NETSetup.adoc. Developer-facing.
|
solution: src/NETSetup.sln project: NETSetup testProject: NETSetup.Tests language: csharp testCommand: dotnet test src/NETSetup.sln --nologo --verbosity minimal ---
9. In Development
9.1. 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.isoon Proxmox ISO storage. Dropbox holdsnixos.iso.7z+nixos.iso.sha256(ArchiveSync + SevenZipCommand pattern — see Serenacompressed_image_driver_sync). -
Bootstrap SSH: single master NETSetup keypair. Pubkey committed at
src/NETSetup/nixos/netsetup-master.pub, baked intoiso/iso.nixrootauthorized_keys. Privkey shipped with NETSetup binary undersrc/NETSetup/Resources/netsetup-master.key(embedded resource, extracted to/NETSetup/.ssh/netsetup-master.keychmod 0600 on use). -
OS trigger:
Machine.OperatingSystem.Name.StartsWith("Nix")→ nix-backed.OS.NixOS(contracts) routes toNixHostTemplate. New NETSetup-local OS constantsNixNextcloud,NixIPFSdefined inNETSetup.Config.Nix.NetsetupOS(NOT in contracts lib). -
Templates: abstract
NixHostTemplatebase + concreteNixNextcloudTemplate,NixIpfsTemplate. Templates exposeGenerateDefaultNix()(host config body) andGenerateHardwareNix(). Content derived fromsrc/NETSetup/nixos/hosts/default-nextcloud/default.nixanddefault-ipfs/default.nix.GenerateFlakeNix()exists but is no longer called byProvisionNixosVm— see post-implementation pivot below. -
Per-VM emit shape (post-pivot, 2026-04-30): flat
config.nix+hardware.nix+ sopssecrets.yaml+.default.keyat the per-VM generated dir root. No emittedflake.nix— the base nix tree at/iso/nixos-config/flake.nixalready exposesnixosConfigurations.default = mkHost "default" ./config.nix(nixos/hosts.nix:113). Overlaying our flake strippeddisko + lanzaboote + sopsmodules fromsharedHostModules, causingdisko-installto crash withartifacts[1]: unbound variable(nodiskoScriptartifact when disko module isn’t loaded). Reusing the base flake keeps every shared module in scope. -
End-to-end test ISO bootstrap:
ProxmoxNixosNextcloudTestsself-bootstraps every prerequisite in[TestInitialize]plusnixos.isoper run:-
EnsureChocoPackageCommand(nested NETCommand-style wrapper aroundchoco install <pkg> -y, idempotent) ensuressopsis on PATH forSopsCommand. -
EnsureWslDistroCommand(nested NETCommand-style wrapper aroundwsl --import) ensures theNixOSWSL distro is registered.IsDistroRegisteredshort-circuits onwsl -l -qso already-imported distros skip the Dropbox fetch. When absent, the rootfs tarball is pulled fromImages/NixOSWsl.tar.gzon 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 .#isoagainst the localsrc/NETSetup/nixosflake →C:\NETSetupDEV\nixos.iso) builds the ISO, thenpveHost.UploadISOships it sha-driven so unchanged builds skip the wire transfer.
-
-
Why
wsl --importinstead ofwsl --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.shinvoked with-h default(not the per-VM hostname). Per-VM OS hostname identity preserved vianetworking.hostNameset inside the emittedconfig.nixbyNixHostTemplate. -
Installer SOPS age key: single committed pair at
src/NETSetup/nixos/.sops/installer.{key,pub}. Public key pinned asProvisionNixosVm.InstallerAgePublicKeyconstant. Private key shipped via csproj<Content CopyToOutputDirectory="Always" CopyToPublishDirectory="Always">, deployed to/NETSetup/.sops/installer.key(700/600) bynetsetup-first-boot.shsection 4c.ProvisionNixosVm.EmitConfigBundlegenerates a random 32-byte hex LUKS password, encrypts it viaSopsCommandto a per-VMsecrets.yaml, and copies the installer private key into.default.keysoinstall.shL156 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.NixHostTemplatetakes device param. -
Entry point: extend
UpdateMethods.RunProxmoxUpdate(appendEnsureCustomerVms(host, config)at end).InstallCommand.InstallStage.ProxmoxVMsstays a stub. -
New commands live in
NETSetup/NETCommand/— no changes to sharedosisa.NETCommand.NixRemoteInstallCommandcomposesLinuxCommandBaseforssh/scp.QmGuestCommandwraps Proxmox-APInodes/<node>/qemu/<vmid>/agentvia corsinvest client (reuse existingProxmox/Hostwrappers fromosisa.SystemManagement.Proxmox). -
No
System.IO: useAbsolutePath+PhysicalFileOperator. No rawProcess: 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
OSconstants and nix-detection extension: createsrc/NETSetup/Config/Nix/NetsetupOS.cswith public static readonlyOperatingSystem NixNextcloud = OperatingSystem.From("NixNextcloud", OperatingSystemFamily.Linux_GNU)andNixIPFS = OperatingSystem.From("NixIPFS", OperatingSystemFamily.Linux_GNU). Createsrc/NETSetup/Extensions/NixOsExtensions.cswithpublic static bool IsNixBacked(this OperatingSystem os)returning true whenos == OS.NixOSORos.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.nixwith qemu-guest-agent + master pubkey: addservices.qemuGuest.enable = true;andusers.users.root.openssh.authorizedKeys.keys = [ (lib.fileContents ../netsetup-master.pub) ];(createsrc/NETSetup/nixos/netsetup-master.pubplaceholder committed with a real ed25519 pubkey). Keepservices.openssh.enable = true, ensurePermitRootLogin = "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-imageskill + standalone script: add.claude/skills/build-nixos-image/SKILL.md(description: "Build NixOS installer ISO, 7z, sha256, upload to Dropbox") plussrc/NETSetup/nixos/scripts/build-nixos-image.shthat (1)cdto 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)cpboth 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 distronixos(modeled afterupdate-nixos-imageskill). 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/: abstractNixHostTemplatewith propertiesHostname(MachineName),DiskDevice(string, default/dev/sda),UserName(string, defaultadmin),TimeZone(string, defaultUTC),AuthorizedKeys(IReadOnlyList<string>) and virtual methodsGenerateFlakeNix(),GenerateHardwareNix(), abstractGenerateDefaultNix(). ConcreteNixNextcloudTemplateandNixIpfsTemplateoverrideGenerateDefaultNix()with content ported fromsrc/NETSetup/nixos/hosts/default-nextcloud/default.nixanddefault-ipfs/default.nix(literal string). FactoryNixTemplateFactory.ForMachine(Machine machine)returns concrete template based onmachine.OperatingSystem:NixNextcloud→ NixNextcloudTemplate,NixIPFS→ NixIpfsTemplate,NixOS→NixHostTemplategeneric subclassNixGenericTemplate, other → throwsNETSetupException. 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.cswithpublic 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 JSONdata.result[].ip-addresses[]filteringip-address-type == "ipv4", excluding127.0.0.0/8and169.254.0.0/16. Polls every 2s until first match or deadline. ThrowsNETSetupExceptionwithvmIdin 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
NixRemoteInstallCommandundersrc/NETSetup/NETCommand/NixRemoteInstallCommand.csusing NETCommand builder pattern. Builder exposesWithIp(IPAddress),WithHostname(string),WithMasterKey(AbsolutePath keyPath),WithConfigDir(AbsolutePath localGeneratedDir),WithDiskDevice(string)(default/dev/sda),CopyHostSshKeys(bool).Execute()runs: (1)ssh-keyscantarget → 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 aLinuxCommandBaseinternally. ReturnsNixRemoteInstallResultwithSuccess,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.isoexistence AND sha256 match against DropboxNETSetup/ISOs/nixos.iso.sha256. If missing or mismatch: downloadsnixos.iso.7zto temp, verifies sha256, extracts viaSevenZipCommandto/var/lib/vz/template/iso/nixos.iso, logs action. Idempotent — returns silently if match. Lives atsrc/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)insrc/NETSetup/Stages/5-NETSetupLinux/ProvisionNixosVm.cs: (1) buildVirtualMachineentity fromMachine(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) callproxmoxHost.CreateVirtualMachine(vm, autoStart: true), (4)DiscoverVmIpViaQmAgent(vm.Id, TimeSpan.FromMinutes(5)), (5) pick template viaNixTemplateFactory.ForMachine(machine), emit files toAbsolutePath("/NETSetup/generated-nix") / machine.Name, (6) runNixRemoteInstallCommandbuilder with generated dir + master key path, (7) log result, throwNETSetupExceptionon 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)insrc/NETSetup/Stages/5-NETSetupLinux/ProvisionWindowsVmOnProxmox.cs: (1) skip if VM exists, (2) derive ticket + hostname frommachine.Name+ config, (3) invoke existingCreateIsoCommandoffline path (MainISO.CreateOfflineProductionISO+CopyToProxmoxStorageIfApplicable) to produceNETSetup-{ticket}-{host}.isoin/var/lib/vz/template/iso/, (4) buildVirtualMachinewith that ISO as main, stealth hardware iffShouldUseStealthHardware(machine), (5)proxmoxHost.CreateVirtualMachine(vm, autoStart: true). Reuses existingCreateVirtualMachines.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 methodEnsureCustomerVms(ISystemHost host, NETSetupConfig config, IServiceProvider services)called at end ofRunProxmoxUpdate(afterRunBtrfsScrub). Method: (1) connect to localProxmoxvia DI, (2)GetHostAsync(host.MachineName), (3) callEnsureNixosIsoOnProxmoxHostiff any nix-backed machine targets this host, (4)GetOrderedVmsForHost(config, host.SerialNumber)— iterate with DC first, (5) dispatch:IsNixBacked→ProvisionNixosVm, 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 diagramsrc/NETSetup/docs/NETSetupProvisionVms.pumlshowing: netsetup-update → EnsureNixosIso → CreateVirtualMachine → qm guest-agent → scp config → ssh install.sh → reboot. Include innetsetup.adocviainclude::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")
-
10. In Development
10.1. 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 tosrc/NETSetup.sln -
OPNsense-aligned repo method names: GetAllAsync, GetAsync, AddAsync, SetAsync, DeleteAsync
-
WiFi v7+ only (
/interface/wifipackage, 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(*Nformat) andName(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 intosrc/NETSetup.sln.osisa.Mikrotik.csprojtargets$(DefaultNetTargetFramework), pullsMicrosoft.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*Specrecords (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(carriesStatusCode + ErrorMessage + Detail?, message format"MikroTik {status}: {message}"or"MikroTik {status}: {message} ({detail})"). -
Transport (
Client/MikrotikHttpClient, internal sealedIDisposable): real impl. Basic auth"Basic " + base64(user + ":" + pw)exposed on internalAuthorizationHeadertest 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 keepsReasonPhraseas message. Self-signed cert skip viaHttpClientHandler.ServerCertificateCustomValidationCallback(default on). SharedJsonSerializerOptionswithPropertyNamingPolicy=null+DefaultIgnoreCondition.WhenWritingNull+MikrotikStringConverter. Test-only constructor accepts a pre-wiredHttpClientforFakeHttpMessageHandler. -
JSON (
Json/MikrotikStringConverter, internal sealedJsonConverterFactory): real impl.CanConvertclaims exactlybool | int | long | string.CreateConverteryields 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 withInvariantCultureto avoid German-locale thousands-separator regressions. String converter coerces rawTrue/False/Numbertokens into"true"/"false"/decimal-string so MikroTik fields that occasionally come back as a raw number (e.g.pvid) still bind to astring?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 haveId == null, families without a name field leaveName == null. Concrete entities (task 4) inherit this base + add family-specificstring?/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 overrideabstract string ResourcePath(e.g."interface/bridge").GetAllAsyncdelegatesHttpClient.GetAsync<T>(ResourcePath, ct);GetAsync(id, ct)→HttpClient.GetSingleAsync<T>(ResourcePath + "/" + id, ct).Add/Set/DeletebufferPendingOperationrecords into aList<PendingOperation>;IsDirty == Pending.Count > 0.FlushAsync(ct)iterates pending ops in insertion order:Add → PutAsync(returns saved entity,Applied[]capturessaved.Id ?? ""),Set → PatchAsync(ResourcePath, op.Id, body),Delete → DeleteAsync(ResourcePath, op.Id). Per-optry/catchoverExceptioncaptures intoMikrotikFlushFailure(Id, Error);Pending.Clear()runs unconditionally after the loop. Result typeMikrotikFlushResult(IReadOnlyList<string> Applied, IReadOnlyList<MikrotikFlushFailure> Failed). Pending op carriesPendingOperationKindenum (Add | Set | Delete) + id (empty string for Add until device assigns) + nullableobject?body. No retry, no rollback - caller decides via Applied/Failed split.GetAllAsync+Add+Set+Deletearevirtualso singleton + read-only subclasses can override. -
Entities + repositories (17 resource families): all
internal sealed. Entities inheritMikrotikEntityand 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);DnsSettingsSINGLETON (ip/dns,servers,allow-remote-requests);FirewallNatRule(ip/firewall/nat,chain,action,out-interface,in-interface,in-interface-list,disabled,comment);FirewallFilterRule(ip/firewall/filter,chain,action,protocol,dst-port,in-interface,in-interface-list,comment,disabled);WifiConfiguration(interface/wifi,ssid,country,channel,security);WifiSecurity(interface/wifi/security,authentication-types,passphrase);User(user,password,group);SystemIdentitySINGLETON (system/identity, hostname via inheritedName);SystemResourceSINGLETON + READ-ONLY (system/resource,platform,board-name,version,uptime,free-memory : long?,total-memory : long?-Add/Set/DeletethrowNotSupportedException). Singleton repos overrideGetAllAsyncto wrap the single object in a one-entry array. Repository <→ Contracts spec name drift to bridge in the mapper (task 6):Bridge.Protocol → protocol-modevs specVlanFiltering(spec keeps only Name + VlanFiltering);BridgePort.Tagged(entity, literal wire key) vsBridgePortSpec.TaggedVlans(caller-friendly semantic). -
Unit of work (
UnitOfWork/IMikrotikUnitOfWork+MikrotikUnitOfWork, internal): real impl. Exposes oneIMikrotikRepository<T>getter per resource family (17 props), all backed by a singleMikrotikHttpClientinjected in the ctor.CommitAsync(ct)iterates the 17 repos in declaration order via a privateFlushIfDirtyAsync<T>helper: skips repos with!IsDirty, otherwise awaitsFlushAsyncand 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 isMikrotikCommitResult(IReadOnlyList<MikrotikFlushResult> Results)withHasFailures ⇒ Results.Any(r ⇒ r.Failed.Count > 0). EmptyResults(no repos were dirty) != failure. -
Data store + reference (
DataStore/, all internal): real impl.MikrotikDataStore : IMikrotikDataStore, IDisposableis the canonical HttpClient owner - production ctor(MikrotikConnection)builds a freshMikrotikHttpClient(connection, NullLogger.Instance)+ aMikrotikUnitOfWork(httpClient)and disposes the HttpClient onDispose()(idempotent via_disposedflag). Internal test-seam ctor accepts a pre-built HttpClient forFakeHttpMessageHandler.MikrotikDataStoreReference : IMikrotikDataStoreReferencecarries aMikrotikConnectionand aFunc<MikrotikConnection, MikrotikHttpClient>factory (defaults tonew MikrotikHttpClient(connection, NullLogger.Instance); internal ctor lets tests inject).ExistsAsync(ct)probesGET /rest/system/resourcevia a throw-away HttpClient (using var http = _httpFactory(_connection)), returnstrueon any successful deserialization,falseon anyException(auth failure, timeout, JSON parse, cancellation - all reduce to "not reachable").OpenDataStore()builds a fresh HttpClient +MikrotikDataStore(caller owns disposal).MikrotikDataStoreReferenceProvideris a stateless factory returning a new reference per call. Provider’sGetReferencereturn type tightened fromobjecttoIMikrotikDataStoreReferencein this commit. -
Entity mapper (
Mapping/MikrotikEntityMapper, internal static): real impl. 32 static methods - oneToEntity(XxxSpec)+ oneToSpec(Xxx entity)per family for 16 spec families (SystemResource excluded - read-only, noSpeccounterpart). Mechanical copy-property-to-property, no business logic.ToEntityprojects spec record positionals to entity nullable properties as-is.ToSpeccollapses entity nullables to spec record positionals viaentity.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.Protocolis NOT inBridgeSpecand 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-timeNotImplementedExceptionstub. Ctor takesIMikrotikDataStoreReferenceProvider + ILogger<MikrotikConverge>.RunAsyncflow: (1)Precondition.NotNullon connection + desired; (2) reachability gate viareference.ExistsAsync(ct)→ if false, log warning + returnMikrotikConvergeResult(Reachable=false, Deltas=Array.Empty); (3)using var store = reference.OpenDataStore()→ 16-repo UoW; (4) 16RunResourceAsync(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 thedeltaslist; (5)uow.CommitAsync(ct)wrapped in try/catch (warn + swallow on failure); (6) returnMikrotikConvergeResult(true, deltas.ToArray())with all 16 entries.RunResourceAsync<T>is the per-resource isolation seam: catches everyException, logs warning with the family label, returnsMikrotikResourceDelta(label, 0, 0, 0)so failures appear as zero-delta entries (3-layer failure pattern: per-op viaFlushAsync.Failed, per-family viaRunResourceAsync, global commit via the outer try/catch). Match keys per family:Namefor Bridge/Interface/Bonding/Wifi*/User/DhcpServer; compositeBridge+Interfacefor BridgePort; compositeBridge+VlanIdsfor BridgeVlan;Addressfor IpAddress; compositeDstAddress+Gatewayfor IpRoute;MacAddress(case-insensitive!) for DhcpLease; compositeChain+Commentfor Firewall*; FirstOrDefault for Dns/SystemIdentity singletons. Delete safety guard viaprivate const string NetsetupPrefix = "netsetup-"+IsNetsetupManaged(label): only 5 families have a delete loop (Bridge, Bonding, IpAddress, FirewallNatRule, FirewallFilterRule) and they only delete entries whoseNameorCommentstarts withnetsetup-- manually-created MikroTik resources NEVER get touched. Singleton converges (ConvergeDnsAsync+ConvergeSystemIdentityAsync) treatnulldesired spec as a no-op zero-delta (so non-Liberator hosts can leaveMikrotikDesiredState.Dns = nullandSystemIdentity = nulland the family is visited but does nothing). Interface and DhcpServer converges are Set-only (no Add path - device boots with a fixed set, converge togglesDisabled). 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 withOpnsenseConvergeBridge.Runat lines 177-182) instead of the adoc-spec "explicit Phase 3e step 4 inRunProxmoxUpdate`" - operationally equivalent today since `EnsureCustomerVmsruns insideRunProxmoxUpdate; revisit if/when explicitConfigureProxmoxBridges+RewireVmNicsToVlanshelpers 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); returnsnullif the file is missing.Parse(string)→MikrotikConnection?is a pure helper: empty/whitespace content returnsnull; per-line iteration overSplit('\n', RemoveEmptyEntries)withTrim()strips CRLF, skip-on-blank-or-#, split on first=, value getsTrim('"')to supportKEY="value"shell-style env files. Recognized keys:MIKROTIK_URL,MIKROTIK_USER,MIKROTIK_PASSWORD. Unknown keys silently ignored (forward-compat). Missing required key THROWSNETSetupException("<KEY> missing in mikrotik-api.env")- intentional divergence fromOpnsenseApiCredentials(returns null on incomplete) because a present-but-malformed file means flash-mikrotik wrote it wrong, which is a real bug to surface. The resultingMikrotikConnection.AllowSelfSignedCertificatekeeps its defaulttrue. NETSetup.csproj has aProjectReferencetoosisa.Mikrotik.csprojenabling Contracts visibility. 10/10 tests green inMikrotikApiCredentialsTestscovering 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()onIMikrotikConverge.RunAsync) to match the synchronousEnsureCustomerVmsflow. Best-effort: every failure path log + swallow, NEVER throws past the catch-allExceptionhandler. Phases: (1)ArgumentNullExceptionon null host/config; (2)CredentialsProvider()(defaults toMikrotikApiCredentials.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) privateIsEmptyState4-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-builtServiceCollectionwithAddLogging() + AddMikrotik()ifIMikrotikConvergenot registered); (6) inside try:converge.RunAsync(connection, desired).GetAwaiter().GetResult()→!result.Reachable→ warn "unreachable" + return, otherwise iterateresult.Deltasinfo-logging"MikroTik converge {Resource}: +{Added}/~{Updated}/-{Deleted}"; (7) catch-allException→ warn "converge threw - continuing" with exception attached. TheLiberatorGatecheck lives INSIDE the bridge body (caller minimalism -EnsureCustomerVmscalls unconditionally). Wired intosrc/NETSetup/Helpers/EnsureCustomerVms.csat lines 177-182, immediately afterOpnsenseConvergeBridge.Run, NOT insideRunProxmoxUpdateas the adoc spec suggested - the two REST-converge bridges paired side-by-side is more discoverable, and operational cadence is identical becauseEnsureCustomerVmsitself runs insideRunProxmoxUpdate. 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"withStringComparison.Ordinal) returnsnew 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);BridgePorts4 entries (ether2 + ether3 trunk withPvid=1+ tagged"10,20,250,254"; ether4 + ether5 access withPvid=20+ empty tagged);BridgeVlans4 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 + FirewallFilterRulesempty (OPNsense owns DHCP/NAT/filter);WifiConfigurations3 entries on wifi1/wifi2/wifi3 for SSIDsLiberator+Liberator-Guest+admin-oob-lib, countryCH, channel2ghz-onlyn,5ghz-ax/2ghz-onlyn/5ghz-ax, eachSecurityfield referencing aWifiSecuritySpec.Name;WifiSecurities3 entries with PSK profiles (wpa2-psk,wpa3-pskfor Liberator + open for Guest +wpa3-pskfor admin-oob), passwords fromconfig.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(bothpublic string?; latter doubles as admin user pw AND admin-oob-lib WPA3 PSK - single source of truth). Adoc-spec drift: spec mentionedMachineName.Value, impl uses the realNETSetupConfig.SelectedHostnamefield. 18/18 tests green. -
UPDATE (2026-05-29, dual-WAN inbound): the desired-state builder no longer leaves
FirewallNatRules + FirewallFilterRulesempty for inbound published services - it now emits per-ServedPortnetsetup-dnat-{port}dst-nat +netsetup-allow-{port}forward-accept rules. New constMikrotikDesiredStateBuilder.WanInterfaceList = "WAN". Those rules setInInterfaceList = "WAN"with EMPTYInInterface(NOT literalether1). Reason: behind an XGS-PON modem the WAN arrives tagged on VLAN 10 so ingress isether1-vlan10, notether1; an ether1-pinned port-forward would never match and inbound mail/nextcloud would be dead. Matching the WAN interface-list (which holds BOTHether1+ether1-vlan10) fires the rule on whichever iface carries the WAN. osisa.Mikrotik gainedInInterfaceListonFirewallNatRuleSpec+FirewallFilterRuleSpec(+ entities + mapper + NAT drift-check). DEPENDENCY: the converge REFERENCES theWANlist but does NOT create it - it relies on the flash-mikrotik.rsc(MikrotikRscBuilder, new constWanVlanId = "10") having created theWANinterface-list + both members + a dhcp-client on each iface. Flash-then-update is the normal order. (OPNsense still owns the per-VLAN DHCP + outbound NAT + inter-VLAN filter; only the inbound published-service port-forwards live on the Mikrotik.) -
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.MikrotikCommitResultTestscoversHasFailurescomputation across mixed inner outcomes.MikrotikDataStoreReferenceTestscoversExistsAsynctrue on 200, false on refused/401/timeout/non-JSON,OpenDataStorereturns a usable store, factory seam exercised.MikrotikServiceCollectionExtensionsTestsassertsAddMikrotikregistersIMikrotikConverge+IMikrotikDataStoreReferenceProvideras singletons (idempotent on multiple calls).MikrotikEntityMapperTests(+ in94c3e555) coversToEntity/ToSpecround-trip per family + null projection (string → "", bool → false, int → 0) + theBridgePortSpec.TaggedVlans<→BridgePort.Taggeddrift bridge.MikrotikConvergeTests(+ in94c3e555) 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,nulldesired 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.csroute-keyed (mirrorsosisa.OPNSense.Tests);TestInfrastructure/TestValues.cshosts shared constants. (Test agent renamed the auth-header property assertionAuthHeader→AuthorizationHeaderto match the shipped name.)-
✓ Create project scaffold +
MikrotikHttpClient+MikrotikStringConverter+MikrotikException: Createsrc/osisa.Mikrotik/osisa.Mikrotik.csproj(net10,<InternalsVisibleTo Include="osisa.Mikrotik.Tests" />) andsrc/osisa.Mikrotik.Tests/osisa.Mikrotik.Tests.csproj(MSTest + FluentAssertions). Add both tosrc/NETSetup.sln. CreateClient/MikrotikHttpClient.cs(internal) wrappingHttpClientwith: Basic auth header (Authorization: Basic base64(username:password)), self-signed cert skip viaHttpClientHandler.ServerCertificateCustomValidationCallback, typed methodsGetAsync<T>(string path)returningT(GET/rest/{path}),GetSingleAsync<T>(string path)for singleton resources,PutAsync<T>(string path, T entity)returningTwith.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)returningT(POST/rest/{path}). Error responses (HTTP 4xx/5xx) parsed from JSON{"error": N, "message": "…", "detail": "…"}intoMikrotikException. CreateJson/MikrotikStringConverter.cs- customSystem.Text.Json.Serialization.JsonConverterfactory handling MikroTik’s all-strings encoding: deserializes"true"/"false"tobool, numeric strings toint/long, preservesstringpassthrough. Registered on the sharedJsonSerializerOptionsused byMikrotikHttpClient. CreateExceptions/MikrotikException.cswithint StatusCode,string ErrorMessage,string? Detail. Createsrc/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 insrc/osisa.Mikrotik/with all public types.MikrotikConnectionrecord:string BaseUrl,string Username,string Password,bool AllowSelfSignedCertificate = true.IMikrotikConvergeinterface:Task<MikrotikConvergeResult> RunAsync(MikrotikConnection connection, MikrotikDesiredState desired, CancellationToken ct).MikrotikConvergeResultrecord:bool Reachable,MikrotikResourceDelta[] Deltas.MikrotikResourceDeltarecord:string ResourceType,int Added,int Updated,int Deleted. Desired state spec records (all inContracts/):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).MikrotikDesiredStaterecord: 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)registeringIMikrotikConverge+ 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
MikrotikEntitybase +MikrotikRepositoryBase<T>with batch buffering: CreateEntities/MikrotikEntity.cs(internal) - base class with[JsonPropertyName(".id")] string? Id { get; set; }andstring? Name { get; set; }. All MikroTik entity types will inherit from this. CreateRepositories/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. CreateRepositories/MikrotikRepositoryBase.cs(internal abstract): stores pending operations in aList<PendingOperation>(discriminated union of Add/Set/Delete),GetAllAsyncdelegates toMikrotikHttpClient.GetAsync<List<T>>(ResourcePath),GetAsyncdelegates toMikrotikHttpClient.GetSingleAsync<T>(ResourcePath + "/" + id), Add/Set/Delete append to pending list,FlushAsynciterates pending ops pushing via HttpClient (PUT for adds, PATCH for sets, DELETE for deletes) collectingApplied[]andFailed[]intoMikrotikFlushResult, clears pending on success. Abstractstring ResourcePath { get; }. CreateRepositories/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 inheritingMikrotikEntity, each with typed properties (viaMikrotikStringConverter) matching the MikroTik REST API JSON fields.Bridge.cs:string? VlanFiltering,string? Protocol.BridgePort.cs:string? Bridge,string? Interface,int? Pvid,string? TaggedVlans(mapped fromtagged).BridgeVlan.cs:string? Bridge,string? Tagged,string? Untagged,string? VlanIds(mapped fromvlan-ids).RouterInterface.cs:string? Type,string? DefaultName,bool? Disabled,string? Running,string? MacAddress.Bonding.cs:string? Slaves,string? Mode,string? TransmitHashPolicy(mapped fromtransmit-hash-policy),string? LacpRate(mapped fromlacp-rate).IpAddress.cs:string? Address,string? Network,string? Interface,bool? Disabled,string? Comment.IpRoute.cs:string? DstAddress(mapped fromdst-address),string? Gateway,string? Distance,string? Comment.DhcpServer.cs:string? Interface, bool? Disabled`,string? AddressPool.DhcpLease.cs:string? Address,string? MacAddress(mapped frommac-address),string? Comment,bool? Disabled.DnsSettings.cs:string? Servers,bool? AllowRemoteRequests(mapped fromallow-remote-requests).FirewallNatRule.cs:string? Chain,string? Action,string? OutInterface(mapped fromout-interface),bool? Disabled,string? Comment.FirewallFilterRule.cs:string? Chain,string? Action,string? Protocol,string? DstPort(mapped fromdst-port),string? InInterface(mapped fromin-interface),string? Comment,bool? Disabled.WifiConfiguration.cs:string? Ssid,string? Country,string? Channel,string? Security.WifiSecurity.cs:string? AuthenticationTypes(mapped fromauthentication-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. InRepositories/create concrete repos:BridgeRepository(pathinterface/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 throwNotSupportedException). 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-portmapped to DstPort property -
✓ Create UoW + DataStore + DataStoreReference + Provider + DI registration: In
UnitOfWork/createIMikrotikUnitOfWork.cs(internal): exposes all 17 repos as read-only properties (IMikrotikRepository<Bridge> Bridges,IMikrotikRepository<BridgePort> BridgePorts, etc.) +Task<MikrotikCommitResult> CommitAsync(CancellationToken ct). CreateMikrotikCommitResult.cs(internal):IReadOnlyList<MikrotikFlushResult> Results,bool HasFailures. CreateMikrotikUnitOfWork.cs(internal): constructor takesMikrotikHttpClient, instantiates all 17 concrete repos,CommitAsynciterates repos whereIsDirty == true, callsFlushAsyncon each, aggregates intoMikrotikCommitResult. InDataStore/createIMikrotikDataStore.cs(internal):IMikrotikUnitOfWork UnitOfWork { get; },IDisposable. CreateMikrotikDataStore.cs: createsMikrotikHttpClientfromMikrotikConnection, passes toMikrotikUnitOfWork. CreateIMikrotikDataStoreReference.cs(internal):Task<bool> ExistsAsync(CancellationToken ct),IMikrotikDataStore OpenDataStore(). CreateMikrotikDataStoreReference.cs:ExistsAsyncprobesGET /rest/system/resource- returns true on 200, false on any exception (connection refused, timeout, auth failure). CreateIMikrotikDataStoreReferenceProvider.cs(internal):IMikrotikDataStoreReference GetReference(MikrotikConnection connection). CreateMikrotikDataStoreReferenceProvider.cs. In root, updateMikrotikServiceCollectionExtensions.AddMikrotik: registerIMikrotikDataStoreReferenceProvider→MikrotikDataStoreReferenceProvider(singleton),IMikrotikConverge→MikrotikConverge(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+MikrotikConvergeorchestrator: CreateMapping/MikrotikEntityMapper.cs(internal static) with bidirectional mapping methods between Contracts *Spec records and internal entity types. OneToEntity/ToSpecmethod pair per resource family (e.g.,Bridge ToEntity(BridgeSpec spec),BridgeSpec ToSpec(Bridge entity)). Handle nullable fields. CreateMikrotikConverge.cs(internal, implementsIMikrotikConverge):RunAsyncflow: (1)provider.GetReference(connection), (2)ExistsAsync- if false, returnMikrotikConvergeResult(Reachable: false, Deltas: []), (3)OpenDataStore()→ UoW, (4) per resource family:GetAllAsynccurrent 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), queueAddfor missing,Setfor changed (compare all properties),Deletefor 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) buildMikrotikConvergeResultwith per-resourceMikrotikResourceDelta, (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
MikrotikApiCredentialsenv file parser: Createsrc/NETSetup/Helpers/MikrotikApiCredentials.cswith staticParse(string content)returningMikrotikConnection. Reads KEY=VALUE format (one per line,#comments, empty lines skipped):MIKROTIK_URL→BaseUrl,MIKROTIK_USER→Username,MIKROTIK_PASSWORD→Password. Missing required field (URL, USER, PASSWORD) throwsNETSetupExceptionwith field name in message. AddTryReadFromDisk(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 insrc/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
MikrotikDesiredStateBuilderfor Liberator VLAN cutover: Createsrc/NETSetup/Helpers/MikrotikDesiredStateBuilder.cswithBuild(NETSetupConfig config, string hostDescription)returningMikrotikDesiredState. ForhostDescription == "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 withconfig.MikrotikAdminPassword(from flash-mikrotik flashed password). For non-LiberatorhostDescription: returnMikrotikDesiredStatewith all empty arrays and null singletons (no-op converge). WiFi passwords sourced fromNETSetupConfigfields (existing WiFi password config or flash-mikrotik defaults). Test class insrc/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 intoRunProxmoxUpdate: Createsrc/NETSetup/Helpers/MikrotikConvergeBridge.cswithRun(ISystemHost host, NETSetupConfig config, IServiceProvider services, ILogger logger). Best-effort pattern (same asOpnsenseConvergeBridge): (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) resolveIMikrotikConvergefromservices(build one-shotServiceCollectionwithAddMikrotik()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 intosrc/NETSetup/Helpers/UpdateMethods.csRunProxmoxUpdateas step 4 from Liberator design (afterEnsureCustomerVms/OpnsenseConvergeBridge, beforeConfigureProxmoxBridges), gated onrunFullNetworkFlow(host.Description == "Liberator" && hasOpnsenseVm). Test class insrc/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
-