diff --git a/CHANGELOG.md b/CHANGELOG.md
index c07cb8d..9c1c0dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi
## [Unreleased]
+### Added
+
+- Firewall management cmdlets (21): rules, security groups, aliases, IP sets, options at cluster/node/VM/container levels
+- Backup/vzdump cmdlets (5): ad-hoc backup creation and scheduled backup job CRUD
+- SDN IPAM cmdlets (3): Get/New/Remove-PveSdnIpam for IPAM plugin management
+- SDN DNS cmdlets (3): Get/New/Remove-PveSdnDns for DNS plugin management
+- SDN Controller cmdlets (3): Get/New/Remove-PveSdnController for controller management
+- PSGallery version badge in README
+- Integration tests for firewall rules, aliases, IP sets, backup jobs, and OVA import
+
+### Fixed
+
+- `Remove-PveRole` now has `ConfirmImpact.High` for safety
+- URL-encode snapshot names in API paths for defense-in-depth
+- Extract auth header magic strings to named constants in PveHttpClient
+
## [0.1.0-preview] - 2026-03-19
### Added
diff --git a/README.md b/README.md
index 06618ed..b573b1e 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
[](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/build.yml)
[](https://github.com/goodolclint/PSProxmoxVE/actions/workflows/unit-tests.yml)
[](https://github.com/goodolclint/PSProxmoxVE/blob/main/LICENSE)
+[](https://www.powershellgallery.com/packages/PSProxmoxVE)
A production-grade C# binary PowerShell module for managing Proxmox VE environments.
@@ -325,6 +326,53 @@ SDN management requires Proxmox VE 8.0 or later. Connected server is version 7.4
| `Get-PveTask` | Get task status |
| `Wait-PveTask` | Wait for a task to complete |
+### Firewall
+| Cmdlet | Description |
+|---|---|
+| `Get-PveFirewallRule` | List firewall rules (cluster/node/VM/container) |
+| `New-PveFirewallRule` | Create a firewall rule |
+| `Set-PveFirewallRule` | Update a firewall rule |
+| `Remove-PveFirewallRule` | Delete a firewall rule |
+| `Get-PveFirewallGroup` | List security groups or group rules |
+| `New-PveFirewallGroup` | Create a security group |
+| `Remove-PveFirewallGroup` | Delete a security group |
+| `Get-PveFirewallAlias` | List firewall IP aliases |
+| `New-PveFirewallAlias` | Create a firewall IP alias |
+| `Set-PveFirewallAlias` | Update a firewall IP alias |
+| `Remove-PveFirewallAlias` | Delete a firewall IP alias |
+| `Get-PveFirewallIpSet` | List firewall IP sets |
+| `New-PveFirewallIpSet` | Create a firewall IP set |
+| `Remove-PveFirewallIpSet` | Delete a firewall IP set |
+| `Get-PveFirewallIpSetEntry` | List entries in an IP set |
+| `New-PveFirewallIpSetEntry` | Add an entry to an IP set |
+| `Set-PveFirewallIpSetEntry` | Update an IP set entry |
+| `Remove-PveFirewallIpSetEntry` | Remove an entry from an IP set |
+| `Get-PveFirewallOptions` | Get firewall options |
+| `Set-PveFirewallOptions` | Set firewall options |
+| `Get-PveFirewallRef` | List firewall references (aliases, IP sets) |
+
+### Backup
+| Cmdlet | Description |
+|---|---|
+| `New-PveBackup` | Create an ad-hoc backup (vzdump) |
+| `Get-PveBackupJob` | List scheduled backup jobs |
+| `New-PveBackupJob` | Create a scheduled backup job |
+| `Set-PveBackupJob` | Update a scheduled backup job |
+| `Remove-PveBackupJob` | Delete a scheduled backup job |
+
+### SDN — IPAM / DNS / Controllers (PVE 8.0+)
+| Cmdlet | Description |
+|---|---|
+| `Get-PveSdnIpam` | List SDN IPAM plugins |
+| `New-PveSdnIpam` | Create an SDN IPAM plugin |
+| `Remove-PveSdnIpam` | Remove an SDN IPAM plugin |
+| `Get-PveSdnDns` | List SDN DNS plugins |
+| `New-PveSdnDns` | Create an SDN DNS plugin |
+| `Remove-PveSdnDns` | Remove an SDN DNS plugin |
+| `Get-PveSdnController` | List SDN controllers |
+| `New-PveSdnController` | Create an SDN controller |
+| `Remove-PveSdnController` | Remove an SDN controller |
+
## Known Limitations (v1)
- **No automatic retries**: Failed API calls are not retried. Implement your own retry logic if needed.
diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md
index 7c6c5a0..f714461 100644
--- a/REVIEW_REPORT.md
+++ b/REVIEW_REPORT.md
@@ -1,6 +1,10 @@
# PSProxmoxVE Module Review Report
-**Date:** 2026-03-19
+```
+Scan date: 2026-03-21
+Prior report date: 2026-03-19
+```
+
**Reviewer:** Claude Code (Opus 4.6)
**Module Version:** 0.1.0-preview
**Repository:** PSProxmoxVE (C# binary PowerShell module)
@@ -9,83 +13,101 @@
## Executive Summary
-1. **Well-structured project** with clean separation: `PSProxmoxVE.Core` (services, models, client) and `PSProxmoxVE` (cmdlets, module surface).
-2. **66 cmdlets** covering VMs, containers, storage, networking, SDN, users/roles/permissions, templates, cloud-init, snapshots, and tasks.
-3. **Excellent ShouldProcess coverage** -- all destructive cmdlets implement `SupportsShouldProcess` with appropriate `ConfirmImpact` levels.
-4. **OutputType attributes** present on every cmdlet. ValueFromPipelineByPropertyName used consistently for pipeline chaining.
-5. **Sync-over-async pattern** (`GetAwaiter().GetResult()`) used pervasively -- acceptable for PowerShell binary modules but a known tradeoff.
-6. **Comprehensive integration tests** running against real PVE 8 and PVE 9 via Terraform-provisioned nested instances.
-7. **Missing community files**: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `.gitattributes`, issue/PR templates.
-8. **No publish workflow**: No CI pipeline for publishing to PSGallery.
-9. **One unapproved verb**: `Reset-PveVm` uses a hardcoded string `"Reset"` instead of `VerbsCommon` -- `Reset` is not an approved PowerShell verb.
-10. **PVE API coverage**: Module covers core automation scenarios well but lacks firewall, backup/restore (PBS), Ceph, HA, and replication management.
+**Delta since last scan: 22 fixed | 2 new findings | 0 regressed | 4 still open**
+
+1. **Significant progress since prior scan** — 22 of 28 findings from the initial report have been resolved, including all Critical items.
+2. **83 cmdlets** (up from 66) covering VMs, containers (with snapshots and migration), storage, networking, SDN (zones, VNets, subnets), users/roles/permissions, templates, cloud-init, OVA/disk import, and tasks.
+3. **All prior Critical items resolved**: publish workflow added, README updated with `Install-Module`, ReleaseNotes in manifest.
+4. **Community files complete**: CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, .gitattributes, issue/PR templates all added.
+5. **Code quality improvements**: HelpMessage on all parameters, WriteVerbose throughout, ValidateRange on VmId, ConfirmImpact.High on Stop/Reset-PveVm, Reset verb constant fixed.
+6. **MAML help file** present (PSProxmoxVE.dll-Help.xml) with 75 cmdlet documentation files in docs/cmdlets/.
+7. **Remaining gaps**: No IconUri in manifest, Remove-PveRole missing ConfirmImpact.High, firewall/backup/pool cmdlets not yet implemented.
+8. **Excellent test coverage**: 100% Pester unit tests, 84% integration test coverage, xUnit model tests with PVE 8 & 9 fixtures.
+9. **Security posture is clean**: No hardcoded secrets, HTTPS enforced, SkipCertificateCheck emits warning, all dependencies pinned.
+10. **Module is near PSGallery-ready** — no blocking issues remain.
---
-## Phase 1 -- Repository Inventory & Structure
+## Phase 1 — Repository Inventory & Structure
-### Directory Tree (depth <= 4)
+### Directory Tree (depth ≤ 4)
```
PSProxmoxVE/
-+-- PSProxmoxVE.sln
-+-- README.md
-+-- LICENSE (MIT)
-+-- CHANGELOG.md
-+-- .editorconfig
-+-- .gitignore
-+-- CLAUDE.md
-+-- .github/
-| +-- workflows/
-| +-- build.yml
-| +-- unit-tests.yml
-| +-- integration-tests.yml
-+-- src/
-| +-- PSProxmoxVE/
-| | +-- PSProxmoxVE.csproj
-| | +-- PSProxmoxVE.psd1
-| | +-- PSProxmoxVE.format.ps1xml
-| | +-- ModuleState.cs
-| | +-- Cmdlets/
-| | +-- PveCmdletBase.cs
-| | +-- CloudInit/ (3 cmdlets)
-| | +-- Connection/ (3 cmdlets)
-| | +-- Containers/ (8 cmdlets)
-| | +-- Network/ (10 cmdlets)
-| | +-- Nodes/ (2 cmdlets)
-| | +-- Snapshots/ (4 cmdlets)
-| | +-- Storage/ (5 cmdlets)
-| | +-- Tasks/ (2 cmdlets)
-| | +-- Templates/ (4 cmdlets)
-| | +-- Users/ (11 cmdlets)
-| | +-- Vms/ (17 cmdlets)
-| +-- PSProxmoxVE.Core/
-| +-- PSProxmoxVE.Core.csproj
-| +-- Authentication/ (PveAuthenticator, PveAuthMode, PveSession, PveVersion)
-| +-- Client/ (PveHttpClient)
-| +-- Exceptions/ (7 exception types)
-| +-- Models/ (Cluster, Containers, Network, Nodes, Storage, Users, Vms)
-| +-- Services/ (11 service classes)
-+-- tests/
-| +-- PSProxmoxVE.Core.Tests/ (xUnit)
-| | +-- Authentication/ (3 test files)
-| | +-- Models/ (10 test files)
-| | +-- TestHelper.cs
-| +-- PSProxmoxVE.Tests/ (Pester 5)
-| +-- _TestHelper.ps1
-| +-- CloudInit/ (1 test file)
-| +-- Connection/ (3 test files)
-| +-- Containers/ (3 test files)
-| +-- Network/ (2 test files)
-| +-- Nodes/ (1 test file)
-| +-- Snapshots/ (1 test file)
-| +-- Storage/ (3 test files)
-| +-- Tasks/ (1 test file)
-| +-- Templates/ (1 test file)
-| +-- Users/ (3 test files)
-| +-- Vms/ (6 test files)
-| +-- Integration/ (1 comprehensive test file)
-+-- tests/infrastructure/ (Terraform, Dockerfile, scripts for CI)
+├── PSProxmoxVE.sln
+├── README.md
+├── LICENSE (MIT)
+├── CHANGELOG.md
+├── CONTRIBUTING.md
+├── CODE_OF_CONDUCT.md
+├── SECURITY.md
+├── .editorconfig
+├── .gitignore
+├── .gitattributes
+├── CLAUDE.md
+├── REVIEW_REPORT.md
+├── .github/
+│ ├── ISSUE_TEMPLATE/
+│ │ ├── bug_report.yml
+│ │ └── feature_request.yml
+│ ├── pull_request_template.md
+│ └── workflows/
+│ ├── build.yml
+│ ├── unit-tests.yml
+│ ├── integration-tests.yml
+│ └── publish.yml
+├── docs/
+│ ├── PVE_API_COVERAGE.md
+│ └── cmdlets/ (75 cmdlet documentation files)
+├── src/
+│ ├── PSProxmoxVE/
+│ │ ├── PSProxmoxVE.csproj
+│ │ ├── PSProxmoxVE.psd1
+│ │ ├── PSProxmoxVE.format.ps1xml
+│ │ ├── PSProxmoxVE.dll-Help.xml
+│ │ ├── ModuleState.cs
+│ │ └── Cmdlets/
+│ │ ├── PveCmdletBase.cs
+│ │ ├── CloudInit/ (3 cmdlets)
+│ │ ├── Connection/ (3 cmdlets)
+│ │ ├── Containers/ (14 cmdlets)
+│ │ ├── Network/ (14 cmdlets)
+│ │ ├── Nodes/ (2 cmdlets)
+│ │ ├── Snapshots/ (4 cmdlets)
+│ │ ├── Storage/ (6 cmdlets)
+│ │ ├── Tasks/ (2 cmdlets)
+│ │ ├── Templates/ (4 cmdlets)
+│ │ ├── Users/ (12 cmdlets)
+│ │ └── Vms/ (19 cmdlets)
+│ └── PSProxmoxVE.Core/
+│ ├── PSProxmoxVE.Core.csproj
+│ ├── Authentication/ (PveAuthenticator, PveAuthMode, PveSession, PveVersion)
+│ ├── Client/ (PveHttpClient)
+│ ├── Exceptions/ (7 exception types)
+│ ├── Models/ (25 model classes)
+│ └── Services/ (11 service classes)
+├── tests/
+│ ├── PSProxmoxVE.Core.Tests/ (xUnit)
+│ │ ├── Authentication/ (3 test files)
+│ │ ├── Fixtures/ (23 JSON fixtures)
+│ │ ├── Models/ (10 test files)
+│ │ └── TestHelper.cs
+│ ├── PSProxmoxVE.Tests/ (Pester 5)
+│ │ ├── _TestHelper.ps1
+│ │ ├── CloudInit/
+│ │ ├── Connection/ (3 test files)
+│ │ ├── Containers/ (4 test files)
+│ │ ├── Network/ (3 test files)
+│ │ ├── Nodes/ (1 test file)
+│ │ ├── Snapshots/ (1 test file)
+│ │ ├── Storage/ (3 test files)
+│ │ ├── Tasks/ (1 test file)
+│ │ ├── Templates/ (1 test file)
+│ │ ├── Users/ (3 test files)
+│ │ ├── Vms/ (9 test files)
+│ │ └── Integration/ (1 comprehensive test file)
+│ └── infrastructure/ (Terraform, Dockerfile, scripts for CI)
+└── tools/
```
### Project Layout
@@ -109,631 +131,649 @@ PSProxmoxVE/
| `build.yml` | Build + xUnit tests on net48 (Windows) and net9.0 (Windows + Ubuntu) |
| `unit-tests.yml` | Build + Pester tests across PS 5.1/7.5 on Windows/Ubuntu/macOS |
| `integration-tests.yml` | Full integration tests against nested PVE 8 + PVE 9 instances |
+| `publish.yml` | Tag-triggered publish to PSGallery + GitHub Release creation |
-### What's Present
+### Inventory Checklist
-- [x] Solution file (`.sln`)
-- [x] Module manifest (`.psd1`) with explicit `CmdletsToExport`
-- [x] Format definitions (`.format.ps1xml`)
-- [x] `.editorconfig`
-- [x] `.gitignore`
-- [x] `LICENSE` (MIT)
-- [x] `CHANGELOG.md` (Keep a Changelog format)
-- [x] `README.md` (comprehensive)
-- [x] CI/CD workflows (build, unit tests, integration tests)
+| Item | Present | Prior Status |
+|---|---|---|
+| Solution file (`.sln`) | Yes | Open |
+| Module manifest (`.psd1`) | Yes | Open |
+| Format definitions (`.format.ps1xml`) | Yes | Open |
+| MAML help (`.dll-Help.xml`) | Yes | New |
+| `.editorconfig` | Yes | Open |
+| `.gitignore` | Yes | Open |
+| `.gitattributes` | Yes | Fixed |
+| `LICENSE` (MIT) | Yes | Open |
+| `CHANGELOG.md` | Yes | Open |
+| `README.md` | Yes | Open |
+| `CONTRIBUTING.md` | Yes | Fixed |
+| `CODE_OF_CONDUCT.md` | Yes | Fixed |
+| `SECURITY.md` | Yes | Fixed |
+| `.github/ISSUE_TEMPLATE/` | Yes | Fixed |
+| `.github/pull_request_template.md` | Yes | Fixed |
+| PSGallery publish workflow | Yes | Fixed |
+| `docs/PVE_API_COVERAGE.md` | Yes | New |
+| `docs/cmdlets/` (75 files) | Yes | New |
### What's Missing
-- [ ] `CONTRIBUTING.md`
-- [ ] `CODE_OF_CONDUCT.md`
-- [ ] `SECURITY.md`
-- [ ] `.gitattributes`
-- [ ] `.github/ISSUE_TEMPLATE/`
-- [ ] `.github/pull_request_template.md`
-- [ ] PSGallery publish workflow
-- [ ] `ReleaseNotes` in manifest `PSData`
-- [ ] `IconUri` in manifest `PSData`
+- [ ] `IconUri` in manifest PSData (cosmetic)
---
-## Phase 2 -- PVE API Coverage Audit
+## Phase 2 — PVE API Coverage Audit
-> **Note:** The PVE API documentation at `pve.proxmox.com/pve-docs/api-viewer/apidoc.js` was too large for automated extraction. Coverage assessment below is based on the author's knowledge of the PVE API and source code analysis.
+> **Note:** Coverage assessment based on source code analysis and the project's own `docs/PVE_API_COVERAGE.md`.
-### Implemented Cmdlets by Category
+### Implemented Cmdlets by Category (83 total)
#### Connection (3 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Connect-PveServer` | `/access/ticket` | POST |
-| `Disconnect-PveServer` | `/access/ticket` | DELETE |
-| `Test-PveConnection` | `/version` | GET |
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Connect-PveServer` | `/access/ticket` | POST | Open |
+| `Disconnect-PveServer` | `/access/ticket` | DELETE | Open |
+| `Test-PveConnection` | `/version` | GET | Open |
#### Nodes (2 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveNode` | `/nodes` | GET |
-| `Get-PveNodeStatus` | `/nodes/{node}/status` | GET |
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveNode` | `/nodes` | GET | Open |
+| `Get-PveNodeStatus` | `/nodes/{node}/status` | GET | Open |
-#### VMs / QEMU (17 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveVm` | `/nodes/{node}/qemu` | GET |
-| `New-PveVm` | `/nodes/{node}/qemu` | POST |
-| `Remove-PveVm` | `/nodes/{node}/qemu/{vmid}` | DELETE |
-| `Start-PveVm` | `/nodes/{node}/qemu/{vmid}/status/start` | POST |
-| `Stop-PveVm` | `/nodes/{node}/qemu/{vmid}/status/stop` | POST |
-| `Restart-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reboot` | POST |
-| `Suspend-PveVm` | `/nodes/{node}/qemu/{vmid}/status/suspend` | POST |
-| `Resume-PveVm` | `/nodes/{node}/qemu/{vmid}/status/resume` | POST |
-| `Reset-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reset` | POST |
-| `Copy-PveVm` | `/nodes/{node}/qemu/{vmid}/clone` | POST |
-| `Move-PveVm` | `/nodes/{node}/qemu/{vmid}/migrate` | POST |
-| `Get-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET |
-| `Set-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT |
-| `Resize-PveVmDisk` | `/nodes/{node}/qemu/{vmid}/resize` | PUT |
-| `Test-PveVmGuestAgent` | `/nodes/{node}/qemu/{vmid}/agent/ping` | POST |
-| `Get-PveVmGuestNetwork` | `/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces` | GET |
-| `Invoke-PveVmGuestExec` | `/nodes/{node}/qemu/{vmid}/agent/exec` | POST |
+#### VMs / QEMU (19 cmdlets)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveVm` | `/nodes/{node}/qemu` | GET | Open |
+| `New-PveVm` | `/nodes/{node}/qemu` | POST | Open |
+| `Remove-PveVm` | `/nodes/{node}/qemu/{vmid}` | DELETE | Open |
+| `Start-PveVm` | `/nodes/{node}/qemu/{vmid}/status/start` | POST | Open |
+| `Stop-PveVm` | `/nodes/{node}/qemu/{vmid}/status/stop` | POST | Open |
+| `Restart-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reboot` | POST | Open |
+| `Suspend-PveVm` | `/nodes/{node}/qemu/{vmid}/status/suspend` | POST | Open |
+| `Resume-PveVm` | `/nodes/{node}/qemu/{vmid}/status/resume` | POST | Open |
+| `Reset-PveVm` | `/nodes/{node}/qemu/{vmid}/status/reset` | POST | Open |
+| `Copy-PveVm` | `/nodes/{node}/qemu/{vmid}/clone` | POST | Open |
+| `Move-PveVm` | `/nodes/{node}/qemu/{vmid}/migrate` | POST | Open |
+| `Get-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET | Open |
+| `Set-PveVmConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT | Open |
+| `Resize-PveVmDisk` | `/nodes/{node}/qemu/{vmid}/resize` | PUT | Open |
+| `Import-PveVmDisk` | `/nodes/{node}/storage/{storage}/upload` | POST | New |
+| `Import-PveOva` | Multiple (upload + config + import) | POST | New |
+| `Test-PveVmGuestAgent` | `/nodes/{node}/qemu/{vmid}/agent/ping` | POST | Open |
+| `Get-PveVmGuestNetwork` | `/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces` | GET | Open |
+| `Invoke-PveVmGuestExec` | `/nodes/{node}/qemu/{vmid}/agent/exec` | POST | Open |
-#### Containers / LXC (8 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveContainer` | `/nodes/{node}/lxc` | GET |
-| `New-PveContainer` | `/nodes/{node}/lxc` | POST |
-| `Remove-PveContainer` | `/nodes/{node}/lxc/{vmid}` | DELETE |
-| `Start-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/start` | POST |
-| `Stop-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/stop` | POST |
-| `Restart-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/shutdown` | POST |
-| `Copy-PveContainer` | `/nodes/{node}/lxc/{vmid}/clone` | POST |
-| `Get-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | GET |
-| `Set-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | PUT |
+#### Containers / LXC (10 cmdlets)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveContainer` | `/nodes/{node}/lxc` | GET | Open |
+| `New-PveContainer` | `/nodes/{node}/lxc` | POST | Open |
+| `Remove-PveContainer` | `/nodes/{node}/lxc/{vmid}` | DELETE | Open |
+| `Start-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/start` | POST | Open |
+| `Stop-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/stop` | POST | Open |
+| `Restart-PveContainer` | `/nodes/{node}/lxc/{vmid}/status/shutdown` | POST | Open |
+| `Copy-PveContainer` | `/nodes/{node}/lxc/{vmid}/clone` | POST | Open |
+| `Move-PveContainer` | `/nodes/{node}/lxc/{vmid}/migrate` | POST | New |
+| `Get-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | GET | Open |
+| `Set-PveContainerConfig` | `/nodes/{node}/lxc/{vmid}/config` | PUT | Open |
-#### Storage (5 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveStorage` | `/storage` or `/nodes/{node}/storage` | GET |
-| `Get-PveStorageContent` | `/nodes/{node}/storage/{storage}/content` | GET |
-| `Send-PveIso` | `/nodes/{node}/storage/{storage}/upload` | POST |
-| `Invoke-PveStorageDownload` | `/nodes/{node}/storage/{storage}/download-url` | POST |
-| `New-PveStorage` | `/storage` | POST |
-| `Remove-PveStorage` | `/storage/{storage}` | DELETE |
+#### Container Snapshots (4 cmdlets — NEW)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot` | GET | New |
+| `New-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot` | POST | New |
+| `Remove-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot/{name}` | DELETE | New |
+| `Restore-PveContainerSnapshot` | `/nodes/{node}/lxc/{vmid}/snapshot/{name}/rollback` | POST | New |
-#### Snapshots (4 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | GET |
-| `New-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | POST |
-| `Remove-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}` | DELETE |
-| `Restore-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}/rollback` | POST |
+#### Storage (6 cmdlets)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveStorage` | `/storage` or `/nodes/{node}/storage` | GET | Open |
+| `Get-PveStorageContent` | `/nodes/{node}/storage/{storage}/content` | GET | Open |
+| `Send-PveFile` | `/nodes/{node}/storage/{storage}/upload` | POST | Open |
+| `Invoke-PveStorageDownload` | `/nodes/{node}/storage/{storage}/download-url` | POST | Open |
+| `New-PveStorage` | `/storage` | POST | Open |
+| `Remove-PveStorage` | `/storage/{storage}` | DELETE | Open |
-#### Networking (4 + 6 SDN cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveNetwork` | `/nodes/{node}/network` | GET |
-| `New-PveNetwork` | `/nodes/{node}/network` | POST |
-| `Set-PveNetwork` | `/nodes/{node}/network/{iface}` | PUT |
-| `Remove-PveNetwork` | `/nodes/{node}/network/{iface}` | DELETE |
-| `Invoke-PveNetworkApply` | `/nodes/{node}/network` | PUT |
-| `Get-PveSdnZone` | `/cluster/sdn/zones` | GET |
-| `New-PveSdnZone` | `/cluster/sdn/zones` | POST |
-| `Remove-PveSdnZone` | `/cluster/sdn/zones/{zone}` | DELETE |
-| `Get-PveSdnVnet` | `/cluster/sdn/vnets` | GET |
-| `New-PveSdnVnet` | `/cluster/sdn/vnets` | POST |
-| `Remove-PveSdnVnet` | `/cluster/sdn/vnets/{vnet}` | DELETE |
+#### VM Snapshots (4 cmdlets)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | GET | Open |
+| `New-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot` | POST | Open |
+| `Remove-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}` | DELETE | Open |
+| `Restore-PveSnapshot` | `/nodes/{node}/qemu/{vmid}/snapshot/{name}/rollback` | POST | Open |
-#### Users / ACLs / Tokens (11 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveUser` | `/access/users` | GET |
-| `New-PveUser` | `/access/users` | POST |
-| `Remove-PveUser` | `/access/users/{userid}` | DELETE |
-| `Set-PveUser` | `/access/users/{userid}` | PUT |
-| `Get-PveRole` | `/access/roles` | GET |
-| `New-PveRole` | `/access/roles` | POST |
-| `Remove-PveRole` | `/access/roles/{roleid}` | DELETE |
-| `Get-PvePermission` | `/access/acl` | GET |
-| `Set-PvePermission` | `/access/acl` | PUT |
-| `Get-PveApiToken` | `/access/users/{userid}/token` | GET |
-| `New-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | POST |
-| `Remove-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | DELETE |
+#### Networking (5 cmdlets)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveNetwork` | `/nodes/{node}/network` | GET | Open |
+| `New-PveNetwork` | `/nodes/{node}/network` | POST | Open |
+| `Set-PveNetwork` | `/nodes/{node}/network/{iface}` | PUT | Open |
+| `Remove-PveNetwork` | `/nodes/{node}/network/{iface}` | DELETE | Open |
+| `Invoke-PveNetworkApply` | `/nodes/{node}/network` | PUT | Open |
+
+#### SDN (9 cmdlets)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveSdnZone` | `/cluster/sdn/zones` | GET | Open |
+| `New-PveSdnZone` | `/cluster/sdn/zones` | POST | Open |
+| `Remove-PveSdnZone` | `/cluster/sdn/zones/{zone}` | DELETE | Open |
+| `Get-PveSdnVnet` | `/cluster/sdn/vnets` | GET | Open |
+| `New-PveSdnVnet` | `/cluster/sdn/vnets` | POST | Open |
+| `Remove-PveSdnVnet` | `/cluster/sdn/vnets/{vnet}` | DELETE | Open |
+| `Get-PveSdnSubnet` | `/cluster/sdn/vnets/{vnet}/subnets` | GET | New |
+| `New-PveSdnSubnet` | `/cluster/sdn/vnets/{vnet}/subnets` | POST | New |
+| `Remove-PveSdnSubnet` | `/cluster/sdn/vnets/{vnet}/subnets/{subnet}` | DELETE | New |
+
+#### Users / ACLs / Tokens (12 cmdlets)
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveUser` | `/access/users` | GET | Open |
+| `New-PveUser` | `/access/users` | POST | Open |
+| `Remove-PveUser` | `/access/users/{userid}` | DELETE | Open |
+| `Set-PveUser` | `/access/users/{userid}` | PUT | Open |
+| `Get-PveRole` | `/access/roles` | GET | Open |
+| `New-PveRole` | `/access/roles` | POST | Open |
+| `Remove-PveRole` | `/access/roles/{roleid}` | DELETE | Open |
+| `Get-PvePermission` | `/access/acl` | GET | Open |
+| `Set-PvePermission` | `/access/acl` | PUT | Open |
+| `Get-PveApiToken` | `/access/users/{userid}/token` | GET | Open |
+| `New-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | POST | Open |
+| `Remove-PveApiToken` | `/access/users/{userid}/token/{tokenid}` | DELETE | Open |
#### Templates (4 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveTemplate` | `/nodes/{node}/qemu` (filtered) | GET |
-| `New-PveTemplate` | `/nodes/{node}/qemu/{vmid}/template` | POST |
-| `Remove-PveTemplate` | `/nodes/{node}/qemu/{vmid}` | DELETE |
-| `New-PveVmFromTemplate` | `/nodes/{node}/qemu/{vmid}/clone` | POST |
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveTemplate` | `/nodes/{node}/qemu` (filtered) | GET | Open |
+| `New-PveTemplate` | `/nodes/{node}/qemu/{vmid}/template` | POST | Open |
+| `Remove-PveTemplate` | `/nodes/{node}/qemu/{vmid}` | DELETE | Open |
+| `New-PveVmFromTemplate` | `/nodes/{node}/qemu/{vmid}/clone` | POST | Open |
#### Cloud-Init (3 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET |
-| `Set-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT |
-| `Invoke-PveCloudInitRegenerate` | `/nodes/{node}/qemu/{vmid}/cloudinit` | PUT |
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | GET | Open |
+| `Set-PveCloudInitConfig` | `/nodes/{node}/qemu/{vmid}/config` | PUT | Open |
+| `Invoke-PveCloudInitRegenerate` | `/nodes/{node}/qemu/{vmid}/cloudinit` | PUT | Open |
#### Tasks (2 cmdlets)
-| Cmdlet | API Endpoint | Method |
-|---|---|---|
-| `Get-PveTask` | `/nodes/{node}/tasks/{upid}/status` | GET |
-| `Wait-PveTask` | `/nodes/{node}/tasks/{upid}/status` (polling) | GET |
+| Cmdlet | API Endpoint | Method | Prior Status |
+|---|---|---|---|
+| `Get-PveTask` | `/nodes/{node}/tasks/{upid}/status` | GET | Open |
+| `Wait-PveTask` | `/nodes/{node}/tasks/{upid}/status` (polling) | GET | Open |
### Missing API Coverage by Functional Area
-| Area | Key Missing Endpoints | Priority |
-|---|---|---|
-| **Firewall** | `/nodes/{node}/firewall/rules`, `/cluster/firewall/*` | High |
-| **Backup / Restore** | `/nodes/{node}/vzdump`, backup schedule management | High |
-| **Pools** | `/pools`, `/pools/{poolid}` (CRUD) | Medium |
-| **HA** | `/cluster/ha/*` (groups, resources, status) | Medium |
-| **Ceph** | `/nodes/{node}/ceph/*` (OSD, pool, mon, fs) | Medium |
-| **Replication** | `/cluster/replication` (CRUD) | Low |
-| **Access Groups** | `/access/groups` (CRUD) | Low |
-| **Access Domains** | `/access/domains` (CRUD) | Low |
-| **Container Snapshots** | `/nodes/{node}/lxc/{vmid}/snapshot` | Medium |
-| **Container Migrate** | `/nodes/{node}/lxc/{vmid}/migrate` | Medium |
-| **PBS Integration** | Proxmox Backup Server operations | Low |
-| **Cluster Config** | `/cluster/config/*` (join, nodes, totem) | Low |
-| **ACME** | `/cluster/acme/*`, `/nodes/{node}/certificates/*` | Low |
-| **Node Management** | `/nodes/{node}/apt/*`, `/nodes/{node}/disks/*`, `/nodes/{node}/services/*` | Low |
-| **Metrics** | `/cluster/metrics/*` | Low |
-| **SDN Subnets** | `/cluster/sdn/vnets/{vnet}/subnets` | Medium |
-| **VM Agent** | Additional agent endpoints (file-read, file-write, etc.) | Low |
+| Area | Key Missing Endpoints | Priority | Prior Status |
+|---|---|---|---|
+| **Firewall** | `/cluster/firewall/*`, `/nodes/{node}/firewall/*`, `/nodes/{node}/qemu/{vmid}/firewall/*` | High | Open |
+| **Backup / vzdump** | `/nodes/{node}/vzdump`, `/cluster/backup/*` | High | Open |
+| **Pools** | `/pools`, `/pools/{poolid}` (CRUD) | Medium | Open |
+| **HA** | `/cluster/ha/*` (groups, resources, status) | Medium | Open |
+| **Ceph** | `/nodes/{node}/ceph/*` (OSD, pool, mon, fs) | Medium | Open |
+| **Replication** | `/cluster/replication` (CRUD) | Low | Open |
+| **Access Groups** | `/access/groups` (CRUD) | Low | Open |
+| **Access Domains** | `/access/domains` (CRUD) | Low | Open |
+| **PBS Integration** | Proxmox Backup Server operations | Low | Open |
+| **Cluster Config** | `/cluster/config/*` (join, nodes, totem) | Low | Open |
+| **ACME** | `/cluster/acme/*`, `/nodes/{node}/certificates/*` | Low | Open |
+| **Node Management** | `/nodes/{node}/apt/*`, `/nodes/{node}/disks/*`, `/nodes/{node}/services/*` | Low | Open |
+| **Metrics** | `/cluster/metrics/*` | Low | Open |
+| **SDN IPAM/DNS/Controllers** | `/cluster/sdn/ipams/*`, `/cluster/sdn/dns/*`, `/cluster/sdn/controllers/*` | Low | New |
+| **VM Agent (extended)** | Additional agent endpoints (file-read, file-write, os-info) | Low | Open |
### High-Value Gaps
-1. **Firewall management** -- Critical for security automation. No cmdlets for creating/managing firewall rules at cluster, node, or VM level.
-2. **Backup/restore (vzdump)** -- Essential for DR automation. No cmdlets for creating backups, managing schedules, or restoring.
-3. **Pool management** -- Required for multi-tenant environments. No cmdlets for creating/managing resource pools.
-4. **Container snapshots** -- Snapshots are only implemented for VMs, not LXC containers.
-5. **Container migration** -- `Move-PveVm` exists for VMs but no equivalent for containers.
+1. **Firewall management** — Critical for security automation. No cmdlets for creating/managing firewall rules at cluster, node, or VM level.
+2. **Backup/restore (vzdump)** — Essential for DR automation. No cmdlets for creating backups, managing schedules, or restoring.
+3. **Pool management** — Required for multi-tenant environments. No cmdlets for creating/managing resource pools.
---
-## Phase 3 -- Code Quality & Best Practices
+## Phase 3 — Code Quality & Best Practices
### 3a. PowerShell Module Design
#### Cmdlet Naming
-- **All cmdlets use approved PowerShell verbs** from the `VerbsCommon`, `VerbsLifecycle`, `VerbsCommunications`, `VerbsDiagnostic`, and `VerbsData` classes.
-- **One exception**: `Reset-PveVm` uses a hardcoded string `"Reset"` instead of a verb class constant: `[Cmdlet("Reset", "PveVm", ...)]`. While `Reset` IS an approved verb (in `VerbsCommon`), the implementation should use `VerbsCommon.Reset` for consistency.
-- Noun prefix `Pve` is consistent across all cmdlets.
+- **All cmdlets use approved PowerShell verbs** via verb class constants (`VerbsCommon.Get`, `VerbsLifecycle.Start`, etc.).
+- Noun prefix `Pve` is consistent across all 83 cmdlets.
+- Previous finding about `Reset-PveVm` using a string literal has been fixed — now uses `VerbsCommon.Reset`.
#### Parameter Design
- `[Parameter(Mandatory = ...)]` used appropriately throughout.
-- `ValueFromPipelineByPropertyName = true` used on `Node`, `VmId`, and similar parameters enabling pipeline chaining (e.g., `Get-PveVm | Start-PveVm`).
-- `Position` attributes used on key parameters for positional binding.
-- `ValidateSet`, `ValidateRange`, `ValidateNotNullOrEmpty` used where appropriate (network types, port range, storage types, etc.).
-- **Gap**: VmId parameters are `int` type but lack `[ValidateRange(100, 999999999)]` to match PVE's VMID constraints.
+- `ValueFromPipelineByPropertyName = true` used on `Node`, `VmId`, and similar parameters.
+- `Position` attributes used on key parameters.
+- `ValidateSet`, `ValidateRange(100, 999999999)`, `ValidateNotNullOrEmpty`, `ValidatePattern` used where appropriate.
+- **HelpMessage** present on virtually all parameters (previously missing).
#### ShouldProcess / WhatIf / Confirm
-- **Excellent**: Every destructive/mutating cmdlet implements `SupportsShouldProcess = true`.
-- `ConfirmImpact = ConfirmImpact.High` is set on all `Remove-*` cmdlets, `New-PveTemplate` (irreversible), and `Restore-PveSnapshot`.
-- **Gap**: `Stop-PveVm`, `Stop-PveContainer`, `Reset-PveVm` do not set `ConfirmImpact.High` -- stopping/resetting VMs can cause data loss.
+- Every destructive/mutating cmdlet implements `SupportsShouldProcess = true`.
+- `ConfirmImpact = ConfirmImpact.High` on all `Remove-*` cmdlets, `Stop-PveVm`, `Reset-PveVm`, `New-PveTemplate`, `Restore-PveSnapshot`, `Restore-PveContainerSnapshot`.
+- **Gap**: `Remove-PveRole` does not set `ConfirmImpact.High` — deleting a role is a significant operation.
#### OutputType
-- `[OutputType]` attributes are present on **all 66 cmdlets**. This is exemplary.
+- `[OutputType]` attributes present on all 83 cmdlets.
#### Pipeline Support
-- All cmdlets use `ValueFromPipelineByPropertyName` on `Node` and `VmId` parameters.
-- `Get-PveVm` and `Get-PveContainer` output objects with `Node` and `VmId` properties, enabling chaining.
-- **Gap**: None of the cmdlets implement `ProcessRecord` for pipeline batch processing -- all use `EndProcessing` (or equivalent). This means `Get-PveVm | Start-PveVm` processes only the last piped object. **This is a functional bug if multiple objects are piped.**
+- All cmdlets use `ProcessRecord` for pipeline processing.
+- `Get-PveVm`, `Get-PveContainer` output objects with `Node` and `VmId` enabling chaining.
#### Error Handling
-- `ThrowTerminatingError` used appropriately in `ConnectPveServerCmdlet`, `WaitPveTaskCmdlet`, `GetPveNodeCmdlet`, `GetPveNodeStatusCmdlet`.
-- `WriteWarning` used in `DisconnectPveServerCmdlet` and `SetPveCloudInitConfigCmdlet` for non-fatal conditions.
-- `WriteVerbose` used in connection cmdlets for diagnostic output.
-- **Gap**: Most cmdlets let exceptions propagate without wrapping in `ErrorRecord` -- this means non-terminating errors are not always well-structured.
+- `ThrowTerminatingError` used appropriately for fatal conditions.
+- `WriteWarning` used for non-fatal conditions.
+- Custom exception hierarchy provides structured errors.
### 3b. C# Code Quality
#### Null-Reference Safety
- `enable` in both `.csproj` files.
-- Nullable annotations used on session, parameters, and return types.
-- Null guards (`?? throw`) used in constructors and authentication methods.
+- Consistent use of nullable annotations, null guards (`?? throw`), and null-coalescing patterns.
+- No null-forgiving operators (`!`) used.
#### Async/Await Pattern
-- All HTTP operations are async at the `PveHttpClient` level.
-- Sync wrappers use `.GetAwaiter().GetResult()` pattern.
-- **Assessment**: This is the standard approach for PowerShell binary modules on .NET. True async cmdlets are only available via `BeginProcessing`/`EndProcessing` async overrides in PS 7.4+, which would break PS 5.1 compatibility.
-- **Risk**: Low. PowerShell cmdlets run on a single pipeline thread, so deadlock risk from `GetAwaiter().GetResult()` is minimal given the `ConfigureAwait(false)` usage throughout.
+- All HTTP operations are async in `PveHttpClient` with `.ConfigureAwait(false)`.
+- Sync wrappers use `.GetAwaiter().GetResult()` — standard for PowerShell binary modules.
+- Low deadlock risk given the single pipeline thread model.
#### IDisposable Correctness
-- `PveHttpClient` implements `IDisposable` correctly with a `_disposed` flag.
-- All usages in cmdlets and services use `using var client = new PveHttpClient(session)` or `using (var client = ...)` blocks.
-- `ProgressStream` disposes its inner stream correctly.
-- File upload uses `try/finally` for stream cleanup with conditional `DisposeAsync` on .NET 9+.
-- **Assessment**: Excellent. No resource leak risks identified.
+- `PveHttpClient` implements `IDisposable` correctly with `_disposed` flag.
+- All service methods use `using var client = new PveHttpClient(session)`.
+- File upload uses framework-conditional async disposal.
#### Exception Handling
-- Custom exception hierarchy: `PveApiException`, `PveAuthenticationException`, `PveNotConnectedException`, `PveSessionExpiredException`, `PveTaskTimeoutException`, `PveTaskFailedException`, `PveVersionException`.
-- `PveHttpClient.SendAsync` catches `HttpRequestException` and wraps it in `PveApiException`.
-- Error message extraction from PVE API JSON responses is handled gracefully.
-- The only bare `catch {}` is in `ExtractErrorMessage` where parse failure falls through to raw body -- this is acceptable.
+- Well-defined exception hierarchy (7 custom types) with context properties.
+- `PveHttpClient.SendAsync` wraps `HttpRequestException` in `PveApiException`.
+- Silent catches are intentional and documented (node polling, status enrichment).
#### Magic Strings
-- API resource paths are constructed inline in service methods (e.g., `$"nodes/{node}/qemu/{vmid}/status/start"`). This is pragmatic for the size of the codebase.
-- Auth header names (`PVEAPIToken=`, `PVEAuthCookie=`, `CSRFPreventionToken`) are inline. Could be constants but not a significant issue.
-
-#### LINQ
-- No complex LINQ usage. JObject/JArray parsing uses Newtonsoft.Json's indexer pattern which is straightforward.
+- Auth header names inline (minor, not problematic).
+- API resource paths constructed inline (pragmatic for codebase size).
+- `RandomNumberGenerator` used for boundary generation (previously `new Random()`).
#### Logging / Verbose Output
-- `WriteVerbose` used in `ConnectPveServerCmdlet` and `DisconnectPveServerCmdlet`.
-- **Gap**: No `WriteVerbose` calls in any other cmdlets -- users cannot see which API calls are being made. This hinders debugging.
-- `WriteDebug` is not used anywhere in the module.
-- **Gap**: `WriteProgress` is only used in `Send-PveIso` (via progress callback). Not used in `Wait-PveTask` or other long-running operations.
+- `WriteVerbose` used extensively throughout cmdlets (previously missing).
+- `WriteProgress` used in `Send-PveFile`, `Import-PveOva`, and `Wait-PveTask`.
### 3c. General Hygiene
-#### Unused Code / Dead Code
-- No unused `using` directives detected (CS1591 suppressed for missing XML docs, not for unused code).
-- No commented-out blocks found.
-- **Clean**: Zero TODO/FIXME/HACK markers in source code.
+- No unused `using` directives detected.
+- No commented-out blocks or dead code.
+- Zero TODO/FIXME/HACK markers.
+- Consistent code style matching `.editorconfig`.
+- XML doc comments on all public types and cmdlet classes.
+- `CS1591` no longer suppressed in Core project.
-#### Code Style
-- Consistent 4-space indentation matching `.editorconfig`.
-- Opening braces on same line (Allman-adjacent style used in some places, K&R in others -- minor inconsistency but not problematic).
-- PascalCase for public members, camelCase for locals/fields.
+### Code Quality Findings
-#### XML Doc Comments
-- Public types in `PSProxmoxVE.Core` have XML doc comments (enabled via `true`).
-- `CS1591` is suppressed in Core project (`$(NoWarn);CS1591`) so not all public members have docs.
-- Cmdlet classes have XML summary comments on the class level.
-- **Gap**: Parameter help text (`[Parameter(HelpMessage = ...)]`) is not used on any cmdlet parameter. This means `Get-Help` shows no parameter descriptions.
+| ID | File | Line | Severity | Description | Prior Status |
+|---|---|---|---|---|---|
+| CQ1 | `RemovePveRoleCmdlet.cs` | 13 | Medium | Missing `ConfirmImpact.High` — deleting roles is significant | New |
+| CQ2 | `PveHttpClient.cs` | 316-323 | Low | Auth header names (`PVEAPIToken=`, `CSRFPreventionToken`) could be constants | Open |
---
-## Phase 4 -- Testing Coverage Analysis
+## Phase 4 — Testing Coverage Analysis
### Test Projects
| Project | Framework | Type | Runner |
|---|---|---|---|
-| `PSProxmoxVE.Core.Tests` | xUnit 2.7 + Moq 4.20 | Unit tests | `dotnet test` |
+| `PSProxmoxVE.Core.Tests` | xUnit 2.7 + Moq 4.20 | Unit tests (models, auth) | `dotnet test` |
| `PSProxmoxVE.Tests` | Pester 5 | Cmdlet + Integration tests | `Invoke-Pester` |
### xUnit Tests (Core Library)
-| Test File | Test Area |
-|---|---|
-| `Authentication/PveSessionTests.cs` | Session creation, expiry, auth modes |
-| `Authentication/PveAuthenticatorTests.cs` | Authentication logic |
-| `Authentication/PveVersionTests.cs` | Version parsing |
-| `Models/ClusterModelTests.cs` | Cluster model deserialization |
-| `Models/ContainerModelTests.cs` | Container model deserialization |
-| `Models/NetworkModelTests.cs` | Network model deserialization |
-| `Models/NodeModelTests.cs` | Node model deserialization |
-| `Models/SdnModelTests.cs` | SDN model deserialization |
-| `Models/SnapshotModelTests.cs` | Snapshot model deserialization |
-| `Models/StorageModelTests.cs` | Storage model deserialization |
-| `Models/TaskModelTests.cs` | Task model deserialization |
-| `Models/UserModelTests.cs` | User model deserialization |
-| `Models/VmModelTests.cs` | VM model deserialization |
+| Test Area | Test File | ~Methods |
+|---|---|---|
+| Session lifecycle | `PveSessionTests.cs` | 9 |
+| Authentication logic | `PveAuthenticatorTests.cs` | 7 |
+| Version parsing | `PveVersionTests.cs` | 11 |
+| Cluster models | `ClusterModelTests.cs` | 13 |
+| Container models | `ContainerModelTests.cs` | 13 |
+| Network models | `NetworkModelTests.cs` | 12 |
+| Node models | `NodeModelTests.cs` | 13 |
+| SDN models | `SdnModelTests.cs` | 17 |
+| Snapshot models | `SnapshotModelTests.cs` | 9 |
+| Storage models | `StorageModelTests.cs` | 19 |
+| Task models | `TaskModelTests.cs` | 9 |
+| User models | `UserModelTests.cs` | 24 |
+| VM models | `VmModelTests.cs` | 18 |
-### Pester Tests (Cmdlet Level)
+**Total: ~174 xUnit tests** with PVE 8 and PVE 9 JSON fixtures.
-| Test File | Cmdlet(s) Covered |
-|---|---|
-| `Connection/Connect-PveServer.Tests.ps1` | `Connect-PveServer` |
-| `Connection/Disconnect-PveServer.Tests.ps1` | `Disconnect-PveServer` |
-| `Connection/Test-PveConnection.Tests.ps1` | `Test-PveConnection` |
-| `Containers/Get-PveContainer.Tests.ps1` | `Get-PveContainer` |
-| `Containers/ContainerLifecycle.Tests.ps1` | Container start/stop/restart |
-| `Containers/ContainerConfigCmdlets.Tests.ps1` | Container config cmdlets |
-| `CloudInit/CloudInitCmdlets.Tests.ps1` | Cloud-init cmdlets |
-| `Network/Get-PveNetwork.Tests.ps1` | `Get-PveNetwork` |
-| `Network/SdnCmdlets.Tests.ps1` | SDN cmdlets |
-| `Nodes/NodeCmdlets.Tests.ps1` | Node cmdlets |
-| `Snapshots/SnapshotCmdlets.Tests.ps1` | Snapshot cmdlets |
-| `Storage/Get-PveStorage.Tests.ps1` | `Get-PveStorage` |
-| `Storage/StorageDownload.Tests.ps1` | `Invoke-PveStorageDownload` |
-| `Storage/Send-PveIso.Tests.ps1` | `Send-PveIso` |
-| `Tasks/TaskCmdlets.Tests.ps1` | Task cmdlets |
-| `Templates/TemplateCmdlets.Tests.ps1` | Template cmdlets |
-| `Users/UserCmdlets.Tests.ps1` | User CRUD |
-| `Users/ApiTokenCmdlets.Tests.ps1` | API token CRUD |
-| `Users/RoleCmdlets.Tests.ps1` | Role CRUD |
-| `Vms/Get-PveVm.Tests.ps1` | `Get-PveVm` |
-| `Vms/New-PveVm.Tests.ps1` | `New-PveVm` |
-| `Vms/Remove-PveVm.Tests.ps1` | `Remove-PveVm` |
-| `Vms/VmLifecycle.Tests.ps1` | VM start/stop/restart |
-| `Vms/VmAdvancedOps.Tests.ps1` | Clone, migrate, resize |
-| `Vms/VmConfigCmdlets.Tests.ps1` | VM config cmdlets |
+### Pester Tests (30 files, ~500 tests)
+
+All 83 cmdlets have Pester unit tests covering command metadata, parameter validation, ShouldProcess presence, and pipeline binding.
### Integration Tests
-The integration test file (`Integration.Tests.ps1`) is comprehensive and covers:
+The integration test file covers 18+ major contexts with 90+ individual tests:
-| Context | Operations Tested |
-|---|---|
-| Connection | Connect via API token, version detection |
-| Nodes | List nodes, get node status |
-| User CRUD | Create, list, update, remove user |
-| Role CRUD | Create, list, remove role |
-| API Token CRUD | Create, list, remove API token |
-| Permissions | List and set permissions |
-| VMs | List, create, config get/set, start/stop, hard reset, clone |
-| Snapshots | Create, list, restore, remove |
-| Storage | List storage, list content, upload ISO |
-| Tasks | Get task, wait for task |
-| Network | List networks |
-| Templates | List templates, convert to template, clone from template |
-| Cloud-Init | Get cloud-init config |
-| Guest Agent | Ping, network interfaces, exec command |
-| ACPI Lifecycle | Graceful restart, graceful stop |
+- Connection, Nodes, User/Role/Token CRUD, Permissions
+- VM lifecycle (create, config, start/stop, reset, clone, resize, suspend/resume)
+- Snapshot CRUD (VM and container)
+- Storage (list, content, upload, create/remove, download)
+- Network CRUD with apply/revert
+- SDN zones/VNets/subnets CRUD
+- Templates, Cloud-Init
+- Container lifecycle (create, config, start/stop/restart, clone, snapshots)
+- Linux VM provisioning (cloud image upload, disk import, cloud-init, guest agent)
-- **PVE 8 and PVE 9 both tested** via matrix strategy in CI.
-- **Setup/teardown**: AfterAll block cleans up created VMs, users, and roles.
-- **Skip logic**: Tests skip gracefully when env vars are not set.
-- **Connection details**: Injected via environment variables (`PVETEST_HOST`, `PVETEST_APITOKEN`, etc.).
+**Setup/teardown**: AfterAll cleans up all created resources. Environment variables inject connection details. Tests skip gracefully when env vars are missing.
### Test Coverage Gap List
-| Cmdlet | Unit Test | Integration Test | Gap |
-|---|---|---|---|
-| `Set-PveNetwork` | Pester | No | No integration test for network modification |
-| `New-PveNetwork` | Pester | No | No integration test for network creation |
-| `Remove-PveNetwork` | Pester | No | No integration test for network removal |
-| `Invoke-PveNetworkApply` | Pester | No | No integration test for network apply |
-| `New-PveSdnZone` | Pester | No | No integration test for SDN zone creation |
-| `Remove-PveSdnZone` | Pester | No | No integration test for SDN zone removal |
-| `New-PveSdnVnet` | Pester | No | No integration test for SDN VNet creation |
-| `Remove-PveSdnVnet` | Pester | No | No integration test for SDN VNet removal |
-| `New-PveStorage` | Pester | No | No integration test for storage creation |
-| `Remove-PveStorage` | Pester | No | No integration test for storage removal |
-| `Invoke-PveStorageDownload` | Pester | No | No integration test for URL download |
-| `Suspend-PveVm` | Pester | No | No integration test for VM suspend |
-| `Resume-PveVm` | Pester | No | No integration test for VM resume |
-| `Move-PveVm` | Pester | No | No integration test (requires multi-node) |
-| `Resize-PveVmDisk` | Pester | No | No integration test for disk resize |
-| `Copy-PveContainer` | Pester | No | No integration test for container clone |
-| `Set-PveCloudInitConfig` | Pester | No | No integration test for cloud-init set |
-| `Invoke-PveCloudInitRegenerate` | Pester | No | Skipped in CI due to bug |
-| `Set-PvePermission` | Pester | Partial | Only tests add, not remove |
+| Cmdlet | Unit Test | Integration Test | PVE 8 | PVE 9 | Prior Status |
+|---|---|---|---|---|---|
+| `Connect-PveServer` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Disconnect-PveServer` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Test-PveConnection` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Get-PveNode` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Get-PveNodeStatus` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Get-PveVm` | ✓ | ✓ | ✓ | ✓ | Open |
+| `New-PveVm` | ✓ | ✓ | — | ✓ | Open |
+| `Remove-PveVm` | ✓ | ✓ | — | ✓ | Open |
+| `Start-PveVm` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Stop-PveVm` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Restart-PveVm` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Suspend-PveVm` | ✓ | ✓ | — | ✓ | Fixed |
+| `Resume-PveVm` | ✓ | ✓ | — | ✓ | Fixed |
+| `Reset-PveVm` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Copy-PveVm` | ✓ | ✓ | — | ✓ | Open |
+| `Move-PveVm` | ✓ | ✗ | — | — | Open |
+| `Get-PveVmConfig` | ✓ | ✓ | — | ✓ | Open |
+| `Set-PveVmConfig` | ✓ | ✓ | — | ✓ | Open |
+| `Resize-PveVmDisk` | ✓ | ✓ | — | ✓ | Fixed |
+| `Import-PveVmDisk` | ✓ | ✓ | — | ✓ | New |
+| `Import-PveOva` | ✓ | ✗ | — | — | New |
+| `Test-PveVmGuestAgent` | ✓ | ✓ | — | ✓ | Open |
+| `Get-PveVmGuestNetwork` | ✓ | ✓ | — | ✓ | Open |
+| `Invoke-PveVmGuestExec` | ✓ | ✓ | — | ✓ | Open |
+| `Get-PveContainer` | ✓ | ✓ | — | ✓ | Open |
+| `New-PveContainer` | ✓ | ✓ | — | ✓ | Open |
+| `Remove-PveContainer` | ✓ | ✓ | — | ✓ | Open |
+| `Start-PveContainer` | ✓ | ✓ | — | ✓ | Open |
+| `Stop-PveContainer` | ✓ | ✓ | — | ✓ | Open |
+| `Restart-PveContainer` | ✓ | ✓ | — | ✓ | Open |
+| `Copy-PveContainer` | ✓ | ✓ | — | ✓ | Fixed |
+| `Move-PveContainer` | ✓ | ✗ | — | — | New |
+| `Get-PveContainerConfig` | ✓ | ✓ | — | ✓ | Open |
+| `Set-PveContainerConfig` | ✓ | ✓ | — | ✓ | Open |
+| `Get-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New |
+| `New-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New |
+| `Remove-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New |
+| `Restore-PveContainerSnapshot` | ✓ | ✓ | — | ✓ | New |
+| `Get-PveStorage` | ✓ | ✓ | ✓ | ✓ | Open |
+| `New-PveStorage` | ✓ | ✓ | — | ✓ | Fixed |
+| `Remove-PveStorage` | ✓ | ✓ | — | ✓ | Fixed |
+| `Get-PveStorageContent` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Send-PveFile` | ✓ | ✓ | — | ✓ | Open |
+| `Invoke-PveStorageDownload` | ✓ | ✓ | — | ✓ | Fixed |
+| `Get-PveSnapshot` | ✓ | ✓ | ✓ | ✓ | Open |
+| `New-PveSnapshot` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Remove-PveSnapshot` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Restore-PveSnapshot` | ✓ | ✗ | — | — | Open |
+| `Get-PveNetwork` | ✓ | ✓ | — | ✓ | Open |
+| `New-PveNetwork` | ✓ | ✓ | — | ✓ | Fixed |
+| `Set-PveNetwork` | ✓ | ✓ | — | ✓ | Fixed |
+| `Remove-PveNetwork` | ✓ | ✓ | — | ✓ | Fixed |
+| `Invoke-PveNetworkApply` | ✓ | ✓ | — | ✓ | Fixed |
+| `Get-PveSdnZone` | ✓ | ✓ | — | ✓ | Open |
+| `New-PveSdnZone` | ✓ | ✓ | — | ✓ | Fixed |
+| `Remove-PveSdnZone` | ✓ | ✓ | — | ✓ | Fixed |
+| `Get-PveSdnVnet` | ✓ | ✓ | — | ✓ | Open |
+| `New-PveSdnVnet` | ✓ | ✓ | — | ✓ | Fixed |
+| `Remove-PveSdnVnet` | ✓ | ✓ | — | ✓ | Fixed |
+| `Get-PveSdnSubnet` | ✓ | ✓ | — | ✓ | New |
+| `New-PveSdnSubnet` | ✓ | ✓ | — | ✓ | New |
+| `Remove-PveSdnSubnet` | ✓ | ✓ | — | ✓ | New |
+| `Get-PveUser` | ✓ | ✓ | ✓ | ✓ | Open |
+| `New-PveUser` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Set-PveUser` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Remove-PveUser` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Get-PveRole` | ✓ | ✓ | ✓ | ✓ | Open |
+| `New-PveRole` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Remove-PveRole` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Get-PveApiToken` | ✓ | ✓ | ✓ | ✓ | Open |
+| `New-PveApiToken` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Remove-PveApiToken` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Get-PvePermission` | ✓ | ✓ | — | ✓ | Open |
+| `Set-PvePermission` | ✓ | ✓ | — | ✓ | Open |
+| `Get-PveTemplate` | ✓ | ✓ | — | ✓ | Open |
+| `New-PveTemplate` | ✓ | ✗ | — | — | Open |
+| `Remove-PveTemplate` | ✓ | ✗ | — | — | Open |
+| `New-PveVmFromTemplate` | ✓ | ✗ | — | — | Open |
+| `Get-PveCloudInitConfig` | ✓ | ✓ | — | ✓ | Open |
+| `Set-PveCloudInitConfig` | ✓ | ✓ | — | ✓ | Fixed |
+| `Invoke-PveCloudInitRegenerate` | ✓ | ✓ | — | ✓ | Fixed |
+| `Get-PveTask` | ✓ | ✓ | ✓ | ✓ | Open |
+| `Wait-PveTask` | ✓ | ✓ | — | ✓ | Open |
-**Note**: Many of the gaps above are for operations that would require complex test infrastructure (multi-node clusters, specific storage backends, etc.) and are reasonable omissions for a pre-1.0 module.
+**Summary**: 83/83 unit tested (100%), 70/83 integration tested (84%). Remaining gaps are for operations requiring multi-node clusters (`Move-PveVm`, `Move-PveContainer`), optional test assets (`Import-PveOva`), or template operations that would require extra setup.
---
-## Phase 5 -- Security Review
+## Phase 5 — Security Review
### 5.1 Credential Handling
-| Aspect | Status | Details |
-|---|---|---|
-| PSCredential usage | **Good** | `Connect-PveServer` accepts `PSCredential` for password auth |
-| Password extraction | **Acceptable** | `NetworkCredential.Password` is used only at the point of API call |
-| API token in memory | **Acceptable** | Stored as plain `string` in `PveSession.ApiToken` -- standard for PowerShell modules |
-| Credential logging | **Good** | No `WriteVerbose`/`WriteDebug` calls that include credentials |
-| Ticket storage | **Good** | Tickets are stored in `PveSession` only, not written to disk |
-
-**Finding**: The password is extracted from `PSCredential` via `NetworkCredential.Password` which returns a plain `string`. This is the standard pattern for PowerShell binary modules since the PVE API requires the password as a form field. No improvement needed.
+| Aspect | Status | Details | Prior Status |
+|---|---|---|---|
+| PSCredential usage | **Good** | `Connect-PveServer` accepts `PSCredential` for password auth | Open |
+| Password extraction | **Acceptable** | `NetworkCredential.Password` used only at API call time | Open |
+| API token in memory | **Acceptable** | Stored as plain `string` in `PveSession.ApiToken` | Open |
+| Credential logging | **Good** | No `WriteVerbose`/`WriteDebug` calls include credentials | Open |
+| Ticket storage | **Good** | Tickets stored in `PveSession` only, not written to disk | Open |
+| Session expiry | **Good** | 2-hour expiry detected via `IsExpired` property | Open |
### 5.2 TLS/HTTPS
-| Aspect | Status | Details |
-|---|---|---|
-| HTTPS enforced | **Good** | `BaseUrl` always constructs `https://` URLs |
-| SkipCertificateCheck | **Good** | Explicit opt-in parameter on `Connect-PveServer` |
-| Certificate bypass | **Acceptable** | Uses `ServerCertificateCustomValidationCallback = ... => true` when opted in |
-| TLS version | **Good** | Left to OS/runtime negotiation (no pinning) |
-| Warning on skip | **Gap** | No `WriteWarning` when `-SkipCertificateCheck` is used |
+| Aspect | Status | Details | Prior Status |
+|---|---|---|---|
+| HTTPS enforced | **Good** | `BaseUrl` always constructs `https://` URLs | Open |
+| SkipCertificateCheck | **Good** | Explicit opt-in parameter on `Connect-PveServer` | Open |
+| Warning on skip | **Good** | `WriteWarning` emitted with man-in-the-middle advisory | Fixed |
+| TLS version | **Good** | Left to OS/runtime negotiation (no pinning) | Open |
+| Port validation | **Good** | `ValidateRange(1, 65535)` on port parameter | Open |
### 5.3 Input Validation
-| Aspect | Status | Details |
-|---|---|---|
-| URL construction | **Good** | Resource paths use string interpolation with validated parameters |
-| Path traversal | **Low risk** | Node names, VM IDs, and storage names are used in URL paths but come from API responses or validated parameters |
-| VMID validation | **Gap** | VmId is `int` type (natural bounds) but no `[ValidateRange]` to enforce PVE's 100-999999999 range |
-| Username format | **Good** | `PveAuthenticator` validates `user@realm` format |
-| API token format | **Good** | Regex validation in `PveAuthenticator.AuthenticateWithApiToken` |
+| Aspect | Status | Details | Prior Status |
+|---|---|---|---|
+| VMID validation | **Good** | `[ValidateRange(100, 999999999)]` on all VmId parameters | Fixed |
+| URL construction | **Good** | String interpolation with validated parameters | Open |
+| User ID encoding | **Good** | `Uri.EscapeDataString()` used for user/token IDs in URLs | Open |
+| API token format | **Good** | Regex validation in `PveAuthenticator` | Open |
+| Path parameters | **Low risk** | Node/snapshot names not URL-encoded but come from validated sources | Open |
### 5.4 Dependency Security
-| Package | Version | Pinned | Notes |
-|---|---|---|---|
-| `Newtonsoft.Json` | 13.0.3 | Yes | Latest stable, no known CVEs |
-| `System.Text.Json` | 8.0.5 | Yes | Latest 8.x patch |
-| `PowerShellStandard.Library` | 5.1.1 | Yes | PrivateAssets=all |
-| `System.Management.Automation` | 7.4.0 | Yes | PrivateAssets=all |
-| `Microsoft.NET.Test.Sdk` | 17.9.0 | Yes | Test only |
-| `xunit` | 2.7.0 | Yes | Test only |
-| `Moq` | 4.20.70 | Yes | Test only |
-| `coverlet.collector` | 6.0.1 | Yes | Test only |
+| Package | Version | Pinned | Notes | Prior Status |
+|---|---|---|---|---|
+| `Newtonsoft.Json` | 13.0.3 | Yes | Latest stable, no known CVEs | Open |
+| `System.Text.Json` | 8.0.5 | Yes | Latest 8.x patch | Open |
+| `SharpCompress` | 0.38.0 | Yes | OVA/tar extraction support | New |
+| `PowerShellStandard.Library` | 5.1.1 | Yes | PrivateAssets=all | Open |
+| `System.Management.Automation` | 7.4.0 | Yes | PrivateAssets=all | Open |
-All dependencies are pinned to exact versions. No floating version ranges detected.
+All dependencies are pinned to exact versions.
### 5.5 Secret Scanning
-| Finding | Severity | Location |
-|---|---|---|
-| Hardcoded test password | **Medium** | `integration-tests.yml:42` -- `PVE_PASSWORD: "Testpass123!"` |
+No hardcoded credentials, tokens, or sensitive IPs found in source code. CI workflow uses GitHub Actions secrets with `::add-mask::` for test passwords.
-The test password is for a throwaway nested PVE instance provisioned and destroyed during CI. It is masked in CI logs via `::add-mask::`. This is acceptable for CI test infrastructure but should be documented.
+### Security Findings
-No other hardcoded credentials, tokens, or sensitive IPs found in source code.
+| ID | Area | File | Severity | Description | Prior Status |
+|---|---|---|---|---|---|
+| S1 | Input validation | `SnapshotService.cs`, `ContainerService.cs` | Low | Snapshot names and node names in URL paths are not URL-encoded — low risk as values come from validated/system sources | New |
---
-## Phase 6 -- PSGallery Publication Readiness
+## Phase 6 — PSGallery Publication Readiness
### 6.1 Module Manifest Assessment
-| Field | Status | Value |
-|---|---|---|
-| `RootModule` | OK | `PSProxmoxVE.dll` |
-| `ModuleVersion` | OK | `0.1.0` |
-| `GUID` | OK | `a3f7c2d1-84e5-4b9f-a061-3e2d8c5f1a7b` |
-| `Author` | OK | `goodolclint` |
-| `CompanyName` | OK | `Worklab` |
-| `Copyright` | OK | `(c) 2026 goodolclint. All rights reserved.` |
-| `Description` | OK | Meaningful, not placeholder |
-| `PowerShellVersion` | OK | `5.1` |
-| `CompatiblePSEditions` | OK | `Desktop`, `Core` |
-| `DotNetFrameworkVersion` | OK | `4.8` |
-| `RequiredAssemblies` | OK | Lists Core DLL and Newtonsoft.Json |
-| `FormatsToProcess` | OK | `PSProxmoxVE.format.ps1xml` |
-| `CmdletsToExport` | **Excellent** | Explicit list of all 66 cmdlets |
-| `FunctionsToExport` | OK | Empty (binary module) |
-| `AliasesToExport` | OK | Empty |
-| `VariablesToExport` | OK | Empty |
-| `Prerelease` | OK | `preview` |
-| `Tags` | OK | 8 relevant tags |
-| `LicenseUri` | OK | Points to GitHub LICENSE |
-| `ProjectUri` | OK | Points to GitHub repo |
-| `ReleaseNotes` | **Missing** | Not present in PSData |
-| `IconUri` | **Missing** | Not present in PSData |
-| `RequiredModules` | N/A | None required |
-| `HelpInfoURI` | **Missing** | No online help |
+| Field | Status | Value | Prior Status |
+|---|---|---|---|
+| `RootModule` | Pass | `PSProxmoxVE.dll` | Open |
+| `ModuleVersion` | Pass | `0.1.0` | Open |
+| `GUID` | Pass | `a3f7c2d1-84e5-4b9f-a061-3e2d8c5f1a7b` | Open |
+| `Author` | Pass | `goodolclint` | Open |
+| `CompanyName` | Pass | `Worklab` | Open |
+| `Copyright` | Pass | `(c) 2026 goodolclint. All rights reserved.` | Open |
+| `Description` | Pass | Meaningful | Open |
+| `PowerShellVersion` | Pass | `5.1` | Open |
+| `CompatiblePSEditions` | Pass | `Desktop`, `Core` | Open |
+| `DotNetFrameworkVersion` | Pass | `4.8` | Open |
+| `RequiredAssemblies` | Pass | Lists Core DLL and Newtonsoft.Json | Open |
+| `FormatsToProcess` | Pass | `PSProxmoxVE.format.ps1xml` | Open |
+| `CmdletsToExport` | Pass | Explicit list of all cmdlets | Open |
+| `FunctionsToExport` | Pass | Empty (binary module) | Open |
+| `AliasesToExport` | Pass | 7 aliases defined | New |
+| `Prerelease` | Pass | `preview` | Open |
+| `Tags` | Pass | 8 relevant tags | Open |
+| `LicenseUri` | Pass | GitHub LICENSE | Open |
+| `ProjectUri` | Pass | GitHub repo | Open |
+| `HelpInfoURI` | Pass | `docs/cmdlets` | Fixed |
+| `ReleaseNotes` | Pass | Present in PSData | Fixed |
+| `IconUri` | **Fail** | Not present | Open |
### 6.2 License
-- MIT License present at repo root.
-- OSI-approved (PSGallery requirement met).
-- `LicenseUri` in manifest points to it.
+| Check | Pass/Fail | Notes | Prior Status |
+|---|---|---|---|
+| LICENSE file present | Pass | MIT License at repo root | Open |
+| OSI-approved license | Pass | MIT | Open |
+| LicenseUri in manifest | Pass | Points to GitHub LICENSE | Open |
### 6.3 README Quality
-| Aspect | Status |
-|---|---|
-| Installation instructions | **Present** but says "not PSGallery" -- needs update for publication |
-| Quick-start examples | **Excellent** -- multiple scenarios covered |
-| Authentication guide | **Excellent** -- both ticket and API token documented |
-| Multi-cluster usage | **Excellent** -- `-Session` parameter documented |
-| Cmdlet reference table | **Excellent** -- all cmdlets listed with descriptions |
-| Known limitations | **Good** -- v1 limitations documented |
-| Contributing section | **Present** -- basic development setup |
-| Badges | **Missing** -- no build status, PSGallery version, or license badges |
+| Check | Pass/Fail | Notes | Prior Status |
+|---|---|---|---|
+| Installation instructions | Pass | `Install-Module PSProxmoxVE` | Fixed |
+| Quick-start examples | Pass | Multiple scenarios | Open |
+| Authentication guide | Pass | Ticket + API token documented | Open |
+| Badges | Pass | Build, Unit Tests, License badges | Fixed |
+| Cmdlet reference | Pass | All cmdlets listed | Open |
+| Known limitations | Pass | Documented | Open |
+| Contributing section | Pass | References CONTRIBUTING.md | Open |
### 6.4 Build & Publish Pipeline
-- **Build workflow**: Builds and tests on net48 (Windows) and net9.0 (Windows + Ubuntu).
-- **Unit test workflow**: Tests Pester scripts across PS 5.1/7.5 on Windows/Ubuntu/macOS.
-- **Integration test workflow**: Full tests against live PVE 8 and PVE 9.
-- **PSGallery publish workflow**: **MISSING** -- no workflow for `Publish-Module` or release creation.
-- **GitHub Releases**: Not configured (no release workflow, no tagged releases in git history).
+| Check | Pass/Fail | Notes | Prior Status |
+|---|---|---|---|
+| Build workflow | Pass | net48 + net9.0, Windows + Ubuntu | Open |
+| Unit test workflow | Pass | PS 5.1/7.5, Windows/Ubuntu/macOS | Open |
+| Integration test workflow | Pass | PVE 8 + PVE 9 | Open |
+| Publish workflow | Pass | Tag-triggered, PSGallery + GitHub Release | Fixed |
+| API key handling | Pass | Via `PSGALLERY_API_KEY` secret | Fixed |
### 6.5 Versioning
-- CHANGELOG follows Keep a Changelog format with Conventional Commits.
-- Only an `[Unreleased]` section exists -- no versioned releases yet.
-- Commit messages follow conventional commits (`feat:`, `fix:`, `test:`, `ci:`, etc.).
+| Check | Pass/Fail | Notes | Prior Status |
+|---|---|---|---|
+| CHANGELOG format | Pass | Keep a Changelog with versioned entry | Open |
+| Versioned release | Pass | `0.1.0-preview` entry present | Fixed |
+| Commit conventions | Pass | Conventional Commits used consistently | Open |
+
+### PSGallery Readiness Checklist
+
+| Check | Pass/Fail | Notes | Prior Status |
+|---|---|---|---|
+| Module manifest complete | Pass | All required fields populated | Open |
+| License present and OSI-approved | Pass | MIT | Open |
+| README with install instructions | Pass | PSGallery install documented | Fixed |
+| Publish pipeline configured | Pass | `publish.yml` on tag push | Fixed |
+| ReleaseNotes present | Pass | In PSData section | Fixed |
+| IconUri | **Fail** | Missing — cosmetic only | Open |
+| MAML help file | Pass | `PSProxmoxVE.dll-Help.xml` present | New |
---
-## Phase 7 -- Community & Repo Maintenance Standards
+## Phase 7 — Community & Repo Maintenance Standards
-### Files Present
+| Check | Pass/Fail | Notes | Prior Status |
+|---|---|---|---|
+| `README.md` | Pass | Comprehensive with badges | Open |
+| `LICENSE` | Pass | MIT | Open |
+| `CHANGELOG.md` | Pass | Keep a Changelog format with versioned entry | Open |
+| `CONTRIBUTING.md` | Pass | Dev setup, coding standards, test instructions, PR process | Fixed |
+| `CODE_OF_CONDUCT.md` | Pass | Contributor Covenant v2.1 | Fixed |
+| `SECURITY.md` | Pass | Vulnerability disclosure policy, 48h response SLA | Fixed |
+| `.editorconfig` | Pass | Comprehensive rules | Open |
+| `.gitignore` | Pass | Build outputs, IDE files, test results | Open |
+| `.gitattributes` | Pass | Line ending normalization, binary detection | Fixed |
+| Issue templates | Pass | Bug report + feature request (YAML format) | Fixed |
+| PR template | Pass | Summary, type checkboxes, checklist | Fixed |
+| Conventional commits | Pass | Consistent in recent history | Open |
+| Default branch `main` | Pass | Confirmed | Open |
+| API coverage documentation | Pass | `docs/PVE_API_COVERAGE.md` | New |
+| Cmdlet documentation | Pass | 75 files in `docs/cmdlets/` | New |
-| Item | Status |
-|---|---|
-| `README.md` | Present, comprehensive |
-| `LICENSE` | Present (MIT) |
-| `CHANGELOG.md` | Present |
-| `.editorconfig` | Present |
-| `.gitignore` | Present |
+### Recommended Branch Protection (cannot verify via API)
-### Files Missing
-
-| Item | Recommendation |
-|---|---|
-| `CONTRIBUTING.md` | Create with dev setup, coding standards, test instructions, PR process |
-| `CODE_OF_CONDUCT.md` | Adopt Contributor Covenant |
-| `SECURITY.md` | Create with vulnerability disclosure policy |
-| `.gitattributes` | Create for line ending normalization (`* text=auto`) |
-| `.github/ISSUE_TEMPLATE/` | Create bug report and feature request templates |
-| `.github/pull_request_template.md` | Create with checklist |
-
-### Commit Conventions
-
-- Conventional Commits used consistently.
-- Scopes include: `ci`, `test`, `fix`, `feat`, `refactor`, `chore`, `docs`.
-- Recent history shows disciplined commit hygiene.
-
-### Branch Protection
-
-Recommended settings (cannot verify via API):
-- Default branch: `main` (confirmed)
- Require PR reviews before merge
-- Require status checks to pass (build + unit tests + integration tests)
+- Require status checks (build + unit tests + integration tests)
- Require linear history or squash merging
---
-## Phase 8 -- Prioritized Recommendations
+## Phase 8 — Prioritized Recommendations
-### Critical (blocks PSGallery publication or is a security risk)
+### 🔴 Critical (blocks PSGallery publication or is a security risk)
-| # | What | Where | Why | Fix |
-|---|---|---|---|---|
-| C1 | **No PSGallery publish workflow** | `.github/workflows/` | Cannot automate releases | Create `publish.yml` triggered on tag push that runs `Publish-Module` with PSGallery API key from secrets |
-| C2 | **README says "not PSGallery"** | `README.md:30` | Contradicts publication goal | Update installation section to `Install-Module PSProxmoxVE` |
-| C3 | **No ReleaseNotes in manifest** | `PSProxmoxVE.psd1:168-195` | PSGallery strongly recommends release notes | Add `ReleaseNotes` to `PSData` section |
-| ~~C4~~ | ~~Pipeline processing bug~~ | *Verified: all cmdlets use `ProcessRecord`* | N/A -- pipeline processing is correct | No action needed |
+**None** — all prior Critical items have been resolved.
-### High (significantly impacts quality or community adoption)
+### 🟠 High (significantly impacts quality or community adoption)
-| # | What | Where | Why | Fix |
-|---|---|---|---|---|
-| H1 | **No `HelpMessage` on parameters** | All cmdlet files | `Get-Help` shows no parameter descriptions | Add `HelpMessage = "..."` to all `[Parameter]` attributes |
-| H2 | **No `WriteVerbose` in cmdlets** | All cmdlets except Connection | Users cannot debug API calls | Add `WriteVerbose($"Calling {method} {resource}")` before API calls |
-| H3 | **Missing `CONTRIBUTING.md`** | Repo root | Barrier to community contributions | Create with dev setup, coding standards, PR process |
-| H4 | **Missing `SECURITY.md`** | Repo root | No vulnerability disclosure process | Create with responsible disclosure instructions |
-| H5 | **No warning on `-SkipCertificateCheck`** | `ConnectPveServerCmdlet.cs` | Users may not realize they're disabling TLS verification | Add `WriteWarning("Certificate validation is disabled...")` |
-| H6 | **`Reset-PveVm` uses string literal** | `ResetPveVmCmdlet.cs:16` | Inconsistent with all other cmdlets using verb class constants | Change `"Reset"` to `VerbsCommon.Reset` |
-| ~~H7~~ | ~~No `WriteProgress` in `Wait-PveTask`~~ | *Verified: already implemented* | N/A -- `WaitPveTaskCmdlet` already uses `WriteProgress` with percentage and timeout tracking | No action needed |
-| H8 | **Missing GitHub issue/PR templates** | `.github/` | No structured issue reporting | Create bug report, feature request, and PR templates |
+| # | What | Where | Why | Fix | Prior Status |
+|---|---|---|---|---|---|
+| H1 | **No firewall cmdlets** | Module | High-value gap for security automation — most requested feature area | Implement firewall rule CRUD for cluster/node/VM | Open |
+| H2 | **No backup/vzdump cmdlets** | Module | Essential for DR automation scenarios | Implement vzdump and backup schedule management | Open |
-### Medium (best practice gaps, test coverage holes)
+### 🟡 Medium (best practice gaps, test coverage holes)
-| # | What | Where | Why | Fix |
-|---|---|---|---|---|
-| M1 | **`Stop-PveVm`/`Reset-PveVm` lack `ConfirmImpact.High`** | `StopPveVmCmdlet.cs`, `ResetPveVmCmdlet.cs` | These can cause data loss | Add `ConfirmImpact = ConfirmImpact.High` |
-| M2 | **No `[ValidateRange]` on VmId** | All cmdlets with VmId parameter | PVE requires VMID 100-999999999 | Add `[ValidateRange(100, 999999999)]` to all VmId parameters |
-| M3 | **Container snapshots not supported** | Services/Cmdlets | Containers support snapshots in PVE but module only handles VM snapshots | Add `Get/New/Remove/Restore-PveContainerSnapshot` cmdlets |
-| M4 | **No integration tests for network modifications** | Integration tests | Network CRUD is tested only at Pester level | Add integration tests for network create/modify/delete |
-| M5 | **No `CODE_OF_CONDUCT.md`** | Repo root | Expected for open-source projects | Add Contributor Covenant |
-| M6 | **No `.gitattributes`** | Repo root | Line ending consistency across platforms | Add `* text=auto` and specific overrides |
-| M7 | **CS1591 suppressed in Core project** | `PSProxmoxVE.Core.csproj:10` | Public API lacks XML documentation | Remove NoWarn and add XML docs to public members |
-| M8 | **No badges in README** | `README.md` | Reduces discoverability and trust | Add build status, PSGallery version, license badges |
-| M9 | **CHANGELOG has only [Unreleased]** | `CHANGELOG.md` | No release history | Create first versioned release entry |
-| M10 | **`Invoke-PveVmGuestExec` lacks ShouldProcess** | `InvokePveVmGuestExecCmdlet.cs:14` | Executes commands inside a VM -- a mutating operation | Add `SupportsShouldProcess = true` |
+| # | What | Where | Why | Fix | Prior Status |
+|---|---|---|---|---|---|
+| M1 | `Remove-PveRole` missing `ConfirmImpact.High` | `RemovePveRoleCmdlet.cs:13` | Deleting roles is a significant, potentially disruptive operation | Add `ConfirmImpact = ConfirmImpact.High` | New |
+| M2 | No pool management | Module | Useful for multi-tenant environments | Implement pool CRUD | Open |
+| M3 | `Move-PveVm` / `Move-PveContainer` lack integration tests | Integration tests | Migration is untested end-to-end (requires multi-node) | Add integration tests when multi-node CI is available | Open |
+| M4 | `Import-PveOva` lacks integration test | Integration tests | OVA import only tested at parameter level | Add to integration tests (requires OVA test asset) | New |
+| M5 | URL-encode snapshot/node names in API paths | `SnapshotService.cs`, `ContainerService.cs` | Defense-in-depth against path injection (low risk) | Apply `Uri.EscapeDataString()` to path parameters | New |
-### Low / Nice-to-have (polish, discoverability, stretch goals)
+### 🟢 Low / Nice-to-have (polish, discoverability, stretch goals)
-| # | What | Where | Why | Fix |
-|---|---|---|---|---|
-| L1 | **No `IconUri` in manifest** | `PSProxmoxVE.psd1` | Improves PSGallery listing appearance | Create a module icon and host it, add URI |
-| L2 | **No online help (MAML/platyPS)** | Module | `Get-Help -Online` doesn't work | Generate MAML help using platyPS |
-| L3 | **No firewall cmdlets** | Module | High-value gap for security automation | Implement firewall rule CRUD for cluster/node/VM |
-| L4 | **No backup/vzdump cmdlets** | Module | High-value gap for DR automation | Implement vzdump and backup schedule management |
-| L5 | **No pool management** | Module | Useful for multi-tenant environments | Implement pool CRUD |
-| L6 | **`new Random()` for boundary generation** | `PveHttpClient.cs:393` | Not cryptographically secure (not a security risk for multipart boundaries, but could use `RandomNumberGenerator`) | Consider `RandomNumberGenerator` for .NET 6+ |
-| L7 | **No alias support** | Module manifest | Some users prefer short aliases | Consider adding aliases like `cpve` for `Connect-PveServer` |
-| L8 | **Tagged releases in GitHub** | Repository | No GitHub Releases | Set up release workflow with auto-generated release notes |
-| L9 | **Container migration cmdlet** | Module | VMs have `Move-PveVm` but containers have no equivalent | Add `Move-PveContainer` |
-| L10 | **SDN subnet management** | Module | SDN zones/vnets exist but subnets are missing | Add SDN subnet CRUD |
+| # | What | Where | Why | Fix | Prior Status |
+|---|---|---|---|---|---|
+| L1 | No `IconUri` in manifest | `PSProxmoxVE.psd1` | Improves PSGallery listing appearance | Create module icon, host it, add URI | Open |
+| L2 | Auth header magic strings | `PveHttpClient.cs:316-323` | Minor code quality — could be named constants | Extract to `const string` fields | Open |
+| L3 | HA/Ceph/Replication cmdlets | Module | Lower-priority API coverage gaps | Implement as demand grows | Open |
+| L4 | Access groups/domains cmdlets | Module | Useful for LDAP/AD integration | Implement `/access/groups`, `/access/domains` CRUD | Open |
+| L5 | PSGallery version badge | `README.md` | Shows published version to visitors | Add PSGallery badge after first publish | New |
+| L6 | SDN IPAM/DNS/Controller cmdlets | Module | Complete SDN management surface | Implement as demand grows | New |
---
## Summary Statistics
-| Metric | Count |
-|---|---|
-| Total cmdlets | 66 |
-| Cmdlets with ShouldProcess | 43 (all destructive/mutating) |
-| Cmdlets with OutputType | 66 (100%) |
-| xUnit test files | 13 |
-| Pester test files | 26 |
-| Integration test contexts | 13 |
-| PVE API areas covered | 10 of ~20 major areas |
-| Critical issues | 4 |
-| High issues | 8 |
-| Medium issues | 10 |
-| Low issues | 10 |
-| NuGet dependencies (runtime) | 2 (Newtonsoft.Json, System.Text.Json) |
-| Security vulnerabilities found | 0 |
-| Hardcoded secrets | 1 (test password in CI, masked) |
+| Metric | This Scan | Prior Scan | Delta |
+|---|---|---|---|
+| Total cmdlets | 83 | 66 | +17 |
+| Cmdlets with ShouldProcess | All mutating | All mutating | — |
+| Cmdlets with OutputType | 83 (100%) | 66 (100%) | — |
+| Cmdlets with HelpMessage | 83 (100%) | 0 (0%) | +83 |
+| xUnit test files | 13 | 13 | — |
+| Pester test files | 30 | 26 | +4 |
+| Integration test coverage | 84% | ~70% | +14% |
+| PVE API areas covered | 12 of ~20 | 10 of ~20 | +2 |
+| Critical issues | 0 | 3 | -3 |
+| High issues | 2 | 8 | -6 |
+| Medium issues | 5 | 10 | -5 |
+| Low issues | 6 | 10 | -4 |
+| **Total issues** | **13** | **31** | **-18** |
+| NuGet dependencies (runtime) | 3 | 2 | +1 (SharpCompress) |
+| Security vulnerabilities | 0 | 0 | — |
+| Community files present | 9/9 | 3/9 | +6 |
+| Documentation files | 78 | 1 | +77 |
diff --git a/docs/PVE_API_COVERAGE.md b/docs/PVE_API_COVERAGE.md
index 4f4162c..1e204f0 100644
--- a/docs/PVE_API_COVERAGE.md
+++ b/docs/PVE_API_COVERAGE.md
@@ -2,9 +2,9 @@
This document tracks which PVE API areas are implemented in PSProxmoxVE and which are planned for future releases.
-**Last updated:** 2026-03-20
+**Last updated:** 2026-03-21
**Module version:** 0.1.0-preview
-**Total cmdlets:** 75
+**Total cmdlets:** 110
## Implemented
@@ -28,17 +28,18 @@ This document tracks which PVE API areas are implemented in PSProxmoxVE and whic
| **Templates** | 4 | VM template conversion, listing, cloning, removal |
| **Cloud-Init** | 3 | `/nodes/{node}/qemu/{vmid}/config` (cloud-init fields), `/nodes/{node}/qemu/{vmid}/cloudinit/regenerate` |
| **Tasks** | 2 | `/nodes/{node}/tasks/{upid}/status` (get, wait) |
+| **Firewall** | 21 | `/cluster/firewall/*`, `/nodes/{node}/firewall/*`, `/nodes/{node}/qemu/{vmid}/firewall/*`, `/nodes/{node}/lxc/{vmid}/firewall/*` (rules, groups, aliases, IP sets, options, refs) |
+| **Backup** | 5 | `/nodes/{node}/vzdump`, `/cluster/backup/*` (ad-hoc backup, scheduled job CRUD) |
+| **SDN IPAM** | 3 | `/cluster/sdn/ipams/*` (CRUD) |
+| **SDN DNS** | 3 | `/cluster/sdn/dns/*` (CRUD) |
+| **SDN Controllers** | 3 | `/cluster/sdn/controllers/*` (CRUD) |
## Not Yet Implemented
-### High-Value Gaps
+### Gaps
-These are the most impactful areas for real-world automation that are not yet covered.
-
-| Area | Key Endpoints | Use Case | Priority |
-|------|--------------|----------|----------|
-| **Firewall** | `/cluster/firewall/*`, `/nodes/{node}/firewall/*`, `/nodes/{node}/qemu/{vmid}/firewall/*` | Security automation, rule management, IP sets, aliases | High |
-| **Backup / vzdump** | `/nodes/{node}/vzdump`, `/cluster/backup/*` | Disaster recovery, scheduled backups, backup job management | High |
+| Area | Key Endpoints | Notes | Priority |
+|------|--------------|-------|----------|
| **Pool Management** | `/pools/*` | Multi-tenant environments, resource grouping | Medium |
### Lower Priority Gaps
@@ -56,9 +57,6 @@ These are the most impactful areas for real-world automation that are not yet co
| **Node Management** | `/nodes/{node}/apt/*`, `/nodes/{node}/disks/*`, `/nodes/{node}/services/*` | Package updates, disk management, service control |
| **Metrics** | `/cluster/metrics/*` | External metrics server configuration |
| **Additional VM Agent** | `/nodes/{node}/qemu/{vmid}/agent/*` | File read/write, OS info, suspend/resume via agent |
-| **SDN IPAM** | `/cluster/sdn/ipams/*` | IP address management for SDN |
-| **SDN DNS** | `/cluster/sdn/dns/*` | DNS integration for SDN |
-| **SDN Controllers** | `/cluster/sdn/controllers/*` | SDN controller configuration |
## Contributing New Cmdlets
diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs
index 76d4512..aa05a05 100644
--- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs
+++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs
@@ -31,6 +31,10 @@ namespace PSProxmoxVE.Core.Client
private readonly HttpClient _httpClient;
private bool _disposed;
+ private const string ApiTokenPrefix = "PVEAPIToken=";
+ private const string AuthCookieName = "PVEAuthCookie=";
+ private const string CsrfHeaderName = "CSRFPreventionToken";
+
///
/// Creates an HTTP client authenticated with the specified PVE session.
///
@@ -313,14 +317,14 @@ namespace PSProxmoxVE.Core.Client
if (_session.AuthMode == PveAuthMode.ApiToken)
{
- request.Headers.TryAddWithoutValidation("Authorization", $"PVEAPIToken={_session.ApiToken}");
+ request.Headers.TryAddWithoutValidation("Authorization", $"{ApiTokenPrefix}{_session.ApiToken}");
}
else
{
// Ticket auth
- request.Headers.Add("Cookie", $"PVEAuthCookie={_session.Ticket}");
+ request.Headers.Add("Cookie", $"{AuthCookieName}{_session.Ticket}");
if (mutating && !string.IsNullOrEmpty(_session.CsrfToken))
- request.Headers.Add("CSRFPreventionToken", _session.CsrfToken);
+ request.Headers.Add(CsrfHeaderName, _session.CsrfToken);
}
return request;
diff --git a/src/PSProxmoxVE.Core/Models/Backup/PveBackupJob.cs b/src/PSProxmoxVE.Core/Models/Backup/PveBackupJob.cs
new file mode 100644
index 0000000..c476e61
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Backup/PveBackupJob.cs
@@ -0,0 +1,138 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Backup;
+
+///
+/// Represents a Proxmox VE scheduled backup job as returned by
+/// the /cluster/backup endpoint.
+///
+public class PveBackupJob
+{
+ ///
+ /// The backup job identifier.
+ ///
+ [JsonPropertyName("id")]
+ [JsonProperty("id")]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// The job type (typically "vzdump").
+ ///
+ [JsonPropertyName("type")]
+ [JsonProperty("type")]
+ public string? Type { get; set; }
+
+ ///
+ /// Whether the job is enabled (1) or disabled (0).
+ ///
+ [JsonPropertyName("enabled")]
+ [JsonProperty("enabled")]
+ public int? Enabled { get; set; }
+
+ ///
+ /// The cron-style schedule (e.g. "0 2 * * *").
+ ///
+ [JsonPropertyName("schedule")]
+ [JsonProperty("schedule")]
+ public string? Schedule { get; set; }
+
+ ///
+ /// The target storage for backup files.
+ ///
+ [JsonPropertyName("storage")]
+ [JsonProperty("storage")]
+ public string? Storage { get; set; }
+
+ ///
+ /// Backup mode: "snapshot", "suspend", or "stop".
+ ///
+ [JsonPropertyName("mode")]
+ [JsonProperty("mode")]
+ public string? Mode { get; set; }
+
+ ///
+ /// Comma-separated list of VM/container IDs to back up, or empty for all.
+ ///
+ [JsonPropertyName("vmid")]
+ [JsonProperty("vmid")]
+ public string? VmId { get; set; }
+
+ ///
+ /// Whether to back up all VMs/containers (1 = all).
+ ///
+ [JsonPropertyName("all")]
+ [JsonProperty("all")]
+ public int? All { get; set; }
+
+ ///
+ /// Compression algorithm (e.g. "zstd", "lzo", "gzip").
+ ///
+ [JsonPropertyName("compress")]
+ [JsonProperty("compress")]
+ public string? Compress { get; set; }
+
+ ///
+ /// Deprecated — maximum number of backup files to keep. Use prune-backups instead.
+ ///
+ [JsonPropertyName("maxfiles")]
+ [JsonProperty("maxfiles")]
+ public int? MaxFiles { get; set; }
+
+ ///
+ /// Retention policy string (e.g. "keep-daily=7,keep-weekly=4").
+ ///
+ [JsonPropertyName("prune-backups")]
+ [JsonProperty("prune-backups")]
+ public string? PruneBackups { get; set; }
+
+ ///
+ /// Template for backup notes.
+ ///
+ [JsonPropertyName("notes-template")]
+ [JsonProperty("notes-template")]
+ public string? NotesTemplate { get; set; }
+
+ ///
+ /// Optional comment describing the backup job.
+ ///
+ [JsonPropertyName("comment")]
+ [JsonProperty("comment")]
+ public string? Comment { get; set; }
+
+ ///
+ /// Mail notification setting (e.g. "always", "failure").
+ ///
+ [JsonPropertyName("mailnotification")]
+ [JsonProperty("mailnotification")]
+ public string? MailNotification { get; set; }
+
+ ///
+ /// Email address to send notifications to.
+ ///
+ [JsonPropertyName("mailto")]
+ [JsonProperty("mailto")]
+ public string? MailTo { get; set; }
+
+ ///
+ /// Specific node to run the backup on (empty = any node).
+ ///
+ [JsonPropertyName("node")]
+ [JsonProperty("node")]
+ public string? Node { get; set; }
+
+ ///
+ /// Comma-separated list of excluded VM/container IDs.
+ ///
+ [JsonPropertyName("exclude")]
+ [JsonProperty("exclude")]
+ public string? Exclude { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ var state = Enabled == 1 ? "Enabled" : "Disabled";
+ return $"Backup Job: {Id} | {state} | Schedule: {Schedule ?? "N/A"} | "
+ + $"Storage: {Storage ?? "N/A"} | Mode: {Mode ?? "N/A"}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallAlias.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallAlias.cs
new file mode 100644
index 0000000..9d7e517
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallAlias.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Firewall;
+
+///
+/// Represents a Proxmox VE firewall IP alias as returned by
+/// the firewall/aliases endpoints.
+///
+public class PveFirewallAlias
+{
+ ///
+ /// The alias name.
+ ///
+ [JsonPropertyName("name")]
+ [JsonProperty("name")]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// The CIDR network or IP address (e.g. "10.0.0.0/8" or "192.168.1.1").
+ ///
+ [JsonPropertyName("cidr")]
+ [JsonProperty("cidr")]
+ public string Cidr { get; set; } = string.Empty;
+
+ ///
+ /// Optional comment describing the alias.
+ ///
+ [JsonPropertyName("comment")]
+ [JsonProperty("comment")]
+ public string? Comment { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"Alias: {Name} → {Cidr}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallGroup.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallGroup.cs
new file mode 100644
index 0000000..7414d8f
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallGroup.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Firewall;
+
+///
+/// Represents a Proxmox VE firewall security group as returned by
+/// the /cluster/firewall/groups endpoint.
+///
+public class PveFirewallGroup
+{
+ ///
+ /// The security group name.
+ ///
+ [JsonPropertyName("group")]
+ [JsonProperty("group")]
+ public string Group { get; set; } = string.Empty;
+
+ ///
+ /// Optional comment describing the group.
+ ///
+ [JsonPropertyName("comment")]
+ [JsonProperty("comment")]
+ public string? Comment { get; set; }
+
+ ///
+ /// Configuration digest for detecting concurrent modifications.
+ ///
+ [JsonPropertyName("digest")]
+ [JsonProperty("digest")]
+ public string? Digest { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"Firewall Group: {Group} | Comment: {Comment ?? "N/A"}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSet.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSet.cs
new file mode 100644
index 0000000..328eb6f
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSet.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Firewall;
+
+///
+/// Represents a Proxmox VE firewall IP set as returned by
+/// the firewall/ipset endpoints.
+///
+public class PveFirewallIpSet
+{
+ ///
+ /// The IP set name.
+ ///
+ [JsonPropertyName("name")]
+ [JsonProperty("name")]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Optional comment describing the IP set.
+ ///
+ [JsonPropertyName("comment")]
+ [JsonProperty("comment")]
+ public string? Comment { get; set; }
+
+ ///
+ /// Configuration digest for detecting concurrent modifications.
+ ///
+ [JsonPropertyName("digest")]
+ [JsonProperty("digest")]
+ public string? Digest { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"IP Set: {Name} | Comment: {Comment ?? "N/A"}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSetEntry.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSetEntry.cs
new file mode 100644
index 0000000..4d58372
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallIpSetEntry.cs
@@ -0,0 +1,39 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Firewall;
+
+///
+/// Represents an entry within a Proxmox VE firewall IP set as returned by
+/// the firewall/ipset/{name} endpoints.
+///
+public class PveFirewallIpSetEntry
+{
+ ///
+ /// The CIDR network or IP address (e.g. "192.168.1.0/24" or "10.0.0.1").
+ ///
+ [JsonPropertyName("cidr")]
+ [JsonProperty("cidr")]
+ public string Cidr { get; set; } = string.Empty;
+
+ ///
+ /// Whether this is an exclusion entry (1 = exclude from set).
+ ///
+ [JsonPropertyName("nomatch")]
+ [JsonProperty("nomatch")]
+ public int? NoMatch { get; set; }
+
+ ///
+ /// Optional comment describing the entry.
+ ///
+ [JsonPropertyName("comment")]
+ [JsonProperty("comment")]
+ public string? Comment { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ var prefix = NoMatch == 1 ? "!" : "";
+ return $"{prefix}{Cidr}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallOptions.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallOptions.cs
new file mode 100644
index 0000000..c25623f
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallOptions.cs
@@ -0,0 +1,88 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Firewall;
+
+///
+/// Represents Proxmox VE firewall options as returned by
+/// the firewall/options endpoints at cluster, node, VM, or container level.
+///
+public class PveFirewallOptions
+{
+ ///
+ /// Whether the firewall is enabled (1) or disabled (0).
+ ///
+ [JsonPropertyName("enable")]
+ [JsonProperty("enable")]
+ public int? Enable { get; set; }
+
+ ///
+ /// Default policy for incoming traffic (ACCEPT, DROP, REJECT).
+ ///
+ [JsonPropertyName("policy_in")]
+ [JsonProperty("policy_in")]
+ public string? PolicyIn { get; set; }
+
+ ///
+ /// Default policy for outgoing traffic (ACCEPT, DROP, REJECT).
+ ///
+ [JsonPropertyName("policy_out")]
+ [JsonProperty("policy_out")]
+ public string? PolicyOut { get; set; }
+
+ ///
+ /// Log level for incoming traffic (nolog, emerg, alert, crit, err, warning, notice, info, debug).
+ ///
+ [JsonPropertyName("log_level_in")]
+ [JsonProperty("log_level_in")]
+ public string? LogLevelIn { get; set; }
+
+ ///
+ /// Log level for outgoing traffic.
+ ///
+ [JsonPropertyName("log_level_out")]
+ [JsonProperty("log_level_out")]
+ public string? LogLevelOut { get; set; }
+
+ ///
+ /// Whether to allow DHCP traffic (VM/container level).
+ ///
+ [JsonPropertyName("dhcp")]
+ [JsonProperty("dhcp")]
+ public int? Dhcp { get; set; }
+
+ ///
+ /// Whether to allow NDP (IPv6 Neighbor Discovery Protocol).
+ ///
+ [JsonPropertyName("ndp")]
+ [JsonProperty("ndp")]
+ public int? Ndp { get; set; }
+
+ ///
+ /// Whether to allow Router Advertisement.
+ ///
+ [JsonPropertyName("radv")]
+ [JsonProperty("radv")]
+ public int? Radv { get; set; }
+
+ ///
+ /// Whether to enable MAC address filter (VM/container level).
+ ///
+ [JsonPropertyName("macfilter")]
+ [JsonProperty("macfilter")]
+ public int? MacFilter { get; set; }
+
+ ///
+ /// Whether to enable IP filter (VM/container level).
+ ///
+ [JsonPropertyName("ipfilter")]
+ [JsonProperty("ipfilter")]
+ public int? IpFilter { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ var state = Enable == 1 ? "Enabled" : "Disabled";
+ return $"Firewall: {state} | In: {PolicyIn ?? "N/A"} | Out: {PolicyOut ?? "N/A"}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRef.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRef.cs
new file mode 100644
index 0000000..29491f8
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRef.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Firewall;
+
+///
+/// Represents a Proxmox VE firewall reference item (alias, ipset, etc.)
+/// as returned by the firewall/refs endpoints.
+///
+public class PveFirewallRef
+{
+ ///
+ /// The reference type (e.g. "alias", "ipset").
+ ///
+ [JsonPropertyName("type")]
+ [JsonProperty("type")]
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// The reference name.
+ ///
+ [JsonPropertyName("name")]
+ [JsonProperty("name")]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Optional comment.
+ ///
+ [JsonPropertyName("comment")]
+ [JsonProperty("comment")]
+ public string? Comment { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{Type}: {Name}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRule.cs b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRule.cs
new file mode 100644
index 0000000..eba503b
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Firewall/PveFirewallRule.cs
@@ -0,0 +1,111 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Firewall;
+
+///
+/// Represents a Proxmox VE firewall rule as returned by
+/// the firewall/rules endpoints at cluster, node, VM, or container level.
+///
+public class PveFirewallRule
+{
+ ///
+ /// Rule position (used for ordering and as identifier for updates/deletes).
+ ///
+ [JsonPropertyName("pos")]
+ [JsonProperty("pos")]
+ public int? Pos { get; set; }
+
+ ///
+ /// Rule type: "in", "out", or "group".
+ ///
+ [JsonPropertyName("type")]
+ [JsonProperty("type")]
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// Action to take: ACCEPT, DROP, or REJECT.
+ ///
+ [JsonPropertyName("action")]
+ [JsonProperty("action")]
+ public string Action { get; set; } = string.Empty;
+
+ ///
+ /// Whether the rule is enabled (1) or disabled (0).
+ ///
+ [JsonPropertyName("enable")]
+ [JsonProperty("enable")]
+ public int? Enable { get; set; }
+
+ ///
+ /// Source address or alias.
+ ///
+ [JsonPropertyName("source")]
+ [JsonProperty("source")]
+ public string? Source { get; set; }
+
+ ///
+ /// Destination address or alias.
+ ///
+ [JsonPropertyName("dest")]
+ [JsonProperty("dest")]
+ public string? Dest { get; set; }
+
+ ///
+ /// Protocol (e.g. tcp, udp, icmp).
+ ///
+ [JsonPropertyName("proto")]
+ [JsonProperty("proto")]
+ public string? Proto { get; set; }
+
+ ///
+ /// Destination port or port range.
+ ///
+ [JsonPropertyName("dport")]
+ [JsonProperty("dport")]
+ public string? Dport { get; set; }
+
+ ///
+ /// Source port or port range.
+ ///
+ [JsonPropertyName("sport")]
+ [JsonProperty("sport")]
+ public string? Sport { get; set; }
+
+ ///
+ /// Optional comment describing the rule.
+ ///
+ [JsonPropertyName("comment")]
+ [JsonProperty("comment")]
+ public string? Comment { get; set; }
+
+ ///
+ /// Predefined macro name (e.g. "SSH", "HTTP", "DNS").
+ ///
+ [JsonPropertyName("macro")]
+ [JsonProperty("macro")]
+ public string? Macro { get; set; }
+
+ ///
+ /// Log level for matched packets (e.g. "nolog", "info", "warning").
+ ///
+ [JsonPropertyName("log")]
+ [JsonProperty("log")]
+ public string? Log { get; set; }
+
+ ///
+ /// Network interface to match (e.g. "net0").
+ ///
+ [JsonPropertyName("iface")]
+ [JsonProperty("iface")]
+ public string? Iface { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ var action = Macro != null ? $"{Action} ({Macro})" : Action;
+ return $"Rule {Pos}: {Type} {action} | "
+ + $"Src: {Source ?? "any"} → Dst: {Dest ?? "any"} | "
+ + $"Proto: {Proto ?? "any"} DPort: {Dport ?? "any"}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Network/PveSdnController.cs b/src/PSProxmoxVE.Core/Models/Network/PveSdnController.cs
new file mode 100644
index 0000000..425dd13
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Network/PveSdnController.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Network;
+
+///
+/// Represents a Software-Defined Networking (SDN) controller as returned by
+/// the /cluster/sdn/controllers endpoint. Requires Proxmox VE 8.0+.
+///
+public class PveSdnController
+{
+ ///
+ /// The controller identifier.
+ ///
+ [JsonPropertyName("controller")]
+ [JsonProperty("controller")]
+ public string Controller { get; set; } = string.Empty;
+
+ ///
+ /// The controller type (e.g. "evpn", "bgp").
+ ///
+ [JsonPropertyName("type")]
+ [JsonProperty("type")]
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// The Autonomous System Number for BGP/EVPN.
+ ///
+ [JsonPropertyName("asn")]
+ [JsonProperty("asn")]
+ public int? Asn { get; set; }
+
+ ///
+ /// Comma-separated list of BGP peer addresses.
+ ///
+ [JsonPropertyName("peers")]
+ [JsonProperty("peers")]
+ public string? Peers { get; set; }
+
+ ///
+ /// The node this controller is configured on.
+ ///
+ [JsonPropertyName("node")]
+ [JsonProperty("node")]
+ public string? Node { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"SDN Controller: {Controller} | Type: {Type} | ASN: {Asn?.ToString() ?? "N/A"}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Network/PveSdnDns.cs b/src/PSProxmoxVE.Core/Models/Network/PveSdnDns.cs
new file mode 100644
index 0000000..f825d49
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Network/PveSdnDns.cs
@@ -0,0 +1,59 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Network;
+
+///
+/// Represents a Software-Defined Networking (SDN) DNS plugin as returned by
+/// the /cluster/sdn/dns endpoint. Requires Proxmox VE 8.0+.
+///
+public class PveSdnDns
+{
+ ///
+ /// The DNS plugin identifier.
+ ///
+ [JsonPropertyName("dns")]
+ [JsonProperty("dns")]
+ public string Dns { get; set; } = string.Empty;
+
+ ///
+ /// The DNS plugin type (e.g. "powerdns").
+ ///
+ [JsonPropertyName("type")]
+ [JsonProperty("type")]
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// The URL of the DNS service API.
+ ///
+ [JsonPropertyName("url")]
+ [JsonProperty("url")]
+ public string? Url { get; set; }
+
+ ///
+ /// The API key for the DNS service.
+ ///
+ [JsonPropertyName("key")]
+ [JsonProperty("key")]
+ public string? Key { get; set; }
+
+ ///
+ /// The IPv6 reverse zone mask length.
+ ///
+ [JsonPropertyName("reversemaskv6")]
+ [JsonProperty("reversemaskv6")]
+ public int? ReverseMaskV6 { get; set; }
+
+ ///
+ /// The TTL (time-to-live) for DNS records.
+ ///
+ [JsonPropertyName("ttl")]
+ [JsonProperty("ttl")]
+ public int? Ttl { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"SDN DNS: {Dns} | Type: {Type} | URL: {Url ?? "N/A"}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Models/Network/PveSdnIpam.cs b/src/PSProxmoxVE.Core/Models/Network/PveSdnIpam.cs
new file mode 100644
index 0000000..0c04a7e
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Models/Network/PveSdnIpam.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Serialization;
+using Newtonsoft.Json;
+
+namespace PSProxmoxVE.Core.Models.Network;
+
+///
+/// Represents a Software-Defined Networking (SDN) IPAM plugin as returned by
+/// the /cluster/sdn/ipams endpoint. Requires Proxmox VE 8.0+.
+///
+public class PveSdnIpam
+{
+ ///
+ /// The IPAM plugin identifier.
+ ///
+ [JsonPropertyName("ipam")]
+ [JsonProperty("ipam")]
+ public string Ipam { get; set; } = string.Empty;
+
+ ///
+ /// The IPAM type (e.g. "pve", "netbox", "phpipam").
+ ///
+ [JsonPropertyName("type")]
+ [JsonProperty("type")]
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// The URL of the external IPAM service (for netbox/phpipam types).
+ ///
+ [JsonPropertyName("url")]
+ [JsonProperty("url")]
+ public string? Url { get; set; }
+
+ ///
+ /// The API token for the external IPAM service.
+ ///
+ [JsonPropertyName("token")]
+ [JsonProperty("token")]
+ public string? Token { get; set; }
+
+ ///
+ /// The configuration section identifier.
+ ///
+ [JsonPropertyName("section")]
+ [JsonProperty("section")]
+ public int? Section { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"SDN IPAM: {Ipam} | Type: {Type}";
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/BackupService.cs b/src/PSProxmoxVE.Core/Services/BackupService.cs
new file mode 100644
index 0000000..53a2dc6
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/BackupService.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+using PSProxmoxVE.Core.Authentication;
+using PSProxmoxVE.Core.Client;
+using PSProxmoxVE.Core.Models.Backup;
+using PSProxmoxVE.Core.Models.Vms;
+
+namespace PSProxmoxVE.Core.Services
+{
+ ///
+ /// Service for Proxmox VE Backup (vzdump) and backup job API operations.
+ ///
+ public class BackupService
+ {
+ // -------------------------------------------------------------------------
+ // Ad-hoc backup (vzdump)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Creates an ad-hoc backup via vzdump. Returns the task UPID.
+ ///
+ /// The authenticated PVE session.
+ /// The node to run the backup on.
+ /// Backup configuration parameters.
+ public PveTask CreateBackup(PveSession session, string node, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/vzdump", config)
+ .GetAwaiter().GetResult();
+ return ParseTask(response, node);
+ }
+
+ // -------------------------------------------------------------------------
+ // Backup jobs (scheduled)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns all scheduled backup jobs.
+ ///
+ public PveBackupJob[] GetBackupJobs(PveSession session)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync("cluster/backup").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Returns a single scheduled backup job by ID.
+ ///
+ public PveBackupJob? GetBackupJob(PveSession session, string id)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id));
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync($"cluster/backup/{Uri.EscapeDataString(id)}")
+ .GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject();
+ }
+
+ ///
+ /// Creates a scheduled backup job.
+ ///
+ public void CreateBackupJob(PveSession session, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync("cluster/backup", config).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Updates a scheduled backup job.
+ ///
+ public void UpdateBackupJob(PveSession session, string id, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ client.PutAsync($"cluster/backup/{Uri.EscapeDataString(id)}", config)
+ .GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes a scheduled backup job.
+ ///
+ public void RemoveBackupJob(PveSession session, string id)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id));
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"cluster/backup/{Uri.EscapeDataString(id)}")
+ .GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private static PveTask ParseTask(string response, string node)
+ {
+ var data = JObject.Parse(response)["data"];
+ if (data?.Type == JTokenType.String)
+ return new PveTask { Upid = data.ToString(), Node = node };
+
+ var task = data?.ToObject() ?? new PveTask();
+ task.Node = node;
+ return task;
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/ContainerService.cs b/src/PSProxmoxVE.Core/Services/ContainerService.cs
index 84cd829..040ce7a 100644
--- a/src/PSProxmoxVE.Core/Services/ContainerService.cs
+++ b/src/PSProxmoxVE.Core/Services/ContainerService.cs
@@ -173,7 +173,7 @@ namespace PSProxmoxVE.Core.Services
if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname));
using var client = new PveHttpClient(session);
- var response = client.DeleteAsync($"nodes/{node}/lxc/{vmid}/snapshot/{snapname}")
+ var response = client.DeleteAsync($"nodes/{node}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
@@ -192,7 +192,7 @@ namespace PSProxmoxVE.Core.Services
if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname));
using var client = new PveHttpClient(session);
- var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/snapshot/{snapname}/rollback")
+ var response = client.PostAsync($"nodes/{node}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
diff --git a/src/PSProxmoxVE.Core/Services/FirewallService.cs b/src/PSProxmoxVE.Core/Services/FirewallService.cs
new file mode 100644
index 0000000..8e8ced3
--- /dev/null
+++ b/src/PSProxmoxVE.Core/Services/FirewallService.cs
@@ -0,0 +1,478 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+using PSProxmoxVE.Core.Authentication;
+using PSProxmoxVE.Core.Client;
+using PSProxmoxVE.Core.Models.Firewall;
+
+namespace PSProxmoxVE.Core.Services
+{
+ ///
+ /// Service for Proxmox VE Firewall API operations.
+ /// Supports firewall management at cluster, node, VM, and container levels.
+ ///
+ public class FirewallService
+ {
+ // -------------------------------------------------------------------------
+ // Rules
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns firewall rules at the specified level.
+ ///
+ public PveFirewallRule[] GetRules(PveSession session, string level, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync($"{basePath}/rules").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates a firewall rule at the specified level.
+ ///
+ public void CreateRule(PveSession session, string level, Dictionary config,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync($"{basePath}/rules", config).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Updates a firewall rule at the specified position.
+ ///
+ public void UpdateRule(PveSession session, string level, int pos, Dictionary config,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ client.PutAsync($"{basePath}/rules/{pos}", config).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes a firewall rule at the specified position.
+ ///
+ public void RemoveRule(PveSession session, string level, int pos,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"{basePath}/rules/{pos}").GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // Security Groups (cluster level only)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns all firewall security groups.
+ ///
+ public PveFirewallGroup[] GetGroups(PveSession session)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync("cluster/firewall/groups").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates a firewall security group.
+ ///
+ public void CreateGroup(PveSession session, string name, string? comment = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+
+ var formData = new Dictionary { ["group"] = name };
+ if (!string.IsNullOrEmpty(comment))
+ formData["comment"] = comment!;
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync("cluster/firewall/groups", formData).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes a firewall security group.
+ ///
+ public void RemoveGroup(PveSession session, string name)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"cluster/firewall/groups/{Uri.EscapeDataString(name)}")
+ .GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Returns firewall rules within a security group.
+ ///
+ public PveFirewallRule[] GetGroupRules(PveSession session, string group)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}")
+ .GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates a rule within a security group.
+ ///
+ public void CreateGroupRule(PveSession session, string group, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}", config)
+ .GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Updates a rule within a security group.
+ ///
+ public void UpdateGroupRule(PveSession session, string group, int pos, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ client.PutAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}/{pos}", config)
+ .GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes a rule from a security group.
+ ///
+ public void RemoveGroupRule(PveSession session, string group, int pos)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(group)) throw new ArgumentNullException(nameof(group));
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"cluster/firewall/groups/{Uri.EscapeDataString(group)}/{pos}")
+ .GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // Aliases
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns firewall aliases at the specified level.
+ ///
+ public PveFirewallAlias[] GetAliases(PveSession session, string level, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync($"{basePath}/aliases").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates a firewall alias.
+ ///
+ public void CreateAlias(PveSession session, string level, string name, string cidr,
+ string? comment = null, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ var formData = new Dictionary
+ {
+ ["name"] = name,
+ ["cidr"] = cidr
+ };
+ if (!string.IsNullOrEmpty(comment))
+ formData["comment"] = comment!;
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync($"{basePath}/aliases", formData).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Updates a firewall alias.
+ ///
+ public void UpdateAlias(PveSession session, string level, string name,
+ string? cidr = null, string? comment = null, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ var formData = new Dictionary();
+ if (!string.IsNullOrEmpty(cidr))
+ formData["cidr"] = cidr!;
+ if (!string.IsNullOrEmpty(comment))
+ formData["comment"] = comment!;
+
+ using var client = new PveHttpClient(session);
+ client.PutAsync($"{basePath}/aliases/{Uri.EscapeDataString(name)}", formData)
+ .GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes a firewall alias.
+ ///
+ public void RemoveAlias(PveSession session, string level, string name,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"{basePath}/aliases/{Uri.EscapeDataString(name)}")
+ .GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // IP Sets
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns firewall IP sets at the specified level.
+ ///
+ public PveFirewallIpSet[] GetIpSets(PveSession session, string level, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync($"{basePath}/ipset").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates a firewall IP set.
+ ///
+ public void CreateIpSet(PveSession session, string level, string name,
+ string? comment = null, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ var formData = new Dictionary { ["name"] = name };
+ if (!string.IsNullOrEmpty(comment))
+ formData["comment"] = comment!;
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync($"{basePath}/ipset", formData).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes a firewall IP set.
+ ///
+ public void RemoveIpSet(PveSession session, string level, string name,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"{basePath}/ipset/{Uri.EscapeDataString(name)}")
+ .GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // IP Set Entries
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns entries within an IP set.
+ ///
+ public PveFirewallIpSetEntry[] GetIpSetEntries(PveSession session, string level, string name,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync($"{basePath}/ipset/{Uri.EscapeDataString(name)}")
+ .GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Adds an entry to an IP set.
+ ///
+ public void AddIpSetEntry(PveSession session, string level, string name, string cidr,
+ bool nomatch = false, string? comment = null, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ var formData = new Dictionary { ["cidr"] = cidr };
+ if (nomatch)
+ formData["nomatch"] = "1";
+ if (!string.IsNullOrEmpty(comment))
+ formData["comment"] = comment!;
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync($"{basePath}/ipset/{Uri.EscapeDataString(name)}", formData)
+ .GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Updates an entry in an IP set.
+ ///
+ public void UpdateIpSetEntry(PveSession session, string level, string name, string cidr,
+ bool? nomatch = null, string? comment = null, string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ var formData = new Dictionary();
+ if (nomatch.HasValue)
+ formData["nomatch"] = nomatch.Value ? "1" : "0";
+ if (!string.IsNullOrEmpty(comment))
+ formData["comment"] = comment!;
+
+ using var client = new PveHttpClient(session);
+ client.PutAsync(
+ $"{basePath}/ipset/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(cidr)}",
+ formData).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes an entry from an IP set.
+ ///
+ public void RemoveIpSetEntry(PveSession session, string level, string name, string cidr,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
+ if (string.IsNullOrWhiteSpace(cidr)) throw new ArgumentNullException(nameof(cidr));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync(
+ $"{basePath}/ipset/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(cidr)}")
+ .GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // Options
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns firewall options at the specified level.
+ ///
+ public PveFirewallOptions GetOptions(PveSession session, string level,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync($"{basePath}/options").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? new PveFirewallOptions();
+ }
+
+ ///
+ /// Sets firewall options at the specified level.
+ ///
+ public void SetOptions(PveSession session, string level, Dictionary config,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ if (config == null) throw new ArgumentNullException(nameof(config));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ using var client = new PveHttpClient(session);
+ client.PutAsync($"{basePath}/options", config).GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // Refs
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns firewall references (aliases, IP sets) at the specified level.
+ ///
+ public PveFirewallRef[] GetRefs(PveSession session, string level, string? type = null,
+ string? node = null, int? vmid = null)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ var basePath = BuildBasePath(level, node, vmid);
+
+ var resource = $"{basePath}/refs";
+ if (!string.IsNullOrEmpty(type))
+ resource += $"?type={Uri.EscapeDataString(type!)}";
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync(resource).GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Builds the API base path for the specified firewall level.
+ ///
+ private static string BuildBasePath(string level, string? node, int? vmid)
+ {
+ switch (level.ToLowerInvariant())
+ {
+ case "cluster":
+ return "cluster/firewall";
+ case "node":
+ if (string.IsNullOrWhiteSpace(node))
+ throw new ArgumentException("Node is required for node-level firewall operations.", nameof(node));
+ return $"nodes/{Uri.EscapeDataString(node)}/firewall";
+ case "vm":
+ if (string.IsNullOrWhiteSpace(node))
+ throw new ArgumentException("Node is required for VM-level firewall operations.", nameof(node));
+ if (!vmid.HasValue)
+ throw new ArgumentException("VmId is required for VM-level firewall operations.", nameof(vmid));
+ return $"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid.Value}/firewall";
+ case "container":
+ if (string.IsNullOrWhiteSpace(node))
+ throw new ArgumentException("Node is required for container-level firewall operations.", nameof(node));
+ if (!vmid.HasValue)
+ throw new ArgumentException("VmId is required for container-level firewall operations.", nameof(vmid));
+ return $"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid.Value}/firewall";
+ default:
+ throw new ArgumentException($"Invalid firewall level: {level}. Must be Cluster, Node, Vm, or Container.", nameof(level));
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE.Core/Services/NetworkService.cs b/src/PSProxmoxVE.Core/Services/NetworkService.cs
index 5b58d4d..791b893 100644
--- a/src/PSProxmoxVE.Core/Services/NetworkService.cs
+++ b/src/PSProxmoxVE.Core/Services/NetworkService.cs
@@ -304,6 +304,141 @@ namespace PSProxmoxVE.Core.Services
.GetAwaiter().GetResult();
}
+ // -------------------------------------------------------------------------
+ // SDN IPAM
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns all SDN IPAM plugins. Requires PVE 8.0+.
+ ///
+ public PveSdnIpam[] GetSdnIpams(PveSession session)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync("cluster/sdn/ipams").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates an SDN IPAM plugin. Requires PVE 8.0+.
+ ///
+ public void CreateSdnIpam(PveSession session, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync("cluster/sdn/ipams", config).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes an SDN IPAM plugin. Requires PVE 8.0+.
+ ///
+ public void RemoveSdnIpam(PveSession session, string ipam)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+ if (string.IsNullOrWhiteSpace(ipam)) throw new ArgumentNullException(nameof(ipam));
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"cluster/sdn/ipams/{Uri.EscapeDataString(ipam)}")
+ .GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // SDN DNS
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns all SDN DNS plugins. Requires PVE 8.0+.
+ ///
+ public PveSdnDns[] GetSdnDnsPlugins(PveSession session)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync("cluster/sdn/dns").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates an SDN DNS plugin. Requires PVE 8.0+.
+ ///
+ public void CreateSdnDnsPlugin(PveSession session, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync("cluster/sdn/dns", config).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes an SDN DNS plugin. Requires PVE 8.0+.
+ ///
+ public void RemoveSdnDnsPlugin(PveSession session, string dns)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+ if (string.IsNullOrWhiteSpace(dns)) throw new ArgumentNullException(nameof(dns));
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"cluster/sdn/dns/{Uri.EscapeDataString(dns)}")
+ .GetAwaiter().GetResult();
+ }
+
+ // -------------------------------------------------------------------------
+ // SDN Controllers
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Returns all SDN controllers. Requires PVE 8.0+.
+ ///
+ public PveSdnController[] GetSdnControllers(PveSession session)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+
+ using var client = new PveHttpClient(session);
+ var response = client.GetAsync("cluster/sdn/controllers").GetAwaiter().GetResult();
+ var data = JObject.Parse(response)["data"];
+ return data?.ToObject() ?? Array.Empty();
+ }
+
+ ///
+ /// Creates an SDN controller. Requires PVE 8.0+.
+ ///
+ public void CreateSdnController(PveSession session, Dictionary config)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ using var client = new PveHttpClient(session);
+ client.PostAsync("cluster/sdn/controllers", config).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Removes an SDN controller. Requires PVE 8.0+.
+ ///
+ public void RemoveSdnController(PveSession session, string controller)
+ {
+ if (session == null) throw new ArgumentNullException(nameof(session));
+ RequireSdn(session);
+ if (string.IsNullOrWhiteSpace(controller)) throw new ArgumentNullException(nameof(controller));
+
+ using var client = new PveHttpClient(session);
+ client.DeleteAsync($"cluster/sdn/controllers/{Uri.EscapeDataString(controller)}")
+ .GetAwaiter().GetResult();
+ }
+
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
diff --git a/src/PSProxmoxVE.Core/Services/SnapshotService.cs b/src/PSProxmoxVE.Core/Services/SnapshotService.cs
index e69acd4..aa21bb8 100644
--- a/src/PSProxmoxVE.Core/Services/SnapshotService.cs
+++ b/src/PSProxmoxVE.Core/Services/SnapshotService.cs
@@ -84,7 +84,7 @@ namespace PSProxmoxVE.Core.Services
if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname));
using var client = new PveHttpClient(session);
- var response = client.DeleteAsync($"nodes/{node}/qemu/{vmid}/snapshot/{snapname}")
+ var response = client.DeleteAsync($"nodes/{node}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
@@ -107,7 +107,7 @@ namespace PSProxmoxVE.Core.Services
if (string.IsNullOrWhiteSpace(snapname)) throw new ArgumentNullException(nameof(snapname));
using var client = new PveHttpClient(session);
- var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback")
+ var response = client.PostAsync($"nodes/{node}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback")
.GetAwaiter().GetResult();
return ParseTask(response, node);
}
diff --git a/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupJobCmdlet.cs
new file mode 100644
index 0000000..183e8b1
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupJobCmdlet.cs
@@ -0,0 +1,46 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Backup;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Backup
+{
+ ///
+ /// Lists Proxmox VE backup jobs.
+ ///
+ /// Returns scheduled backup job configurations from the Proxmox VE cluster.
+ /// Optionally filter by job ID.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Get, "PveBackupJob")]
+ [OutputType(typeof(PveBackupJob))]
+ public sealed class GetPveBackupJobCmdlet : PveCmdletBase
+ {
+ ///
+ /// The backup job ID to retrieve. When omitted, all jobs are returned.
+ ///
+ [Parameter(Mandatory = false, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The backup job ID.")]
+ public string? Id { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var session = GetSession();
+ var service = new BackupService();
+
+ if (!string.IsNullOrEmpty(Id))
+ {
+ WriteVerbose($"Getting backup job '{Id}'...");
+ var job = service.GetBackupJob(session, Id!);
+ WriteObject(job);
+ }
+ else
+ {
+ WriteVerbose("Getting all backup jobs...");
+ var jobs = service.GetBackupJobs(session);
+ foreach (var job in jobs)
+ {
+ WriteObject(job);
+ }
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupCmdlet.cs
new file mode 100644
index 0000000..e48b4eb
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupCmdlet.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Vms;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Backup
+{
+ ///
+ /// Creates an ad-hoc backup of a virtual machine via vzdump.
+ ///
+ /// Triggers an immediate backup (vzdump) of the specified VM on the given node.
+ /// Returns a PveTask representing the backup operation. Use -Wait to block until
+ /// the backup completes.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.New, "PveBackup", SupportsShouldProcess = true)]
+ [OutputType(typeof(PveTask))]
+ public sealed class NewPveBackupCmdlet : PveCmdletBase
+ {
+ ///
+ /// The node on which the VM resides.
+ ///
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The PVE node name.")]
+ public string Node { get; set; } = string.Empty;
+
+ ///
+ /// The ID of the VM to back up.
+ ///
+ [Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true, HelpMessage = "The VM identifier.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ ///
+ /// The target storage for the backup.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The target storage for the backup.")]
+ public string? Storage { get; set; }
+
+ ///
+ /// The backup mode (snapshot, suspend, or stop).
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The backup mode.")]
+ [ValidateSet("snapshot", "suspend", "stop")]
+ public string? Mode { get; set; }
+
+ ///
+ /// The compression algorithm to use.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The compression algorithm.")]
+ [ValidateSet("zstd", "lzo", "gzip", "none")]
+ public string? Compress { get; set; }
+
+ ///
+ /// When specified, waits for the backup task to complete before returning.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
+ public SwitchParameter Wait { get; set; }
+
+ ///
+ /// Maximum seconds to wait when -Wait is specified. Default 3600.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Timeout in seconds for -Wait (default 3600).")]
+ public int Timeout { get; set; } = 3600;
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"VM {VmId} on node '{Node}'", "New-PveBackup"))
+ return;
+
+ var session = GetSession();
+ var service = new BackupService();
+
+ var formData = new Dictionary
+ {
+ ["vmid"] = VmId.ToString()
+ };
+
+ if (!string.IsNullOrEmpty(Storage)) formData["storage"] = Storage!;
+ if (!string.IsNullOrEmpty(Mode)) formData["mode"] = Mode!;
+ if (!string.IsNullOrEmpty(Compress)) formData["compress"] = Compress!;
+
+ WriteVerbose($"Creating backup of VM {VmId} on node '{Node}'...");
+ var task = service.CreateBackup(session, Node, formData);
+
+ if (Wait.IsPresent)
+ {
+ var taskService = new TaskService();
+ task = taskService.WaitForTask(session, task.Node ?? Node, task.Upid, TimeSpan.FromSeconds(Timeout));
+ }
+
+ WriteObject(task);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupJobCmdlet.cs
new file mode 100644
index 0000000..2a9ffe2
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Backup/NewPveBackupJobCmdlet.cs
@@ -0,0 +1,122 @@
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Backup
+{
+ ///
+ /// Creates a scheduled backup job on Proxmox VE.
+ ///
+ /// Creates a new scheduled backup job in the Proxmox VE cluster configuration.
+ /// Specify a cron-style schedule and target storage. Use VmId for specific VMs
+ /// or -All to back up all VMs.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.New, "PveBackupJob", SupportsShouldProcess = true)]
+ public sealed class NewPveBackupJobCmdlet : PveCmdletBase
+ {
+ ///
+ /// Cron-style schedule expression (e.g., "0 2 * * *").
+ ///
+ [Parameter(Mandatory = true, HelpMessage = "Cron-style schedule expression (e.g., '0 2 * * *').")]
+ public string Schedule { get; set; } = string.Empty;
+
+ ///
+ /// The target storage for backups.
+ ///
+ [Parameter(Mandatory = true, HelpMessage = "The target storage for backups.")]
+ public string Storage { get; set; } = string.Empty;
+
+ ///
+ /// The backup mode (snapshot, suspend, or stop).
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The backup mode.")]
+ [ValidateSet("snapshot", "suspend", "stop")]
+ public string? Mode { get; set; }
+
+ ///
+ /// Comma-separated list of VM IDs to include in the backup job.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Comma-separated list of VM IDs to back up.")]
+ public string? VmId { get; set; }
+
+ ///
+ /// When specified, backs up all VMs on the node.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Back up all VMs.")]
+ public SwitchParameter All { get; set; }
+
+ ///
+ /// The compression algorithm to use.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The compression algorithm.")]
+ [ValidateSet("zstd", "lzo", "gzip", "none")]
+ public string? Compress { get; set; }
+
+ ///
+ /// The node to restrict the backup job to.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The node to restrict the backup job to.")]
+ public string? Node { get; set; }
+
+ ///
+ /// Email address(es) to send notifications to.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Email address(es) for notifications.")]
+ public string? MailTo { get; set; }
+
+ ///
+ /// When to send email notifications (always or failure).
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "When to send email notifications.")]
+ [ValidateSet("always", "failure")]
+ public string? MailNotification { get; set; }
+
+ ///
+ /// Whether the backup job is enabled. Default is true.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Whether the backup job is enabled.")]
+ public SwitchParameter Enabled { get; set; } = true;
+
+ ///
+ /// A comment or description for the backup job.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "A comment or description for the backup job.")]
+ public string? Comment { get; set; }
+
+ ///
+ /// Prune-backups configuration string (e.g., "keep-daily=7,keep-weekly=4").
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Prune-backups configuration string.")]
+ public string? PruneBackups { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess("cluster backup configuration", "New-PveBackupJob"))
+ return;
+
+ var session = GetSession();
+ var service = new BackupService();
+
+ var config = new Dictionary
+ {
+ ["schedule"] = Schedule,
+ ["storage"] = Storage,
+ ["enabled"] = Enabled.IsPresent ? "1" : "0"
+ };
+
+ if (!string.IsNullOrEmpty(Mode)) config["mode"] = Mode!;
+ if (!string.IsNullOrEmpty(VmId)) config["vmid"] = VmId!;
+ if (All.IsPresent) config["all"] = "1";
+ if (!string.IsNullOrEmpty(Compress)) config["compress"] = Compress!;
+ if (!string.IsNullOrEmpty(Node)) config["node"] = Node!;
+ if (!string.IsNullOrEmpty(MailTo)) config["mailto"] = MailTo!;
+ if (!string.IsNullOrEmpty(MailNotification)) config["mailnotification"] = MailNotification!;
+ if (!string.IsNullOrEmpty(Comment)) config["comment"] = Comment!;
+ if (!string.IsNullOrEmpty(PruneBackups)) config["prune-backups"] = PruneBackups!;
+
+ WriteVerbose("Creating backup job...");
+ service.CreateBackupJob(session, config);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Backup/RemovePveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/RemovePveBackupJobCmdlet.cs
new file mode 100644
index 0000000..28e229e
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Backup/RemovePveBackupJobCmdlet.cs
@@ -0,0 +1,36 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Backup
+{
+ ///
+ /// Removes a scheduled backup job from Proxmox VE.
+ ///
+ /// Deletes a backup job from the Proxmox VE cluster configuration.
+ /// This operation is destructive and requires confirmation unless -Force is specified.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Remove, "PveBackupJob",
+ SupportsShouldProcess = true,
+ ConfirmImpact = ConfirmImpact.High)]
+ public sealed class RemovePveBackupJobCmdlet : PveCmdletBase
+ {
+ ///
+ /// The backup job ID to remove.
+ ///
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The backup job ID.")]
+ public string Id { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"backup job '{Id}'", "Remove-PveBackupJob"))
+ return;
+
+ var session = GetSession();
+ var service = new BackupService();
+
+ WriteVerbose($"Removing backup job '{Id}'...");
+ service.RemoveBackupJob(session, Id);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Backup/SetPveBackupJobCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/SetPveBackupJobCmdlet.cs
new file mode 100644
index 0000000..9d4ae24
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Backup/SetPveBackupJobCmdlet.cs
@@ -0,0 +1,125 @@
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Backup
+{
+ ///
+ /// Updates a scheduled backup job on Proxmox VE.
+ ///
+ /// Modifies properties of an existing backup job. Only specified parameters are
+ /// updated; omitted parameters retain their current values.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Set, "PveBackupJob", SupportsShouldProcess = true)]
+ public sealed class SetPveBackupJobCmdlet : PveCmdletBase
+ {
+ ///
+ /// The backup job ID to update.
+ ///
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The backup job ID.")]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Updated cron-style schedule expression.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Cron-style schedule expression (e.g., '0 2 * * *').")]
+ public string? Schedule { get; set; }
+
+ ///
+ /// Updated target storage for backups.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The target storage for backups.")]
+ public string? Storage { get; set; }
+
+ ///
+ /// Updated backup mode (snapshot, suspend, or stop).
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The backup mode.")]
+ [ValidateSet("snapshot", "suspend", "stop")]
+ public string? Mode { get; set; }
+
+ ///
+ /// Updated comma-separated list of VM IDs.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Comma-separated list of VM IDs to back up.")]
+ public string? VmId { get; set; }
+
+ ///
+ /// When specified, backs up all VMs.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Back up all VMs.")]
+ public SwitchParameter All { get; set; }
+
+ ///
+ /// Updated compression algorithm.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The compression algorithm.")]
+ [ValidateSet("zstd", "lzo", "gzip", "none")]
+ public string? Compress { get; set; }
+
+ ///
+ /// Updated node restriction.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "The node to restrict the backup job to.")]
+ public string? Node { get; set; }
+
+ ///
+ /// Updated email address(es) for notifications.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Email address(es) for notifications.")]
+ public string? MailTo { get; set; }
+
+ ///
+ /// Updated notification policy.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "When to send email notifications.")]
+ [ValidateSet("always", "failure")]
+ public string? MailNotification { get; set; }
+
+ ///
+ /// Enable or disable the backup job.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Enable or disable the backup job.")]
+ public SwitchParameter Enabled { get; set; }
+
+ ///
+ /// Updated comment or description.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "A comment or description for the backup job.")]
+ public string? Comment { get; set; }
+
+ ///
+ /// Updated prune-backups configuration string.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Prune-backups configuration string.")]
+ public string? PruneBackups { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"backup job '{Id}'", "Set-PveBackupJob"))
+ return;
+
+ var session = GetSession();
+ var service = new BackupService();
+
+ var config = new Dictionary();
+
+ if (!string.IsNullOrEmpty(Schedule)) config["schedule"] = Schedule!;
+ if (!string.IsNullOrEmpty(Storage)) config["storage"] = Storage!;
+ if (!string.IsNullOrEmpty(Mode)) config["mode"] = Mode!;
+ if (!string.IsNullOrEmpty(VmId)) config["vmid"] = VmId!;
+ if (All.IsPresent) config["all"] = "1";
+ if (!string.IsNullOrEmpty(Compress)) config["compress"] = Compress!;
+ if (!string.IsNullOrEmpty(Node)) config["node"] = Node!;
+ if (!string.IsNullOrEmpty(MailTo)) config["mailto"] = MailTo!;
+ if (!string.IsNullOrEmpty(MailNotification)) config["mailnotification"] = MailNotification!;
+ if (MyInvocation.BoundParameters.ContainsKey("Enabled")) config["enabled"] = Enabled.IsPresent ? "1" : "0";
+ if (!string.IsNullOrEmpty(Comment)) config["comment"] = Comment!;
+ if (!string.IsNullOrEmpty(PruneBackups)) config["prune-backups"] = PruneBackups!;
+
+ WriteVerbose($"Updating backup job '{Id}'...");
+ service.UpdateBackupJob(session, Id, config);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallAliasCmdlet.cs
new file mode 100644
index 0000000..8275a0e
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallAliasCmdlet.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Linq;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Get, "PveFirewallAlias")]
+ [OutputType(typeof(PveFirewallAlias))]
+ public class GetPveFirewallAliasCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional alias name to filter by.")]
+ public string? Name { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Getting firewall aliases at level '{level}'...");
+ var aliases = service.GetAliases(session, level, Node, vmid);
+
+ if (!string.IsNullOrEmpty(Name))
+ {
+ var filtered = aliases.Where(a => string.Equals(a.Name, Name, StringComparison.OrdinalIgnoreCase)).ToArray();
+ WriteObject(filtered, true);
+ }
+ else
+ {
+ WriteObject(aliases, true);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallGroupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallGroupCmdlet.cs
new file mode 100644
index 0000000..3128d93
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallGroupCmdlet.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Get, "PveFirewallGroup")]
+ [OutputType(typeof(PveFirewallGroup), typeof(PveFirewallRule))]
+ public class GetPveFirewallGroupCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = false, Position = 0, HelpMessage = "The security group name. If specified, returns rules within the group.")]
+ public string? Group { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var session = GetSession();
+ var service = new FirewallService();
+
+ if (!string.IsNullOrEmpty(Group))
+ {
+ WriteVerbose($"Getting firewall rules for security group '{Group}'...");
+ var rules = service.GetGroupRules(session, Group!);
+ WriteObject(rules, true);
+ }
+ else
+ {
+ WriteVerbose("Getting firewall security groups...");
+ var groups = service.GetGroups(session);
+ WriteObject(groups, true);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetCmdlet.cs
new file mode 100644
index 0000000..f1ac250
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetCmdlet.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Linq;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Get, "PveFirewallIpSet")]
+ [OutputType(typeof(PveFirewallIpSet))]
+ public class GetPveFirewallIpSetCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional IP set name to filter by.")]
+ public string? Name { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Getting firewall IP sets at level '{level}'...");
+ var ipSets = service.GetIpSets(session, level, Node, vmid);
+
+ if (!string.IsNullOrEmpty(Name))
+ {
+ var filtered = ipSets.Where(s => string.Equals(s.Name, Name, StringComparison.OrdinalIgnoreCase)).ToArray();
+ WriteObject(filtered, true);
+ }
+ else
+ {
+ WriteObject(ipSets, true);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetEntryCmdlet.cs
new file mode 100644
index 0000000..822449b
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallIpSetEntryCmdlet.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Get, "PveFirewallIpSetEntry")]
+ [OutputType(typeof(PveFirewallIpSetEntry))]
+ public class GetPveFirewallIpSetEntryCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The IP set name.")]
+ public string Name { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Getting entries for IP set '{Name}' at level '{level}'...");
+ var entries = service.GetIpSetEntries(session, level, Name, Node, vmid);
+ WriteObject(entries, true);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallOptionsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallOptionsCmdlet.cs
new file mode 100644
index 0000000..f2b91fb
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallOptionsCmdlet.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Get, "PveFirewallOptions")]
+ [OutputType(typeof(PveFirewallOptions))]
+ public class GetPveFirewallOptionsCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Getting firewall options at level '{level}'...");
+ var options = service.GetOptions(session, level, Node, vmid);
+ WriteObject(options);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRefCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRefCmdlet.cs
new file mode 100644
index 0000000..ce5b87f
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRefCmdlet.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Get, "PveFirewallRef")]
+ [OutputType(typeof(PveFirewallRef))]
+ public class GetPveFirewallRefCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional type filter for references.")]
+ public string? Type { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Getting firewall references at level '{level}'...");
+ var refs = service.GetRefs(session, level, Type, Node, vmid);
+ WriteObject(refs, true);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRuleCmdlet.cs
new file mode 100644
index 0000000..76e5ad6
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/GetPveFirewallRuleCmdlet.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Linq;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Get, "PveFirewallRule")]
+ [OutputType(typeof(PveFirewallRule))]
+ public class GetPveFirewallRuleCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional rule position to filter by.")]
+ public int? Position { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Getting firewall rules at level '{level}'...");
+ var rules = service.GetRules(session, level, Node, vmid);
+
+ if (Position.HasValue)
+ {
+ var filtered = rules.Where(r => r.Pos == Position.Value).ToArray();
+ WriteObject(filtered, true);
+ }
+ else
+ {
+ WriteObject(rules, true);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallAliasCmdlet.cs
new file mode 100644
index 0000000..1ca6fc8
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallAliasCmdlet.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.New, "PveFirewallAlias", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class NewPveFirewallAliasCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The alias name.")]
+ public string Name { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = true, HelpMessage = "The CIDR network address.")]
+ public string Cidr { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional comment for the alias.")]
+ public string? Comment { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall alias '{Name}' ({Level})", "Create"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Creating firewall alias '{Name}' at level '{level}'...");
+ service.CreateAlias(session, level, Name, Cidr, Comment, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallGroupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallGroupCmdlet.cs
new file mode 100644
index 0000000..1f68ba4
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallGroupCmdlet.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.New, "PveFirewallGroup", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class NewPveFirewallGroupCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The security group name.")]
+ public string Group { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional comment for the security group.")]
+ public string? Comment { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"firewall security group '{Group}'", "Create"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+
+ WriteVerbose($"Creating firewall security group '{Group}'...");
+ service.CreateGroup(session, Group, Comment);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetCmdlet.cs
new file mode 100644
index 0000000..a11c282
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetCmdlet.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.New, "PveFirewallIpSet", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class NewPveFirewallIpSetCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The IP set name.")]
+ public string Name { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional comment for the IP set.")]
+ public string? Comment { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall IP set '{Name}' ({Level})", "Create"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Creating firewall IP set '{Name}' at level '{level}'...");
+ service.CreateIpSet(session, level, Name, Comment, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetEntryCmdlet.cs
new file mode 100644
index 0000000..19dba6b
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallIpSetEntryCmdlet.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.New, "PveFirewallIpSetEntry", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class NewPveFirewallIpSetEntryCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The IP set name.")]
+ public string Name { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = true, HelpMessage = "The CIDR network address to add.")]
+ public string Cidr { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "Exclude this entry from the IP set match.")]
+ public SwitchParameter NoMatch { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Optional comment for the entry.")]
+ public string? Comment { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"IP set entry '{Cidr}' in '{Name}' ({Level})", "Create"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Adding entry '{Cidr}' to IP set '{Name}' at level '{level}'...");
+ service.AddIpSetEntry(session, level, Name, Cidr, NoMatch.IsPresent, Comment, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallRuleCmdlet.cs
new file mode 100644
index 0000000..94c1bf2
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/NewPveFirewallRuleCmdlet.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.New, "PveFirewallRule", SupportsShouldProcess = true)]
+ [OutputType(typeof(PveFirewallRule))]
+ public class NewPveFirewallRuleCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The rule type: in, out, or group.")]
+ [ValidateSet("in", "out", "group")]
+ public string Type { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = true, HelpMessage = "The rule action: ACCEPT, DROP, or REJECT.")]
+ [ValidateSet("ACCEPT", "DROP", "REJECT")]
+ public string Action { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "Enable the rule.")]
+ public SwitchParameter Enable { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Source address or alias.")]
+ public string? Source { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Destination address or alias.")]
+ public string? Dest { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Protocol (e.g., tcp, udp, icmp).")]
+ public string? Proto { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Destination port.")]
+ public string? Dport { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Source port.")]
+ public string? Sport { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Rule comment.")]
+ public string? Comment { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Macro name.")]
+ public string? Macro { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Log level.")]
+ public string? Log { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Network interface.")]
+ public string? Iface { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall rule ({Level})", "Create"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ var config = new Dictionary
+ {
+ ["type"] = Type,
+ ["action"] = Action
+ };
+
+ if (Enable.IsPresent)
+ config["enable"] = "1";
+ if (!string.IsNullOrEmpty(Source))
+ config["source"] = Source!;
+ if (!string.IsNullOrEmpty(Dest))
+ config["dest"] = Dest!;
+ if (!string.IsNullOrEmpty(Proto))
+ config["proto"] = Proto!;
+ if (!string.IsNullOrEmpty(Dport))
+ config["dport"] = Dport!;
+ if (!string.IsNullOrEmpty(Sport))
+ config["sport"] = Sport!;
+ if (!string.IsNullOrEmpty(Comment))
+ config["comment"] = Comment!;
+ if (!string.IsNullOrEmpty(Macro))
+ config["macro"] = Macro!;
+ if (!string.IsNullOrEmpty(Log))
+ config["log"] = Log!;
+ if (!string.IsNullOrEmpty(Iface))
+ config["iface"] = Iface!;
+
+ WriteVerbose($"Creating firewall rule at level '{level}'...");
+ service.CreateRule(session, level, config, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallAliasCmdlet.cs
new file mode 100644
index 0000000..6ddc16d
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallAliasCmdlet.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Remove, "PveFirewallAlias", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class RemovePveFirewallAliasCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The alias name to remove.")]
+ public string Name { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall alias '{Name}' ({Level})", "Remove"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Removing firewall alias '{Name}' at level '{level}'...");
+ service.RemoveAlias(session, level, Name, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallGroupCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallGroupCmdlet.cs
new file mode 100644
index 0000000..2e24dc2
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallGroupCmdlet.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Remove, "PveFirewallGroup", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class RemovePveFirewallGroupCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The security group name to remove.")]
+ public string Group { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"firewall security group '{Group}'", "Remove"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+
+ WriteVerbose($"Removing firewall security group '{Group}'...");
+ service.RemoveGroup(session, Group);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetCmdlet.cs
new file mode 100644
index 0000000..a7ae1e3
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetCmdlet.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Remove, "PveFirewallIpSet", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class RemovePveFirewallIpSetCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The IP set name to remove.")]
+ public string Name { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall IP set '{Name}' ({Level})", "Remove"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Removing firewall IP set '{Name}' at level '{level}'...");
+ service.RemoveIpSet(session, level, Name, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetEntryCmdlet.cs
new file mode 100644
index 0000000..97da0c6
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallIpSetEntryCmdlet.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Remove, "PveFirewallIpSetEntry", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class RemovePveFirewallIpSetEntryCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The IP set name.")]
+ public string Name { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = true, HelpMessage = "The CIDR network address to remove.")]
+ public string Cidr { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"IP set entry '{Cidr}' from '{Name}' ({Level})", "Remove"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Removing entry '{Cidr}' from IP set '{Name}' at level '{level}'...");
+ service.RemoveIpSetEntry(session, level, Name, Cidr, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallRuleCmdlet.cs
new file mode 100644
index 0000000..5888161
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/RemovePveFirewallRuleCmdlet.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Remove, "PveFirewallRule", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ [OutputType(typeof(void))]
+ public class RemovePveFirewallRuleCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The rule position to remove.")]
+ public int Position { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall rule at position {Position} ({Level})", "Remove"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Removing firewall rule at position {Position} ({level})...");
+ service.RemoveRule(session, level, Position, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallAliasCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallAliasCmdlet.cs
new file mode 100644
index 0000000..3eea70e
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallAliasCmdlet.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Set, "PveFirewallAlias", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class SetPveFirewallAliasCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The alias name to update.")]
+ public string Name { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The updated CIDR network address.")]
+ public string? Cidr { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Updated comment for the alias.")]
+ public string? Comment { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall alias '{Name}' ({Level})", "Update"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ WriteVerbose($"Updating firewall alias '{Name}' at level '{level}'...");
+ service.UpdateAlias(session, level, Name, Cidr, Comment, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallIpSetEntryCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallIpSetEntryCmdlet.cs
new file mode 100644
index 0000000..d178cc7
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallIpSetEntryCmdlet.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Set, "PveFirewallIpSetEntry", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class SetPveFirewallIpSetEntryCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The IP set name.")]
+ public string Name { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = true, HelpMessage = "The CIDR network address to update.")]
+ public string Cidr { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "Exclude this entry from the IP set match.")]
+ public SwitchParameter NoMatch { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Updated comment for the entry.")]
+ public string? Comment { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"IP set entry '{Cidr}' in '{Name}' ({Level})", "Update"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+ bool? nomatch = NoMatch.IsPresent ? true : (bool?)null;
+
+ WriteVerbose($"Updating entry '{Cidr}' in IP set '{Name}' at level '{level}'...");
+ service.UpdateIpSetEntry(session, level, Name, Cidr, nomatch, Comment, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallOptionsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallOptionsCmdlet.cs
new file mode 100644
index 0000000..46ae7d7
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallOptionsCmdlet.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Set, "PveFirewallOptions", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class SetPveFirewallOptionsCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Enable or disable the firewall.")]
+ public SwitchParameter Enable { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Default policy for incoming traffic: ACCEPT, DROP, or REJECT.")]
+ [ValidateSet("ACCEPT", "DROP", "REJECT")]
+ public string? PolicyIn { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Default policy for outgoing traffic: ACCEPT, DROP, or REJECT.")]
+ [ValidateSet("ACCEPT", "DROP", "REJECT")]
+ public string? PolicyOut { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Log level for incoming traffic.")]
+ public string? LogLevelIn { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Log level for outgoing traffic.")]
+ public string? LogLevelOut { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Enable DHCP.")]
+ public SwitchParameter Dhcp { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Enable NDP.")]
+ public SwitchParameter Ndp { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Enable MAC address filter.")]
+ public SwitchParameter MacFilter { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Enable IP filter.")]
+ public SwitchParameter IpFilter { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall options ({Level})", "Update"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ var config = new Dictionary();
+
+ if (Enable.IsPresent)
+ config["enable"] = "1";
+ if (!string.IsNullOrEmpty(PolicyIn))
+ config["policy_in"] = PolicyIn!;
+ if (!string.IsNullOrEmpty(PolicyOut))
+ config["policy_out"] = PolicyOut!;
+ if (!string.IsNullOrEmpty(LogLevelIn))
+ config["log_level_in"] = LogLevelIn!;
+ if (!string.IsNullOrEmpty(LogLevelOut))
+ config["log_level_out"] = LogLevelOut!;
+ if (Dhcp.IsPresent)
+ config["dhcp"] = "1";
+ if (Ndp.IsPresent)
+ config["ndp"] = "1";
+ if (MacFilter.IsPresent)
+ config["macfilter"] = "1";
+ if (IpFilter.IsPresent)
+ config["ipfilter"] = "1";
+
+ WriteVerbose($"Setting firewall options at level '{level}'...");
+ service.SetOptions(session, level, config, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallRuleCmdlet.cs
new file mode 100644
index 0000000..e65bd81
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Firewall/SetPveFirewallRuleCmdlet.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Firewall;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Firewall
+{
+ [Cmdlet(VerbsCommon.Set, "PveFirewallRule", SupportsShouldProcess = true)]
+ [OutputType(typeof(void))]
+ public class SetPveFirewallRuleCmdlet : PveCmdletBase
+ {
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The firewall level: Cluster, Node, Vm, or Container.")]
+ [ValidateSet("Cluster", "Node", "Vm", "Container")]
+ public string Level { get; set; } = string.Empty;
+
+ [Parameter(Mandatory = false, HelpMessage = "The node name. Required when Level is Node, Vm, or Container.")]
+ public string? Node { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The VM/Container ID. Required when Level is Vm or Container.")]
+ [ValidateRange(100, 999999999)]
+ public int VmId { get; set; }
+
+ [Parameter(Mandatory = true, HelpMessage = "The rule position to update.")]
+ public int Position { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The rule type: in, out, or group.")]
+ [ValidateSet("in", "out", "group")]
+ public string? Type { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "The rule action: ACCEPT, DROP, or REJECT.")]
+ [ValidateSet("ACCEPT", "DROP", "REJECT")]
+ public string? Action { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Enable the rule.")]
+ public SwitchParameter Enable { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Source address or alias.")]
+ public string? Source { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Destination address or alias.")]
+ public string? Dest { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Protocol (e.g., tcp, udp, icmp).")]
+ public string? Proto { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Destination port.")]
+ public string? Dport { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Source port.")]
+ public string? Sport { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Rule comment.")]
+ public string? Comment { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Macro name.")]
+ public string? Macro { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Log level.")]
+ public string? Log { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Network interface.")]
+ public string? Iface { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var level = Level;
+ if (!string.Equals(level, "Cluster", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(Node))
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("Node is required when Level is not Cluster."),
+ "NodeRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+ if (string.Equals(level, "Vm", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(level, "Container", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VmId == 0)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ new PSArgumentException("VmId is required when Level is Vm or Container."),
+ "VmIdRequired", ErrorCategory.InvalidArgument, null));
+ return;
+ }
+ }
+
+ if (!ShouldProcess($"firewall rule at position {Position} ({Level})", "Update"))
+ return;
+
+ var session = GetSession();
+ var service = new FirewallService();
+ int? vmid = VmId > 0 ? VmId : (int?)null;
+
+ var config = new Dictionary();
+
+ if (!string.IsNullOrEmpty(Type))
+ config["type"] = Type!;
+ if (!string.IsNullOrEmpty(Action))
+ config["action"] = Action!;
+ if (Enable.IsPresent)
+ config["enable"] = "1";
+ if (!string.IsNullOrEmpty(Source))
+ config["source"] = Source!;
+ if (!string.IsNullOrEmpty(Dest))
+ config["dest"] = Dest!;
+ if (!string.IsNullOrEmpty(Proto))
+ config["proto"] = Proto!;
+ if (!string.IsNullOrEmpty(Dport))
+ config["dport"] = Dport!;
+ if (!string.IsNullOrEmpty(Sport))
+ config["sport"] = Sport!;
+ if (!string.IsNullOrEmpty(Comment))
+ config["comment"] = Comment!;
+ if (!string.IsNullOrEmpty(Macro))
+ config["macro"] = Macro!;
+ if (!string.IsNullOrEmpty(Log))
+ config["log"] = Log!;
+ if (!string.IsNullOrEmpty(Iface))
+ config["iface"] = Iface!;
+
+ WriteVerbose($"Updating firewall rule at position {Position} ({level})...");
+ service.UpdateRule(session, level, Position, config, Node, vmid);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnControllerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnControllerCmdlet.cs
new file mode 100644
index 0000000..62f6cc7
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnControllerCmdlet.cs
@@ -0,0 +1,41 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Network;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Lists SDN controllers in Proxmox VE.
+ ///
+ /// Returns Software-Defined Networking controller definitions.
+ /// Optionally filters by controller identifier.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Get, "PveSdnController")]
+ [OutputType(typeof(PveSdnController))]
+ public class GetPveSdnControllerCmdlet : PveCmdletBase
+ {
+ /// Optional controller identifier filter.
+ [Parameter(Mandatory = false, Position = 0, HelpMessage = "Filter by controller identifier.")]
+ public string? Controller { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose("Getting SDN controllers...");
+ var controllers = service.GetSdnControllers(session);
+
+ foreach (var item in controllers)
+ {
+ if (!string.IsNullOrEmpty(Controller) &&
+ !string.Equals(item.Controller, Controller, System.StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ WriteObject(item);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnDnsCmdlet.cs
new file mode 100644
index 0000000..5eb6fc5
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnDnsCmdlet.cs
@@ -0,0 +1,41 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Network;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Lists SDN DNS plugins in Proxmox VE.
+ ///
+ /// Returns Software-Defined Networking DNS plugin definitions.
+ /// Optionally filters by DNS identifier.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Get, "PveSdnDns")]
+ [OutputType(typeof(PveSdnDns))]
+ public class GetPveSdnDnsCmdlet : PveCmdletBase
+ {
+ /// Optional DNS plugin identifier filter.
+ [Parameter(Mandatory = false, Position = 0, HelpMessage = "Filter by DNS plugin identifier.")]
+ public string? Dns { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose("Getting SDN DNS plugins...");
+ var plugins = service.GetSdnDnsPlugins(session);
+
+ foreach (var item in plugins)
+ {
+ if (!string.IsNullOrEmpty(Dns) &&
+ !string.Equals(item.Dns, Dns, System.StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ WriteObject(item);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnIpamCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnIpamCmdlet.cs
new file mode 100644
index 0000000..1898587
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnIpamCmdlet.cs
@@ -0,0 +1,41 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Network;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Lists SDN IPAM plugins in Proxmox VE.
+ ///
+ /// Returns Software-Defined Networking IPAM plugin definitions.
+ /// Optionally filters by IPAM identifier.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Get, "PveSdnIpam")]
+ [OutputType(typeof(PveSdnIpam))]
+ public class GetPveSdnIpamCmdlet : PveCmdletBase
+ {
+ /// Optional IPAM identifier filter.
+ [Parameter(Mandatory = false, Position = 0, HelpMessage = "Filter by IPAM plugin identifier.")]
+ public string? Ipam { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose("Getting SDN IPAM plugins...");
+ var ipams = service.GetSdnIpams(session);
+
+ foreach (var item in ipams)
+ {
+ if (!string.IsNullOrEmpty(Ipam) &&
+ !string.Equals(item.Ipam, Ipam, System.StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ WriteObject(item);
+ }
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnControllerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnControllerCmdlet.cs
new file mode 100644
index 0000000..3faa7f2
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnControllerCmdlet.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Creates a new SDN controller in Proxmox VE.
+ ///
+ /// Adds a new Software-Defined Networking controller.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.New, "PveSdnController", SupportsShouldProcess = true)]
+ public class NewPveSdnControllerCmdlet : PveCmdletBase
+ {
+ /// The controller identifier.
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The controller identifier.")]
+ public string Controller { get; set; } = string.Empty;
+
+ /// The controller type.
+ [Parameter(Mandatory = true, Position = 1, HelpMessage = "The controller type.")]
+ [ValidateSet("evpn", "bgp")]
+ public string Type { get; set; } = string.Empty;
+
+ /// The Autonomous System Number for BGP/EVPN.
+ [Parameter(Mandatory = false, HelpMessage = "The Autonomous System Number for BGP/EVPN.")]
+ public int? Asn { get; set; }
+
+ /// Comma-separated list of BGP peer addresses.
+ [Parameter(Mandatory = false, HelpMessage = "Comma-separated list of BGP peer addresses.")]
+ public string? Peers { get; set; }
+
+ /// The node this controller is configured on.
+ [Parameter(Mandatory = false, HelpMessage = "The node this controller is configured on.")]
+ public string? Node { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"SDN Controller '{Controller}'", "Create PVE SDN Controller"))
+ return;
+
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose($"Creating SDN controller '{Controller}' of type '{Type}'...");
+ var data = new Dictionary
+ {
+ ["controller"] = Controller,
+ ["type"] = Type
+ };
+
+ if (Asn.HasValue) data["asn"] = Asn.Value.ToString();
+ if (!string.IsNullOrEmpty(Peers)) data["peers"] = Peers!;
+ if (!string.IsNullOrEmpty(Node)) data["node"] = Node!;
+
+ service.CreateSdnController(session, data);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnDnsCmdlet.cs
new file mode 100644
index 0000000..e00a164
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnDnsCmdlet.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Creates a new SDN DNS plugin in Proxmox VE.
+ ///
+ /// Adds a new Software-Defined Networking DNS plugin.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.New, "PveSdnDns", SupportsShouldProcess = true)]
+ public class NewPveSdnDnsCmdlet : PveCmdletBase
+ {
+ /// The DNS plugin identifier.
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The DNS plugin identifier.")]
+ public string Dns { get; set; } = string.Empty;
+
+ /// The DNS plugin type.
+ [Parameter(Mandatory = true, Position = 1, HelpMessage = "The DNS plugin type.")]
+ [ValidateSet("powerdns")]
+ public string Type { get; set; } = string.Empty;
+
+ /// The URL of the DNS service API.
+ [Parameter(Mandatory = true, HelpMessage = "The URL of the DNS service API.")]
+ public string Url { get; set; } = string.Empty;
+
+ /// The API key for the DNS service.
+ [Parameter(Mandatory = true, HelpMessage = "The API key for the DNS service.")]
+ public string Key { get; set; } = string.Empty;
+
+ /// The IPv6 reverse zone mask length.
+ [Parameter(Mandatory = false, HelpMessage = "The IPv6 reverse zone mask length.")]
+ public int? ReverseMaskV6 { get; set; }
+
+ /// The TTL (time-to-live) for DNS records.
+ [Parameter(Mandatory = false, HelpMessage = "The TTL (time-to-live) for DNS records.")]
+ public int? Ttl { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"SDN DNS '{Dns}'", "Create PVE SDN DNS"))
+ return;
+
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose($"Creating SDN DNS plugin '{Dns}' of type '{Type}'...");
+ var data = new Dictionary
+ {
+ ["dns"] = Dns,
+ ["type"] = Type,
+ ["url"] = Url,
+ ["key"] = Key
+ };
+
+ if (ReverseMaskV6.HasValue) data["reversemaskv6"] = ReverseMaskV6.Value.ToString();
+ if (Ttl.HasValue) data["ttl"] = Ttl.Value.ToString();
+
+ service.CreateSdnDnsPlugin(session, data);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnIpamCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnIpamCmdlet.cs
new file mode 100644
index 0000000..ff666d6
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnIpamCmdlet.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Creates a new SDN IPAM plugin in Proxmox VE.
+ ///
+ /// Adds a new Software-Defined Networking IPAM plugin.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.New, "PveSdnIpam", SupportsShouldProcess = true)]
+ public class NewPveSdnIpamCmdlet : PveCmdletBase
+ {
+ /// The IPAM plugin identifier.
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "The IPAM plugin identifier.")]
+ public string Ipam { get; set; } = string.Empty;
+
+ /// The IPAM plugin type.
+ [Parameter(Mandatory = true, Position = 1, HelpMessage = "The IPAM plugin type.")]
+ [ValidateSet("pve", "netbox", "phpipam")]
+ public string Type { get; set; } = string.Empty;
+
+ /// The URL of the external IPAM service.
+ [Parameter(Mandatory = false, HelpMessage = "The URL of the external IPAM service.")]
+ public string? Url { get; set; }
+
+ /// The API token for the external IPAM service.
+ [Parameter(Mandatory = false, HelpMessage = "The API token for the external IPAM service.")]
+ public string? Token { get; set; }
+
+ /// The configuration section identifier.
+ [Parameter(Mandatory = false, HelpMessage = "The configuration section identifier.")]
+ public int? Section { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"SDN IPAM '{Ipam}'", "Create PVE SDN IPAM"))
+ return;
+
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose($"Creating SDN IPAM plugin '{Ipam}' of type '{Type}'...");
+ var data = new Dictionary
+ {
+ ["ipam"] = Ipam,
+ ["type"] = Type
+ };
+
+ if (!string.IsNullOrEmpty(Url)) data["url"] = Url!;
+ if (!string.IsNullOrEmpty(Token)) data["token"] = Token!;
+ if (Section.HasValue) data["section"] = Section.Value.ToString();
+
+ service.CreateSdnIpam(session, data);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnControllerCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnControllerCmdlet.cs
new file mode 100644
index 0000000..dfc85a1
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnControllerCmdlet.cs
@@ -0,0 +1,32 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Removes an SDN controller from Proxmox VE.
+ ///
+ /// Deletes the specified Software-Defined Networking controller.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Remove, "PveSdnController", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ public class RemovePveSdnControllerCmdlet : PveCmdletBase
+ {
+ /// The controller identifier to remove.
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The controller identifier to remove.")]
+ public string Controller { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"SDN Controller '{Controller}'", "Remove PVE SDN Controller"))
+ return;
+
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose($"Removing SDN controller '{Controller}'...");
+ service.RemoveSdnController(session, Controller);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnDnsCmdlet.cs
new file mode 100644
index 0000000..508934c
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnDnsCmdlet.cs
@@ -0,0 +1,32 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Removes an SDN DNS plugin from Proxmox VE.
+ ///
+ /// Deletes the specified Software-Defined Networking DNS plugin.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Remove, "PveSdnDns", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ public class RemovePveSdnDnsCmdlet : PveCmdletBase
+ {
+ /// The DNS plugin identifier to remove.
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The DNS plugin identifier to remove.")]
+ public string Dns { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"SDN DNS '{Dns}'", "Remove PVE SDN DNS"))
+ return;
+
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose($"Removing SDN DNS plugin '{Dns}'...");
+ service.RemoveSdnDnsPlugin(session, Dns);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnIpamCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnIpamCmdlet.cs
new file mode 100644
index 0000000..b9fa9fa
--- /dev/null
+++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnIpamCmdlet.cs
@@ -0,0 +1,32 @@
+using System.Management.Automation;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Cmdlets.Network
+{
+ ///
+ /// Removes an SDN IPAM plugin from Proxmox VE.
+ ///
+ /// Deletes the specified Software-Defined Networking IPAM plugin.
+ /// Requires Proxmox VE 8.0 or later.
+ ///
+ ///
+ [Cmdlet(VerbsCommon.Remove, "PveSdnIpam", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
+ public class RemovePveSdnIpamCmdlet : PveCmdletBase
+ {
+ /// The IPAM plugin identifier to remove.
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, HelpMessage = "The IPAM plugin identifier to remove.")]
+ public string Ipam { get; set; } = string.Empty;
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess($"SDN IPAM '{Ipam}'", "Remove PVE SDN IPAM"))
+ return;
+
+ var session = GetSession();
+ var service = new NetworkService();
+
+ WriteVerbose($"Removing SDN IPAM plugin '{Ipam}'...");
+ service.RemoveSdnIpam(session, Ipam);
+ }
+ }
+}
diff --git a/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs
index c627068..0770e8c 100644
--- a/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Users/RemovePveRoleCmdlet.cs
@@ -10,7 +10,7 @@ namespace PSProxmoxVE.Cmdlets.Users
/// Built-in roles cannot be removed.
///
///
- [Cmdlet(VerbsCommon.Remove, "PveRole", SupportsShouldProcess = true)]
+ [Cmdlet(VerbsCommon.Remove, "PveRole", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
public class RemovePveRoleCmdlet : PveCmdletBase
{
/// The role identifier to remove.
diff --git a/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml b/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml
index 90e7cf9..c0c2c79 100644
--- a/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml
+++ b/src/PSProxmoxVE/PSProxmoxVE.format.ps1xml
@@ -442,5 +442,507 @@
+
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallRule
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallRule
+
+
+
+
+
+ 5
+ Right
+
+
+
+ 6
+ Left
+
+
+
+ 8
+ Left
+
+
+
+ 6
+ Left
+
+
+
+ 12
+ Left
+
+
+
+ 20
+ Left
+
+
+
+ 20
+ Left
+
+
+
+ 7
+ Left
+
+
+
+ Left
+
+
+
+
+
+
+ Pos
+
+
+ Type
+
+
+ Action
+
+
+ Proto
+
+
+ Dport
+
+
+ Source
+
+
+ Dest
+
+
+ if ($_.Enable -eq 1) { 'Yes' } else { 'No' }
+
+
+ Comment
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallGroup
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallGroup
+
+
+
+
+
+ 25
+ Left
+
+
+
+ Left
+
+
+
+
+
+
+ Group
+
+
+ Comment
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallAlias
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallAlias
+
+
+
+
+
+ 25
+ Left
+
+
+
+ 25
+ Left
+
+
+
+ Left
+
+
+
+
+
+
+ Name
+
+
+ Cidr
+
+
+ Comment
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSet
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSet
+
+
+
+
+
+ 25
+ Left
+
+
+
+ Left
+
+
+
+
+
+
+ Name
+
+
+ Comment
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSetEntry
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallIpSetEntry
+
+
+
+
+
+ 25
+ Left
+
+
+
+ 8
+ Left
+
+
+
+ Left
+
+
+
+
+
+
+ Cidr
+
+
+ if ($_.NoMatch -eq 1) { 'Yes' } else { 'No' }
+
+
+ Comment
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallOptions
+
+ PSProxmoxVE.Core.Models.Firewall.PveFirewallOptions
+
+
+
+
+
+ 8
+ Left
+
+
+
+ 10
+ Left
+
+
+
+ 10
+ Left
+
+
+
+ 12
+ Left
+
+
+
+ 12
+ Left
+
+
+
+
+
+
+ if ($_.Enable -eq 1) { 'Yes' } else { 'No' }
+
+
+ PolicyIn
+
+
+ PolicyOut
+
+
+ LogLevelIn
+
+
+ LogLevelOut
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Backup.PveBackupJob
+
+ PSProxmoxVE.Core.Models.Backup.PveBackupJob
+
+
+
+
+
+ 20
+ Left
+
+
+
+ 8
+ Left
+
+
+
+ 16
+ Left
+
+
+
+ 15
+ Left
+
+
+
+ 10
+ Left
+
+
+
+ 15
+ Left
+
+
+
+ Left
+
+
+
+
+
+
+ Id
+
+
+ if ($_.Enabled -eq 1) { 'Yes' } else { 'No' }
+
+
+ Schedule
+
+
+ Storage
+
+
+ Mode
+
+
+ VmId
+
+
+ Comment
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Network.PveSdnIpam
+
+ PSProxmoxVE.Core.Models.Network.PveSdnIpam
+
+
+
+
+
+ 20
+ Left
+
+
+
+ 15
+ Left
+
+
+
+
+
+
+ Ipam
+
+
+ Type
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Network.PveSdnDns
+
+ PSProxmoxVE.Core.Models.Network.PveSdnDns
+
+
+
+
+
+ 20
+ Left
+
+
+
+ 15
+ Left
+
+
+
+ 40
+ Left
+
+
+
+
+
+
+ Dns
+
+
+ Type
+
+
+ Url
+
+
+
+
+
+
+
+
+
+ PSProxmoxVE.Core.Models.Network.PveSdnController
+
+ PSProxmoxVE.Core.Models.Network.PveSdnController
+
+
+
+
+
+ 20
+ Left
+
+
+
+ 10
+ Left
+
+
+
+ 10
+ Right
+
+
+
+ 30
+ Left
+
+
+
+ 15
+ Left
+
+
+
+
+
+
+ Controller
+
+
+ Type
+
+
+ Asn
+
+
+ Peers
+
+
+ Node
+
+
+
+
+
+
+
diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1
index 3d43515..6bcc6fa 100644
--- a/src/PSProxmoxVE/PSProxmoxVE.psd1
+++ b/src/PSProxmoxVE/PSProxmoxVE.psd1
@@ -170,7 +170,52 @@
# Tasks
'Get-PveTask',
- 'Wait-PveTask'
+ 'Wait-PveTask',
+
+ # Firewall
+ 'Get-PveFirewallRule',
+ 'New-PveFirewallRule',
+ 'Set-PveFirewallRule',
+ 'Remove-PveFirewallRule',
+ 'Get-PveFirewallGroup',
+ 'New-PveFirewallGroup',
+ 'Remove-PveFirewallGroup',
+ 'Get-PveFirewallAlias',
+ 'New-PveFirewallAlias',
+ 'Set-PveFirewallAlias',
+ 'Remove-PveFirewallAlias',
+ 'Get-PveFirewallIpSet',
+ 'New-PveFirewallIpSet',
+ 'Remove-PveFirewallIpSet',
+ 'Get-PveFirewallIpSetEntry',
+ 'New-PveFirewallIpSetEntry',
+ 'Set-PveFirewallIpSetEntry',
+ 'Remove-PveFirewallIpSetEntry',
+ 'Get-PveFirewallOptions',
+ 'Set-PveFirewallOptions',
+ 'Get-PveFirewallRef',
+
+ # Backup
+ 'New-PveBackup',
+ 'Get-PveBackupJob',
+ 'New-PveBackupJob',
+ 'Set-PveBackupJob',
+ 'Remove-PveBackupJob',
+
+ # SDN — IPAM
+ 'Get-PveSdnIpam',
+ 'New-PveSdnIpam',
+ 'Remove-PveSdnIpam',
+
+ # SDN — DNS
+ 'Get-PveSdnDns',
+ 'New-PveSdnDns',
+ 'Remove-PveSdnDns',
+
+ # SDN — Controller
+ 'Get-PveSdnController',
+ 'New-PveSdnController',
+ 'Remove-PveSdnController'
)
# Variables to export from this module
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_backup_jobs.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_backup_jobs.json
new file mode 100644
index 0000000..c0faf3a
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_backup_jobs.json
@@ -0,0 +1 @@
+{"data":[{"id":"backup-1a2b3c","type":"vzdump","enabled":1,"schedule":"0 2 * * *","storage":"local","mode":"snapshot","compress":"zstd","vmid":"100,101","comment":"Nightly backup"},{"id":"backup-4d5e6f","type":"vzdump","enabled":0,"schedule":"0 4 * * 0","storage":"nfs-backup","mode":"stop","all":1,"comment":"Weekly full"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_aliases.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_aliases.json
new file mode 100644
index 0000000..a0ffdd7
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_aliases.json
@@ -0,0 +1 @@
+{"data":[{"name":"local_network","cidr":"10.0.0.0/8","comment":"RFC1918 private"},{"name":"dns_server","cidr":"8.8.8.8","comment":"Google DNS"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_groups.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_groups.json
new file mode 100644
index 0000000..8b1bf90
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_groups.json
@@ -0,0 +1 @@
+{"data":[{"group":"webservers","comment":"HTTP/HTTPS servers","digest":"abc123"},{"group":"dbservers","comment":"Database servers","digest":"def456"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipset_entries.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipset_entries.json
new file mode 100644
index 0000000..ffc3941
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipset_entries.json
@@ -0,0 +1 @@
+{"data":[{"cidr":"192.168.1.0/24","nomatch":0,"comment":"Local subnet"},{"cidr":"10.0.0.0/8","nomatch":1,"comment":"Exclude private"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipsets.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipsets.json
new file mode 100644
index 0000000..ba5f149
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_ipsets.json
@@ -0,0 +1 @@
+{"data":[{"name":"blocklist","comment":"Blocked IPs","digest":"aaa111"},{"name":"allowlist","comment":"Allowed IPs","digest":"bbb222"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_options.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_options.json
new file mode 100644
index 0000000..ddad993
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_options.json
@@ -0,0 +1 @@
+{"data":{"enable":1,"policy_in":"DROP","policy_out":"ACCEPT","log_level_in":"nolog","log_level_out":"nolog"}}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_refs.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_refs.json
new file mode 100644
index 0000000..fe3e9f9
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_refs.json
@@ -0,0 +1 @@
+{"data":[{"type":"alias","name":"local_network","comment":"RFC1918 private"},{"type":"ipset","name":"blocklist","comment":"Blocked IPs"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_rules.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_rules.json
new file mode 100644
index 0000000..63110b9
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_firewall_rules.json
@@ -0,0 +1 @@
+{"data":[{"pos":0,"type":"in","action":"ACCEPT","proto":"tcp","dport":"80","enable":1,"comment":"Allow HTTP","log":"nolog"},{"pos":1,"type":"in","action":"ACCEPT","proto":"tcp","dport":"443","enable":1,"comment":"Allow HTTPS"},{"pos":2,"type":"out","action":"DROP","enable":0,"comment":"Block outbound (disabled)"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_controllers.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_controllers.json
new file mode 100644
index 0000000..1b0d846
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_controllers.json
@@ -0,0 +1 @@
+{"data":[{"controller":"evpn1","type":"evpn","asn":65000,"peers":"10.0.0.1,10.0.0.2","node":"pve1"}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_dns.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_dns.json
new file mode 100644
index 0000000..f9c3461
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_dns.json
@@ -0,0 +1 @@
+{"data":[{"dns":"powerdns1","type":"powerdns","url":"http://dns.example.com:8081","key":"secret123","ttl":3600}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_ipams.json b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_ipams.json
new file mode 100644
index 0000000..ef3cc23
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Fixtures/pve9_sdn_ipams.json
@@ -0,0 +1 @@
+{"data":[{"ipam":"pve","type":"pve"},{"ipam":"netbox1","type":"netbox","url":"https://netbox.example.com","token":"abc123","section":1}]}
\ No newline at end of file
diff --git a/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs
new file mode 100644
index 0000000..d7671f5
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Models/BackupModelTests.cs
@@ -0,0 +1,126 @@
+using Xunit;
+using Newtonsoft.Json.Linq;
+using PSProxmoxVE.Core.Models.Backup;
+
+namespace PSProxmoxVE.Core.Tests.Models
+{
+ public class BackupModelTests
+ {
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Equal(2, jobs.Length);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_FirstJob_HasCorrectId()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Equal("backup-1a2b3c", jobs[0].Id);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_FirstJob_IsEnabled()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Equal(1, jobs[0].Enabled);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_FirstJob_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Equal("vzdump", jobs[0].Type);
+ Assert.Equal("0 2 * * *", jobs[0].Schedule);
+ Assert.Equal("local", jobs[0].Storage);
+ Assert.Equal("snapshot", jobs[0].Mode);
+ Assert.Equal("zstd", jobs[0].Compress);
+ Assert.Equal("100,101", jobs[0].VmId);
+ Assert.Equal("Nightly backup", jobs[0].Comment);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_FirstJob_AllIsNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Null(jobs[0].All);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_SecondJob_IsDisabled()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Equal(0, jobs[1].Enabled);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_SecondJob_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Equal("backup-4d5e6f", jobs[1].Id);
+ Assert.Equal("vzdump", jobs[1].Type);
+ Assert.Equal("0 4 * * 0", jobs[1].Schedule);
+ Assert.Equal("nfs-backup", jobs[1].Storage);
+ Assert.Equal("stop", jobs[1].Mode);
+ Assert.Equal("Weekly full", jobs[1].Comment);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_SecondJob_BacksUpAll()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Equal(1, jobs[1].All);
+ Assert.Null(jobs[1].VmId);
+ }
+
+ [Fact]
+ public void PveBackupJob_Deserialize_Pve9_SecondJob_OptionalFieldsAreNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_backup_jobs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var jobs = data.ToObject();
+ Assert.NotNull(jobs);
+ Assert.Null(jobs[1].Compress);
+ Assert.Null(jobs[1].MaxFiles);
+ Assert.Null(jobs[1].PruneBackups);
+ Assert.Null(jobs[1].MailNotification);
+ Assert.Null(jobs[1].MailTo);
+ Assert.Null(jobs[1].Node);
+ Assert.Null(jobs[1].Exclude);
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Core.Tests/Models/FirewallModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/FirewallModelTests.cs
new file mode 100644
index 0000000..069e3a7
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Models/FirewallModelTests.cs
@@ -0,0 +1,330 @@
+using Xunit;
+using Newtonsoft.Json.Linq;
+using PSProxmoxVE.Core.Models.Firewall;
+
+namespace PSProxmoxVE.Core.Tests.Models
+{
+ public class FirewallModelTests
+ {
+ // ── PveFirewallRule ──────────────────────────────────────────────
+
+ [Fact]
+ public void PveFirewallRule_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_rules.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var rules = data.ToObject();
+ Assert.NotNull(rules);
+ Assert.Equal(3, rules.Length);
+ }
+
+ [Fact]
+ public void PveFirewallRule_Deserialize_Pve9_FirstRule_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_rules.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var rules = data.ToObject();
+ Assert.NotNull(rules);
+ Assert.Equal(0, rules[0].Pos);
+ Assert.Equal("in", rules[0].Type);
+ Assert.Equal("ACCEPT", rules[0].Action);
+ Assert.Equal("tcp", rules[0].Proto);
+ Assert.Equal("80", rules[0].Dport);
+ Assert.Equal(1, rules[0].Enable);
+ Assert.Equal("Allow HTTP", rules[0].Comment);
+ }
+
+ [Fact]
+ public void PveFirewallRule_Deserialize_Pve9_FirstRule_HasLogLevel()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_rules.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var rules = data.ToObject();
+ Assert.NotNull(rules);
+ Assert.NotNull(rules[0].Log);
+ Assert.Equal("nolog", rules[0].Log);
+ }
+
+ [Fact]
+ public void PveFirewallRule_Deserialize_Pve9_SecondRule_LogIsNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_rules.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var rules = data.ToObject();
+ Assert.NotNull(rules);
+ Assert.Null(rules[1].Log);
+ }
+
+ [Fact]
+ public void PveFirewallRule_Deserialize_Pve9_ThirdRule_IsOutboundDrop()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_rules.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var rules = data.ToObject();
+ Assert.NotNull(rules);
+ Assert.Equal(2, rules[2].Pos);
+ Assert.Equal("out", rules[2].Type);
+ Assert.Equal("DROP", rules[2].Action);
+ Assert.Equal(0, rules[2].Enable);
+ }
+
+ [Fact]
+ public void PveFirewallRule_Deserialize_Pve9_ThirdRule_NullableFieldsAreNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_rules.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var rules = data.ToObject();
+ Assert.NotNull(rules);
+ Assert.Null(rules[2].Proto);
+ Assert.Null(rules[2].Dport);
+ Assert.Null(rules[2].Sport);
+ Assert.Null(rules[2].Source);
+ Assert.Null(rules[2].Dest);
+ Assert.Null(rules[2].Macro);
+ Assert.Null(rules[2].Iface);
+ }
+
+ // ── PveFirewallGroup ────────────────────────────────────────────
+
+ [Fact]
+ public void PveFirewallGroup_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_groups.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var groups = data.ToObject();
+ Assert.NotNull(groups);
+ Assert.Equal(2, groups.Length);
+ }
+
+ [Fact]
+ public void PveFirewallGroup_Deserialize_Pve9_FirstGroup_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_groups.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var groups = data.ToObject();
+ Assert.NotNull(groups);
+ Assert.Equal("webservers", groups[0].Group);
+ Assert.Equal("HTTP/HTTPS servers", groups[0].Comment);
+ Assert.Equal("abc123", groups[0].Digest);
+ }
+
+ [Fact]
+ public void PveFirewallGroup_Deserialize_Pve9_SecondGroup_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_groups.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var groups = data.ToObject();
+ Assert.NotNull(groups);
+ Assert.Equal("dbservers", groups[1].Group);
+ Assert.Equal("Database servers", groups[1].Comment);
+ Assert.Equal("def456", groups[1].Digest);
+ }
+
+ // ── PveFirewallAlias ────────────────────────────────────────────
+
+ [Fact]
+ public void PveFirewallAlias_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_aliases.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var aliases = data.ToObject();
+ Assert.NotNull(aliases);
+ Assert.Equal(2, aliases.Length);
+ }
+
+ [Fact]
+ public void PveFirewallAlias_Deserialize_Pve9_FirstAlias_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_aliases.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var aliases = data.ToObject();
+ Assert.NotNull(aliases);
+ Assert.Equal("local_network", aliases[0].Name);
+ Assert.Equal("10.0.0.0/8", aliases[0].Cidr);
+ Assert.Equal("RFC1918 private", aliases[0].Comment);
+ }
+
+ [Fact]
+ public void PveFirewallAlias_Deserialize_Pve9_SecondAlias_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_aliases.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var aliases = data.ToObject();
+ Assert.NotNull(aliases);
+ Assert.Equal("dns_server", aliases[1].Name);
+ Assert.Equal("8.8.8.8", aliases[1].Cidr);
+ Assert.Equal("Google DNS", aliases[1].Comment);
+ }
+
+ // ── PveFirewallIpSet ────────────────────────────────────────────
+
+ [Fact]
+ public void PveFirewallIpSet_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_ipsets.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var ipsets = data.ToObject();
+ Assert.NotNull(ipsets);
+ Assert.Equal(2, ipsets.Length);
+ }
+
+ [Fact]
+ public void PveFirewallIpSet_Deserialize_Pve9_FirstIpSet_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_ipsets.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var ipsets = data.ToObject();
+ Assert.NotNull(ipsets);
+ Assert.Equal("blocklist", ipsets[0].Name);
+ Assert.Equal("Blocked IPs", ipsets[0].Comment);
+ Assert.Equal("aaa111", ipsets[0].Digest);
+ }
+
+ [Fact]
+ public void PveFirewallIpSet_Deserialize_Pve9_SecondIpSet_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_ipsets.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var ipsets = data.ToObject();
+ Assert.NotNull(ipsets);
+ Assert.Equal("allowlist", ipsets[1].Name);
+ Assert.Equal("Allowed IPs", ipsets[1].Comment);
+ Assert.Equal("bbb222", ipsets[1].Digest);
+ }
+
+ // ── PveFirewallIpSetEntry ───────────────────────────────────────
+
+ [Fact]
+ public void PveFirewallIpSetEntry_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_ipset_entries.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var entries = data.ToObject();
+ Assert.NotNull(entries);
+ Assert.Equal(2, entries.Length);
+ }
+
+ [Fact]
+ public void PveFirewallIpSetEntry_Deserialize_Pve9_FirstEntry_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_ipset_entries.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var entries = data.ToObject();
+ Assert.NotNull(entries);
+ Assert.Equal("192.168.1.0/24", entries[0].Cidr);
+ Assert.Equal(0, entries[0].NoMatch);
+ Assert.Equal("Local subnet", entries[0].Comment);
+ }
+
+ [Fact]
+ public void PveFirewallIpSetEntry_Deserialize_Pve9_SecondEntry_IsExclusion()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_ipset_entries.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var entries = data.ToObject();
+ Assert.NotNull(entries);
+ Assert.Equal("10.0.0.0/8", entries[1].Cidr);
+ Assert.Equal(1, entries[1].NoMatch);
+ Assert.Equal("Exclude private", entries[1].Comment);
+ }
+
+ // ── PveFirewallOptions ──────────────────────────────────────────
+
+ [Fact]
+ public void PveFirewallOptions_Deserialize_Pve9_ReturnsNotNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_options.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var options = data.ToObject();
+ Assert.NotNull(options);
+ }
+
+ [Fact]
+ public void PveFirewallOptions_Deserialize_Pve9_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_options.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var options = data.ToObject();
+ Assert.NotNull(options);
+ Assert.Equal(1, options.Enable);
+ Assert.Equal("DROP", options.PolicyIn);
+ Assert.Equal("ACCEPT", options.PolicyOut);
+ Assert.Equal("nolog", options.LogLevelIn);
+ Assert.Equal("nolog", options.LogLevelOut);
+ }
+
+ [Fact]
+ public void PveFirewallOptions_Deserialize_Pve9_OptionalFieldsAreNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_options.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var options = data.ToObject();
+ Assert.NotNull(options);
+ Assert.Null(options.Dhcp);
+ Assert.Null(options.Ndp);
+ Assert.Null(options.Radv);
+ Assert.Null(options.MacFilter);
+ Assert.Null(options.IpFilter);
+ }
+
+ // ── PveFirewallRef ──────────────────────────────────────────────
+
+ [Fact]
+ public void PveFirewallRef_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_refs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var refs = data.ToObject();
+ Assert.NotNull(refs);
+ Assert.Equal(2, refs.Length);
+ }
+
+ [Fact]
+ public void PveFirewallRef_Deserialize_Pve9_FirstRef_IsAlias()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_refs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var refs = data.ToObject();
+ Assert.NotNull(refs);
+ Assert.Equal("alias", refs[0].Type);
+ Assert.Equal("local_network", refs[0].Name);
+ Assert.Equal("RFC1918 private", refs[0].Comment);
+ }
+
+ [Fact]
+ public void PveFirewallRef_Deserialize_Pve9_SecondRef_IsIpSet()
+ {
+ var json = TestHelper.LoadFixture("pve9_firewall_refs.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var refs = data.ToObject();
+ Assert.NotNull(refs);
+ Assert.Equal("ipset", refs[1].Type);
+ Assert.Equal("blocklist", refs[1].Name);
+ Assert.Equal("Blocked IPs", refs[1].Comment);
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs b/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs
index e351111..140f6d9 100644
--- a/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs
+++ b/tests/PSProxmoxVE.Core.Tests/Models/SdnModelTests.cs
@@ -142,5 +142,125 @@ namespace PSProxmoxVE.Core.Tests.Models
// Comments not present in fixture entries
Assert.Null(vnets[0].Comments);
}
+
+ // ── PveSdnIpam ─────────────────────────────────────────────────
+
+ [Fact]
+ public void PveSdnIpam_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_ipams.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var ipams = data.ToObject();
+ Assert.NotNull(ipams);
+ Assert.Equal(2, ipams.Length);
+ }
+
+ [Fact]
+ public void PveSdnIpam_Deserialize_Pve9_FirstIpam_IsPveType()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_ipams.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var ipams = data.ToObject();
+ Assert.NotNull(ipams);
+ Assert.Equal("pve", ipams[0].Ipam);
+ Assert.Equal("pve", ipams[0].Type);
+ }
+
+ [Fact]
+ public void PveSdnIpam_Deserialize_Pve9_FirstIpam_OptionalFieldsAreNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_ipams.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var ipams = data.ToObject();
+ Assert.NotNull(ipams);
+ Assert.Null(ipams[0].Url);
+ Assert.Null(ipams[0].Token);
+ Assert.Null(ipams[0].Section);
+ }
+
+ [Fact]
+ public void PveSdnIpam_Deserialize_Pve9_SecondIpam_HasNetboxProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_ipams.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var ipams = data.ToObject();
+ Assert.NotNull(ipams);
+ Assert.Equal("netbox1", ipams[1].Ipam);
+ Assert.Equal("netbox", ipams[1].Type);
+ Assert.Equal("https://netbox.example.com", ipams[1].Url);
+ Assert.Equal("abc123", ipams[1].Token);
+ Assert.Equal(1, ipams[1].Section);
+ }
+
+ // ── PveSdnDns ──────────────────────────────────────────────────
+
+ [Fact]
+ public void PveSdnDns_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_dns.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var dnsEntries = data.ToObject();
+ Assert.NotNull(dnsEntries);
+ Assert.Single(dnsEntries);
+ }
+
+ [Fact]
+ public void PveSdnDns_Deserialize_Pve9_FirstEntry_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_dns.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var dnsEntries = data.ToObject();
+ Assert.NotNull(dnsEntries);
+ Assert.Equal("powerdns1", dnsEntries[0].Dns);
+ Assert.Equal("powerdns", dnsEntries[0].Type);
+ Assert.Equal("http://dns.example.com:8081", dnsEntries[0].Url);
+ Assert.Equal("secret123", dnsEntries[0].Key);
+ Assert.Equal(3600, dnsEntries[0].Ttl);
+ }
+
+ [Fact]
+ public void PveSdnDns_Deserialize_Pve9_FirstEntry_OptionalFieldsAreNull()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_dns.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var dnsEntries = data.ToObject();
+ Assert.NotNull(dnsEntries);
+ Assert.Null(dnsEntries[0].ReverseMaskV6);
+ }
+
+ // ── PveSdnController ───────────────────────────────────────────
+
+ [Fact]
+ public void PveSdnController_Deserialize_Pve9_ReturnsCorrectCount()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_controllers.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var controllers = data.ToObject();
+ Assert.NotNull(controllers);
+ Assert.Single(controllers);
+ }
+
+ [Fact]
+ public void PveSdnController_Deserialize_Pve9_FirstController_HasCorrectProperties()
+ {
+ var json = TestHelper.LoadFixture("pve9_sdn_controllers.json");
+ var data = JObject.Parse(json)["data"];
+ Assert.NotNull(data);
+ var controllers = data.ToObject();
+ Assert.NotNull(controllers);
+ Assert.Equal("evpn1", controllers[0].Controller);
+ Assert.Equal("evpn", controllers[0].Type);
+ Assert.Equal(65000, controllers[0].Asn);
+ Assert.Equal("10.0.0.1,10.0.0.2", controllers[0].Peers);
+ Assert.Equal("pve1", controllers[0].Node);
+ }
}
}
diff --git a/tests/PSProxmoxVE.Tests/Backup/BackupCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Backup/BackupCmdlets.Tests.ps1
new file mode 100644
index 0000000..8e675c8
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Backup/BackupCmdlets.Tests.ps1
@@ -0,0 +1,291 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for backup cmdlets:
+ New-PveBackup, Get-PveBackupJob, New-PveBackupJob, Set-PveBackupJob, Remove-PveBackupJob.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @('New-PveBackup', 'Get-PveBackupJob', 'New-PveBackupJob', 'Set-PveBackupJob', 'Remove-PveBackupJob')) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+Describe 'Backup cmdlets — manifest declarations' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It " should be declared in CmdletsToExport" -TestCases @(
+ @{ cmdName = 'New-PveBackup' }
+ @{ cmdName = 'Get-PveBackupJob' }
+ @{ cmdName = 'New-PveBackupJob' }
+ @{ cmdName = 'Set-PveBackupJob' }
+ @{ cmdName = 'Remove-PveBackupJob' }
+ ) {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain $cmdName
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveBackup
+# ---------------------------------------------------------------------------
+Describe 'New-PveBackup' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveBackup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveBackup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'New-PveBackup'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveBackup'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have VmId parameter' {
+ Skip-IfMissing 'New-PveBackup'
+ $script:Cmd.Parameters.ContainsKey('VmId') | Should -BeTrue
+ }
+
+ It 'Should have Storage parameter' {
+ Skip-IfMissing 'New-PveBackup'
+ $script:Cmd.Parameters.ContainsKey('Storage') | Should -BeTrue
+ }
+
+ It 'Should have Node parameter' {
+ Skip-IfMissing 'New-PveBackup'
+ $script:Cmd.Parameters.ContainsKey('Node') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'New-PveBackup'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveBackup'
+ { New-PveBackup -VmId 100 -Storage 'local' -Node 'pve' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveBackupJob
+# ---------------------------------------------------------------------------
+Describe 'Get-PveBackupJob' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveBackupJob' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveBackupJob'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveBackupJob'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Id parameter' {
+ Skip-IfMissing 'Get-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('Id') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveBackupJob'
+ { Get-PveBackupJob -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveBackupJob
+# ---------------------------------------------------------------------------
+Describe 'New-PveBackupJob' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveBackupJob' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveBackupJob'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Storage should be Mandatory' {
+ Skip-IfMissing 'New-PveBackupJob'
+ $isMandatory = $script:Cmd.Parameters['Storage'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Schedule should be Mandatory' {
+ Skip-IfMissing 'New-PveBackupJob'
+ $isMandatory = $script:Cmd.Parameters['Schedule'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Optional parameters' {
+ It 'Should have VmId parameter' {
+ Skip-IfMissing 'New-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('VmId') | Should -BeTrue
+ }
+
+ It 'Should have Mode parameter' {
+ Skip-IfMissing 'New-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('Mode') | Should -BeTrue
+ }
+
+ It 'Should have Compress parameter' {
+ Skip-IfMissing 'New-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('Compress') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveBackupJob'
+ { New-PveBackupJob -Storage 'local' -Schedule 'daily' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveBackupJob
+# ---------------------------------------------------------------------------
+Describe 'Set-PveBackupJob' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveBackupJob' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveBackupJob'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Set-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Id should be Mandatory' {
+ Skip-IfMissing 'Set-PveBackupJob'
+ $isMandatory = $script:Cmd.Parameters['Id'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveBackupJob'
+ { Set-PveBackupJob -Id 'backup-test' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveBackupJob
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveBackupJob' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveBackupJob' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveBackupJob'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveBackupJob'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveBackupJob'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Id should be Mandatory' {
+ Skip-IfMissing 'Remove-PveBackupJob'
+ $isMandatory = $script:Cmd.Parameters['Id'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveBackupJob'
+ { Remove-PveBackupJob -Id 'backup-test' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallAliasCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallAliasCmdlets.Tests.ps1
new file mode 100644
index 0000000..23864d5
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallAliasCmdlets.Tests.ps1
@@ -0,0 +1,230 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for firewall alias cmdlets:
+ Get-PveFirewallAlias, New-PveFirewallAlias, Set-PveFirewallAlias, Remove-PveFirewallAlias.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @('Get-PveFirewallAlias', 'New-PveFirewallAlias', 'Set-PveFirewallAlias', 'Remove-PveFirewallAlias')) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+Describe 'Firewall alias cmdlets — manifest declarations' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It " should be declared in CmdletsToExport" -TestCases @(
+ @{ cmdName = 'Get-PveFirewallAlias' }
+ @{ cmdName = 'New-PveFirewallAlias' }
+ @{ cmdName = 'Set-PveFirewallAlias' }
+ @{ cmdName = 'Remove-PveFirewallAlias' }
+ ) {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain $cmdName
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveFirewallAlias
+# ---------------------------------------------------------------------------
+Describe 'Get-PveFirewallAlias' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallAlias' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveFirewallAlias'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveFirewallAlias'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Name parameter' {
+ Skip-IfMissing 'Get-PveFirewallAlias'
+ $script:Cmd.Parameters.ContainsKey('Name') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveFirewallAlias'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveFirewallAlias'
+ { Get-PveFirewallAlias -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveFirewallAlias
+# ---------------------------------------------------------------------------
+Describe 'New-PveFirewallAlias' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallAlias' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveFirewallAlias'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveFirewallAlias'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'New-PveFirewallAlias'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Cidr should be Mandatory' {
+ Skip-IfMissing 'New-PveFirewallAlias'
+ $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Optional parameters' {
+ It 'Should have Comment parameter' {
+ Skip-IfMissing 'New-PveFirewallAlias'
+ $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveFirewallAlias'
+ { New-PveFirewallAlias -Name 'testalias' -Cidr '10.0.0.0/24' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveFirewallAlias
+# ---------------------------------------------------------------------------
+Describe 'Set-PveFirewallAlias' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallAlias' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveFirewallAlias'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Set-PveFirewallAlias'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'Set-PveFirewallAlias'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Cidr should be Mandatory' {
+ Skip-IfMissing 'Set-PveFirewallAlias'
+ $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveFirewallAlias'
+ { Set-PveFirewallAlias -Name 'testalias' -Cidr '10.0.0.0/24' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveFirewallAlias
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveFirewallAlias' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallAlias' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveFirewallAlias'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveFirewallAlias'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveFirewallAlias'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'Remove-PveFirewallAlias'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveFirewallAlias'
+ { Remove-PveFirewallAlias -Name 'testalias' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallGroupCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallGroupCmdlets.Tests.ps1
new file mode 100644
index 0000000..f5c2bc3
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallGroupCmdlets.Tests.ps1
@@ -0,0 +1,176 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for firewall group cmdlets:
+ Get-PveFirewallGroup, New-PveFirewallGroup, Remove-PveFirewallGroup.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @('Get-PveFirewallGroup', 'New-PveFirewallGroup', 'Remove-PveFirewallGroup')) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+Describe 'Firewall group cmdlets — manifest declarations' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It " should be declared in CmdletsToExport" -TestCases @(
+ @{ cmdName = 'Get-PveFirewallGroup' }
+ @{ cmdName = 'New-PveFirewallGroup' }
+ @{ cmdName = 'Remove-PveFirewallGroup' }
+ ) {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain $cmdName
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveFirewallGroup
+# ---------------------------------------------------------------------------
+Describe 'Get-PveFirewallGroup' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallGroup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveFirewallGroup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveFirewallGroup'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Group parameter' {
+ Skip-IfMissing 'Get-PveFirewallGroup'
+ $script:Cmd.Parameters.ContainsKey('Group') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveFirewallGroup'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveFirewallGroup'
+ { Get-PveFirewallGroup -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveFirewallGroup
+# ---------------------------------------------------------------------------
+Describe 'New-PveFirewallGroup' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallGroup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveFirewallGroup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveFirewallGroup'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Group should be Mandatory' {
+ Skip-IfMissing 'New-PveFirewallGroup'
+ $isMandatory = $script:Cmd.Parameters['Group'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Optional parameters' {
+ It 'Should have Comment parameter' {
+ Skip-IfMissing 'New-PveFirewallGroup'
+ $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveFirewallGroup'
+ { New-PveFirewallGroup -Group 'testgroup' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveFirewallGroup
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveFirewallGroup' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallGroup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveFirewallGroup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveFirewallGroup'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveFirewallGroup'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Group should be Mandatory' {
+ Skip-IfMissing 'Remove-PveFirewallGroup'
+ $isMandatory = $script:Cmd.Parameters['Group'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveFirewallGroup'
+ { Remove-PveFirewallGroup -Group 'testgroup' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallIpSetCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallIpSetCmdlets.Tests.ps1
new file mode 100644
index 0000000..6039589
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallIpSetCmdlets.Tests.ps1
@@ -0,0 +1,376 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for firewall IP set cmdlets:
+ Get-PveFirewallIpSet, New-PveFirewallIpSet, Remove-PveFirewallIpSet,
+ Get-PveFirewallIpSetEntry, New-PveFirewallIpSetEntry, Set-PveFirewallIpSetEntry,
+ Remove-PveFirewallIpSetEntry.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @(
+ 'Get-PveFirewallIpSet', 'New-PveFirewallIpSet', 'Remove-PveFirewallIpSet',
+ 'Get-PveFirewallIpSetEntry', 'New-PveFirewallIpSetEntry', 'Set-PveFirewallIpSetEntry',
+ 'Remove-PveFirewallIpSetEntry'
+ )) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+Describe 'Firewall IP set cmdlets — manifest declarations' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It " should be declared in CmdletsToExport" -TestCases @(
+ @{ cmdName = 'Get-PveFirewallIpSet' }
+ @{ cmdName = 'New-PveFirewallIpSet' }
+ @{ cmdName = 'Remove-PveFirewallIpSet' }
+ @{ cmdName = 'Get-PveFirewallIpSetEntry' }
+ @{ cmdName = 'New-PveFirewallIpSetEntry' }
+ @{ cmdName = 'Set-PveFirewallIpSetEntry' }
+ @{ cmdName = 'Remove-PveFirewallIpSetEntry' }
+ ) {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain $cmdName
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveFirewallIpSet
+# ---------------------------------------------------------------------------
+Describe 'Get-PveFirewallIpSet' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallIpSet' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveFirewallIpSet'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveFirewallIpSet'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Name parameter' {
+ Skip-IfMissing 'Get-PveFirewallIpSet'
+ $script:Cmd.Parameters.ContainsKey('Name') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveFirewallIpSet'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveFirewallIpSet'
+ { Get-PveFirewallIpSet -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveFirewallIpSet
+# ---------------------------------------------------------------------------
+Describe 'New-PveFirewallIpSet' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallIpSet' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveFirewallIpSet'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveFirewallIpSet'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'New-PveFirewallIpSet'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Optional parameters' {
+ It 'Should have Comment parameter' {
+ Skip-IfMissing 'New-PveFirewallIpSet'
+ $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveFirewallIpSet'
+ { New-PveFirewallIpSet -Name 'testipset' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveFirewallIpSet
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveFirewallIpSet' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallIpSet' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveFirewallIpSet'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveFirewallIpSet'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveFirewallIpSet'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'Remove-PveFirewallIpSet'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveFirewallIpSet'
+ { Remove-PveFirewallIpSet -Name 'testipset' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveFirewallIpSetEntry
+# ---------------------------------------------------------------------------
+Describe 'Get-PveFirewallIpSetEntry' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveFirewallIpSetEntry'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveFirewallIpSetEntry'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'Get-PveFirewallIpSetEntry'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveFirewallIpSetEntry'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveFirewallIpSetEntry'
+ { Get-PveFirewallIpSetEntry -Name 'testipset' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveFirewallIpSetEntry
+# ---------------------------------------------------------------------------
+Describe 'New-PveFirewallIpSetEntry' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveFirewallIpSetEntry'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveFirewallIpSetEntry'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'New-PveFirewallIpSetEntry'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Cidr should be Mandatory' {
+ Skip-IfMissing 'New-PveFirewallIpSetEntry'
+ $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveFirewallIpSetEntry'
+ { New-PveFirewallIpSetEntry -Name 'testipset' -Cidr '10.0.0.1' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveFirewallIpSetEntry
+# ---------------------------------------------------------------------------
+Describe 'Set-PveFirewallIpSetEntry' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveFirewallIpSetEntry'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Set-PveFirewallIpSetEntry'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'Set-PveFirewallIpSetEntry'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Cidr should be Mandatory' {
+ Skip-IfMissing 'Set-PveFirewallIpSetEntry'
+ $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveFirewallIpSetEntry'
+ { Set-PveFirewallIpSetEntry -Name 'testipset' -Cidr '10.0.0.1' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveFirewallIpSetEntry
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveFirewallIpSetEntry' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallIpSetEntry' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveFirewallIpSetEntry'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveFirewallIpSetEntry'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveFirewallIpSetEntry'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Name should be Mandatory' {
+ Skip-IfMissing 'Remove-PveFirewallIpSetEntry'
+ $isMandatory = $script:Cmd.Parameters['Name'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Cidr should be Mandatory' {
+ Skip-IfMissing 'Remove-PveFirewallIpSetEntry'
+ $isMandatory = $script:Cmd.Parameters['Cidr'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveFirewallIpSetEntry'
+ { Remove-PveFirewallIpSetEntry -Name 'testipset' -Cidr '10.0.0.1' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallOptionsCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallOptionsCmdlets.Tests.ps1
new file mode 100644
index 0000000..0d123bb
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallOptionsCmdlets.Tests.ps1
@@ -0,0 +1,175 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for firewall options/ref cmdlets:
+ Get-PveFirewallOptions, Set-PveFirewallOptions, Get-PveFirewallRef.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @('Get-PveFirewallOptions', 'Set-PveFirewallOptions', 'Get-PveFirewallRef')) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+Describe 'Firewall options/ref cmdlets — manifest declarations' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It " should be declared in CmdletsToExport" -TestCases @(
+ @{ cmdName = 'Get-PveFirewallOptions' }
+ @{ cmdName = 'Set-PveFirewallOptions' }
+ @{ cmdName = 'Get-PveFirewallRef' }
+ ) {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain $cmdName
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveFirewallOptions
+# ---------------------------------------------------------------------------
+Describe 'Get-PveFirewallOptions' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallOptions' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveFirewallOptions'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveFirewallOptions'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Level parameter with ValidateSet' {
+ Skip-IfMissing 'Get-PveFirewallOptions'
+ $param = $script:Cmd.Parameters['Level']
+ $param | Should -Not -BeNullOrEmpty
+ $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $validateSet | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveFirewallOptions'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveFirewallOptions'
+ { Get-PveFirewallOptions -Level 'cluster' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveFirewallOptions
+# ---------------------------------------------------------------------------
+Describe 'Set-PveFirewallOptions' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallOptions' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveFirewallOptions'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Set-PveFirewallOptions'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Level parameter with ValidateSet' {
+ Skip-IfMissing 'Set-PveFirewallOptions'
+ $param = $script:Cmd.Parameters['Level']
+ $param | Should -Not -BeNullOrEmpty
+ $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $validateSet | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have Enable parameter' {
+ Skip-IfMissing 'Set-PveFirewallOptions'
+ $script:Cmd.Parameters.ContainsKey('Enable') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveFirewallOptions'
+ { Set-PveFirewallOptions -Level 'cluster' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveFirewallRef
+# ---------------------------------------------------------------------------
+Describe 'Get-PveFirewallRef' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallRef' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveFirewallRef'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveFirewallRef'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveFirewallRef'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+
+ It 'Should have optional Type parameter' {
+ Skip-IfMissing 'Get-PveFirewallRef'
+ $script:Cmd.Parameters.ContainsKey('Type') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveFirewallRef'
+ { Get-PveFirewallRef -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Firewall/FirewallRuleCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Firewall/FirewallRuleCmdlets.Tests.ps1
new file mode 100644
index 0000000..e52d392
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Firewall/FirewallRuleCmdlets.Tests.ps1
@@ -0,0 +1,243 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for firewall rule cmdlets:
+ Get-PveFirewallRule, New-PveFirewallRule, Set-PveFirewallRule, Remove-PveFirewallRule.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @('Get-PveFirewallRule', 'New-PveFirewallRule', 'Set-PveFirewallRule', 'Remove-PveFirewallRule')) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+Describe 'Firewall rule cmdlets — manifest declarations' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It " should be declared in CmdletsToExport" -TestCases @(
+ @{ cmdName = 'Get-PveFirewallRule' }
+ @{ cmdName = 'New-PveFirewallRule' }
+ @{ cmdName = 'Set-PveFirewallRule' }
+ @{ cmdName = 'Remove-PveFirewallRule' }
+ ) {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain $cmdName
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveFirewallRule
+# ---------------------------------------------------------------------------
+Describe 'Get-PveFirewallRule' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveFirewallRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveFirewallRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveFirewallRule'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Level parameter with ValidateSet' {
+ Skip-IfMissing 'Get-PveFirewallRule'
+ $param = $script:Cmd.Parameters['Level']
+ $param | Should -Not -BeNullOrEmpty
+ $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $validateSet | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveFirewallRule'
+ { Get-PveFirewallRule -Level 'cluster' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveFirewallRule
+# ---------------------------------------------------------------------------
+Describe 'New-PveFirewallRule' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveFirewallRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveFirewallRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Level parameter with ValidateSet' {
+ Skip-IfMissing 'New-PveFirewallRule'
+ $param = $script:Cmd.Parameters['Level']
+ $param | Should -Not -BeNullOrEmpty
+ $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $validateSet | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have Action parameter' {
+ Skip-IfMissing 'New-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('Action') | Should -BeTrue
+ }
+
+ It 'Should have Type parameter' {
+ Skip-IfMissing 'New-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('Type') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveFirewallRule'
+ { New-PveFirewallRule -Level 'cluster' -Action 'ACCEPT' -Type 'in' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveFirewallRule
+# ---------------------------------------------------------------------------
+Describe 'Set-PveFirewallRule' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveFirewallRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveFirewallRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Set-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Level parameter with ValidateSet' {
+ Skip-IfMissing 'Set-PveFirewallRule'
+ $param = $script:Cmd.Parameters['Level']
+ $param | Should -Not -BeNullOrEmpty
+ $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $validateSet | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have Pos parameter' {
+ Skip-IfMissing 'Set-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('Pos') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveFirewallRule'
+ { Set-PveFirewallRule -Level 'cluster' -Pos 0 -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveFirewallRule
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveFirewallRule' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveFirewallRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveFirewallRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveFirewallRule'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Level parameter with ValidateSet' {
+ Skip-IfMissing 'Remove-PveFirewallRule'
+ $param = $script:Cmd.Parameters['Level']
+ $param | Should -Not -BeNullOrEmpty
+ $isMandatory = $param.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ $validateSet = $param.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $validateSet | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have Pos parameter' {
+ Skip-IfMissing 'Remove-PveFirewallRule'
+ $script:Cmd.Parameters.ContainsKey('Pos') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveFirewallRule'
+ { Remove-PveFirewallRule -Level 'cluster' -Pos 0 -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1
index 76c9ea2..af2c1ab 100644
--- a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1
+++ b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1
@@ -79,6 +79,8 @@ BeforeAll {
$script:CreatedRoles = [System.Collections.Generic.List[string]]::new()
$script:TestVmId = $null
$script:TestContainerId = $null
+ $script:FirewallTestRulePos = $null
+ $script:BackupTestJobId = $null
# Helper functions for skip logic
function script:Skip-IfNoTarget {
@@ -159,6 +161,32 @@ AfterAll {
}
catch { <# non-fatal #> }
}
+
+ # Cleanup: firewall test artifacts
+ try {
+ $rules = Get-PveFirewallRule -Level Cluster -ErrorAction SilentlyContinue
+ $testRules = $rules | Where-Object { $_.Comment -like 'pester-test*' }
+ foreach ($r in $testRules) {
+ Remove-PveFirewallRule -Level Cluster -Position $r.Pos -Confirm:$false -ErrorAction SilentlyContinue
+ }
+ } catch { }
+
+ # Cleanup: firewall aliases
+ try {
+ Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction SilentlyContinue
+ } catch { }
+
+ # Cleanup: firewall IP sets
+ try {
+ Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction SilentlyContinue
+ } catch { }
+
+ # Cleanup: backup jobs
+ try {
+ if ($script:BackupTestJobId) {
+ Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction SilentlyContinue
+ }
+ } catch { }
}
# ===========================================================================
@@ -1343,6 +1371,161 @@ Describe 'Integration Tests' -Tag 'Integration' {
}
}
+ # -----------------------------------------------------------------------
+ Context 'OVA Import' {
+ BeforeAll {
+ function Skip-IfNoOva {
+ if (-not $script:OvaPath) {
+ Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH is not set'
+ }
+ }
+ }
+
+ It 'Should import a VM from OVA file' {
+ Skip-IfNoOva
+ $vmId = 9900 + (Get-Random -Maximum 99)
+ $result = Import-PveOva -Node $script:Node -Path $script:OvaPath -Storage $script:Storage -NewVmId $vmId -ErrorAction Stop
+ $result | Should -Not -BeNullOrEmpty
+ $script:CreatedVmIds.Add($vmId) | Out-Null
+ }
+
+ It 'Should find the imported VM' {
+ Skip-IfNoOva
+ if ($script:CreatedVmIds.Count -eq 0 -or $script:CreatedVmIds[-1] -lt 9900) {
+ Set-ItResult -Skipped -Because 'OVA import did not create a VM'
+ }
+ $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:CreatedVmIds[-1] }
+ $vm | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ # -----------------------------------------------------------------------
+ Context 'Firewall — Cluster Rules' {
+ It 'Should create a firewall rule' {
+ Skip-IfNoTarget
+ { New-PveFirewallRule -Level Cluster -Type in -Action ACCEPT -Proto tcp -Dport '8080' -Comment 'pester-test-rule' -Enable -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should list firewall rules and find the test rule' {
+ Skip-IfNoTarget
+ $rules = Get-PveFirewallRule -Level Cluster
+ $rules | Should -Not -BeNullOrEmpty
+ $testRule = $rules | Where-Object { $_.Comment -eq 'pester-test-rule' }
+ $testRule | Should -Not -BeNullOrEmpty
+ $script:FirewallTestRulePos = $testRule.Pos
+ }
+
+ It 'Should update the firewall rule' {
+ Skip-IfNoTarget
+ if ($null -eq $script:FirewallTestRulePos) {
+ Set-ItResult -Skipped -Because 'No test rule was created'
+ }
+ { Set-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should remove the firewall rule' {
+ Skip-IfNoTarget
+ if ($null -eq $script:FirewallTestRulePos) {
+ Set-ItResult -Skipped -Because 'No test rule was created'
+ }
+ { Remove-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Confirm:$false -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should get firewall options' {
+ Skip-IfNoTarget
+ $opts = Get-PveFirewallOptions -Level Cluster
+ $opts | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ # -----------------------------------------------------------------------
+ Context 'Backup Jobs' {
+ It 'Should create a backup job' {
+ Skip-IfNoTarget
+ { New-PveBackupJob -Schedule '0 3 * * 6' -Storage $script:Storage -Mode snapshot -Comment 'pester-test-backup' -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should list backup jobs and find the test job' {
+ Skip-IfNoTarget
+ $jobs = Get-PveBackupJob
+ $testJob = $jobs | Where-Object { $_.Comment -eq 'pester-test-backup' }
+ $testJob | Should -Not -BeNullOrEmpty
+ $script:BackupTestJobId = $testJob.Id
+ }
+
+ It 'Should update the backup job' {
+ Skip-IfNoTarget
+ if (-not $script:BackupTestJobId) {
+ Set-ItResult -Skipped -Because 'No test backup job was created'
+ }
+ { Set-PveBackupJob -Id $script:BackupTestJobId -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should remove the backup job' {
+ Skip-IfNoTarget
+ if (-not $script:BackupTestJobId) {
+ Set-ItResult -Skipped -Because 'No test backup job was created'
+ }
+ { Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should verify the backup job was removed' {
+ Skip-IfNoTarget
+ if (-not $script:BackupTestJobId) {
+ Set-ItResult -Skipped -Because 'No test backup job was created'
+ }
+ $jobs = Get-PveBackupJob
+ $testJob = $jobs | Where-Object { $_.Id -eq $script:BackupTestJobId }
+ $testJob | Should -BeNullOrEmpty
+ }
+ }
+
+ # -----------------------------------------------------------------------
+ Context 'Firewall — Aliases and IP Sets' {
+ It 'Should create a firewall alias' {
+ Skip-IfNoTarget
+ { New-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Cidr '192.168.99.0/24' -Comment 'test alias' -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should list and find the alias' {
+ Skip-IfNoTarget
+ $aliases = Get-PveFirewallAlias -Level Cluster
+ $aliases | Where-Object { $_.Name -eq 'pester-alias' } | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should remove the alias' {
+ Skip-IfNoTarget
+ { Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should create an IP set' {
+ Skip-IfNoTarget
+ { New-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Comment 'test ipset' -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should add an entry to the IP set' {
+ Skip-IfNoTarget
+ { New-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Comment 'test entry' -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should list IP set entries' {
+ Skip-IfNoTarget
+ $entries = Get-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset'
+ $entries | Should -Not -BeNullOrEmpty
+ ($entries | Where-Object { $_.Cidr -like '10.99.0.0*' }) | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should remove the IP set entry' {
+ Skip-IfNoTarget
+ { Remove-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Should remove the IP set' {
+ Skip-IfNoTarget
+ { Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw
+ }
+ }
+
# -----------------------------------------------------------------------
Context 'Disconnect' {
It 'Should disconnect from PVE server (Disconnect-PveServer)' {
diff --git a/tests/PSProxmoxVE.Tests/Network/SdnIpamDnsControllerCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Network/SdnIpamDnsControllerCmdlets.Tests.ps1
new file mode 100644
index 0000000..9d580ca
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Network/SdnIpamDnsControllerCmdlets.Tests.ps1
@@ -0,0 +1,466 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for SDN IPAM, DNS, and controller cmdlets:
+ Get-PveSdnIpam, New-PveSdnIpam, Remove-PveSdnIpam,
+ Get-PveSdnDns, New-PveSdnDns, Remove-PveSdnDns,
+ Get-PveSdnController, New-PveSdnController, Remove-PveSdnController.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @(
+ 'Get-PveSdnIpam', 'New-PveSdnIpam', 'Remove-PveSdnIpam',
+ 'Get-PveSdnDns', 'New-PveSdnDns', 'Remove-PveSdnDns',
+ 'Get-PveSdnController', 'New-PveSdnController', 'Remove-PveSdnController'
+ )) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Manifest contract
+# ---------------------------------------------------------------------------
+Describe 'SDN IPAM/DNS/Controller cmdlets — manifest declarations' {
+ BeforeAll {
+ $manifestPath = Join-Path (Get-Module PSProxmoxVE).ModuleBase 'PSProxmoxVE.psd1'
+ $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null }
+ }
+
+ It " should be declared in CmdletsToExport" -TestCases @(
+ @{ cmdName = 'Get-PveSdnIpam' }
+ @{ cmdName = 'New-PveSdnIpam' }
+ @{ cmdName = 'Remove-PveSdnIpam' }
+ @{ cmdName = 'Get-PveSdnDns' }
+ @{ cmdName = 'New-PveSdnDns' }
+ @{ cmdName = 'Remove-PveSdnDns' }
+ @{ cmdName = 'Get-PveSdnController' }
+ @{ cmdName = 'New-PveSdnController' }
+ @{ cmdName = 'Remove-PveSdnController' }
+ ) {
+ if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return }
+ $script:Manifest.CmdletsToExport | Should -Contain $cmdName
+ }
+}
+
+# ===========================================================================
+# IPAM cmdlets
+# ===========================================================================
+
+# ---------------------------------------------------------------------------
+# Get-PveSdnIpam
+# ---------------------------------------------------------------------------
+Describe 'Get-PveSdnIpam' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveSdnIpam' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveSdnIpam'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveSdnIpam'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Ipam parameter' {
+ Skip-IfMissing 'Get-PveSdnIpam'
+ $script:Cmd.Parameters.ContainsKey('Ipam') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveSdnIpam'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveSdnIpam'
+ { Get-PveSdnIpam -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveSdnIpam
+# ---------------------------------------------------------------------------
+Describe 'New-PveSdnIpam' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveSdnIpam' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveSdnIpam'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveSdnIpam'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Ipam should be Mandatory' {
+ Skip-IfMissing 'New-PveSdnIpam'
+ $isMandatory = $script:Cmd.Parameters['Ipam'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Type should be Mandatory' {
+ Skip-IfMissing 'New-PveSdnIpam'
+ $isMandatory = $script:Cmd.Parameters['Type'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveSdnIpam'
+ { New-PveSdnIpam -Ipam 'testipam' -Type 'pve' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveSdnIpam
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveSdnIpam' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveSdnIpam' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveSdnIpam'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveSdnIpam'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveSdnIpam'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Ipam should be Mandatory' {
+ Skip-IfMissing 'Remove-PveSdnIpam'
+ $isMandatory = $script:Cmd.Parameters['Ipam'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveSdnIpam'
+ { Remove-PveSdnIpam -Ipam 'testipam' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ===========================================================================
+# DNS cmdlets
+# ===========================================================================
+
+# ---------------------------------------------------------------------------
+# Get-PveSdnDns
+# ---------------------------------------------------------------------------
+Describe 'Get-PveSdnDns' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveSdnDns' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveSdnDns'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveSdnDns'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Dns parameter' {
+ Skip-IfMissing 'Get-PveSdnDns'
+ $script:Cmd.Parameters.ContainsKey('Dns') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveSdnDns'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveSdnDns'
+ { Get-PveSdnDns -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveSdnDns
+# ---------------------------------------------------------------------------
+Describe 'New-PveSdnDns' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveSdnDns' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveSdnDns'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveSdnDns'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Dns should be Mandatory' {
+ Skip-IfMissing 'New-PveSdnDns'
+ $isMandatory = $script:Cmd.Parameters['Dns'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Type should be Mandatory' {
+ Skip-IfMissing 'New-PveSdnDns'
+ $isMandatory = $script:Cmd.Parameters['Type'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveSdnDns'
+ { New-PveSdnDns -Dns 'testdns' -Type 'powerdns' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveSdnDns
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveSdnDns' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveSdnDns' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveSdnDns'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveSdnDns'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveSdnDns'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Dns should be Mandatory' {
+ Skip-IfMissing 'Remove-PveSdnDns'
+ $isMandatory = $script:Cmd.Parameters['Dns'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveSdnDns'
+ { Remove-PveSdnDns -Dns 'testdns' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ===========================================================================
+# Controller cmdlets
+# ===========================================================================
+
+# ---------------------------------------------------------------------------
+# Get-PveSdnController
+# ---------------------------------------------------------------------------
+Describe 'Get-PveSdnController' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveSdnController' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveSdnController'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveSdnController'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Controller parameter' {
+ Skip-IfMissing 'Get-PveSdnController'
+ $script:Cmd.Parameters.ContainsKey('Controller') | Should -BeTrue
+ }
+
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveSdnController'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveSdnController'
+ { Get-PveSdnController -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveSdnController
+# ---------------------------------------------------------------------------
+Describe 'New-PveSdnController' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveSdnController' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveSdnController'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess support' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'New-PveSdnController'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Controller should be Mandatory' {
+ Skip-IfMissing 'New-PveSdnController'
+ $isMandatory = $script:Cmd.Parameters['Controller'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Type should be Mandatory' {
+ Skip-IfMissing 'New-PveSdnController'
+ $isMandatory = $script:Cmd.Parameters['Type'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveSdnController'
+ { New-PveSdnController -Controller 'testctrl' -Type 'evpn' -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveSdnController
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveSdnController' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveSdnController' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveSdnController'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'ShouldProcess / ConfirmImpact' {
+ It 'Should support WhatIf' {
+ Skip-IfMissing 'Remove-PveSdnController'
+ $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue
+ }
+
+ It 'Should declare ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveSdnController'
+ $attr = $script:Cmd.ImplementingType.GetCustomAttributes(
+ [System.Management.Automation.CmdletAttribute], $false) |
+ Select-Object -First 1
+ $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High)
+ }
+ }
+
+ Context 'Required parameters' {
+ It 'Controller should be Mandatory' {
+ Skip-IfMissing 'Remove-PveSdnController'
+ $isMandatory = $script:Cmd.Parameters['Controller'].ParameterSets.Values |
+ Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveSdnController'
+ { Remove-PveSdnController -Controller 'testctrl' -Confirm:$false -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}