diff --git a/Cargo.lock b/Cargo.lock index 8e5a5c607..892075837 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -662,6 +662,7 @@ dependencies = [ "dsc-lib-osinfo", "dsc-lib-security_context", "indicatif", + "ipnetwork", "jsonschema", "linked-hash-map", "murmurhash64", @@ -1429,6 +1430,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "iri-string" version = "0.7.8" diff --git a/Cargo.toml b/Cargo.toml index 31f5c69fd..d4d73bfd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -204,6 +204,8 @@ url = { version = "2.5" } urlencoding = { version = "2.1" } # dsc-lib which = { version = "8.0" } +# dsc-lib +ipnetwork = { version = "0.21" } # build-only dependencies # dsc-lib, dsc-lib-registry, sshdconfig, tree-sitter-dscexpression, tree-sitter-ssh-server-config diff --git a/docs/reference/schemas/config/functions/cidrHost.md b/docs/reference/schemas/config/functions/cidrHost.md new file mode 100644 index 000000000..0de44842d --- /dev/null +++ b/docs/reference/schemas/config/functions/cidrHost.md @@ -0,0 +1,268 @@ +--- +description: Reference for the 'cidrHost' DSC configuration document function +ms.date: 11/03/2025 +ms.topic: reference +title: cidrHost +--- + +# cidrHost + +## Synopsis + +Calculates a host IP address within a CIDR network block. + +## Syntax + +```Syntax +cidrHost(, ) +``` + +## Description + +The `cidrHost()` function calculates a specific host IP address within a given +[CIDR][01] network block by adding a host number offset to the network address. +This function is particularly useful for systematically assigning IP addresses +to hosts, generating gateway addresses, or allocating IP addresses for network +resources in infrastructure-as-code scenarios. + +The host number is zero-indexed, where `0` represents the network address +itself. For typical host assignments, start with `1` to get the first usable +IP address in the network. + +## Examples + +### Example 1 - Calculate gateway address + +Network configurations commonly use the first usable IP address as the gateway. +This example calculates that address using host number `1`. + +```yaml +# cidrHost.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Gateway address + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrHost('10.0.1.0/24', 1)]" +``` + +```bash +dsc config get --file cidrHost.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Gateway address + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: 10.0.1.1 +messages: [] +hadErrors: false +``` + +### Example 2 - Assign multiple host addresses + +This configuration demonstrates calculating host addresses for a subnet created +with [`cidrSubnet()`][02], useful for assigning IP addresses to multiple servers +or network devices. + +```yaml +# cidrHost.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + baseNetwork: + type: string + defaultValue: 172.16.0.0/16 + subnetIndex: + type: int + defaultValue: 10 +resources: + - name: Web server IPs + type: Microsoft.DSC.Debug/Echo + properties: + output: + subnet: "[cidrSubnet(parameters('baseNetwork'), 24, parameters('subnetIndex'))]" + webServer1: "[cidrHost(cidrSubnet(parameters('baseNetwork'), 24, parameters('subnetIndex')), 10)]" + webServer2: "[cidrHost(cidrSubnet(parameters('baseNetwork'), 24, parameters('subnetIndex')), 11)]" + webServer3: "[cidrHost(cidrSubnet(parameters('baseNetwork'), 24, parameters('subnetIndex')), 12)]" +``` + +```bash +dsc config get --file cidrHost.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Web server IPs + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + subnet: 172.16.10.0/24 + webServer1: 172.16.10.10 + webServer2: 172.16.10.11 + webServer3: 172.16.10.12 +messages: [] +hadErrors: false +``` + +### Example 3 - Allocate IPs for network infrastructure + +This configuration shows allocating specific IP addresses for various network +infrastructure components within a subnet, demonstrating practical host number +offsets for different device types. + +```yaml +# cidrHost.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + networkCidr: + type: string + defaultValue: 192.168.100.0/24 +resources: + - name: Network infrastructure IPs + type: Microsoft.DSC.Debug/Echo + properties: + output: + network: "[parameters('networkCidr')]" + gateway: "[cidrHost(parameters('networkCidr'), 1)]" + primaryDNS: "[cidrHost(parameters('networkCidr'), 2)]" + secondaryDNS: "[cidrHost(parameters('networkCidr'), 3)]" + loadBalancer: "[cidrHost(parameters('networkCidr'), 10)]" + webServer1: "[cidrHost(parameters('networkCidr'), 20)]" + webServer2: "[cidrHost(parameters('networkCidr'), 21)]" + dbServer: "[cidrHost(parameters('networkCidr'), 50)]" +``` + +```bash +dsc config get --file cidrHost.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Network infrastructure IPs + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + network: 192.168.100.0/24 + gateway: 192.168.100.1 + primaryDNS: 192.168.100.2 + secondaryDNS: 192.168.100.3 + loadBalancer: 192.168.100.10 + webServer1: 192.168.100.20 + webServer2: 192.168.100.21 + dbServer: 192.168.100.50 +messages: [] +hadErrors: false +``` + +### Example 4 - IPv6 host address allocation + +This configuration demonstrates calculating host addresses within an IPv6 +network, showing that the function supports both IPv4 and IPv6 address families. + +```yaml +# cidrHost.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + ipv6Network: + type: string + defaultValue: 2001:db8::/64 +resources: + - name: IPv6 host addresses + type: Microsoft.DSC.Debug/Echo + properties: + output: + network: "[parameters('ipv6Network')]" + router: "[cidrHost(parameters('ipv6Network'), 1)]" + server1: "[cidrHost(parameters('ipv6Network'), 10)]" + server2: "[cidrHost(parameters('ipv6Network'), 11)]" +``` + +```bash +dsc config get --file cidrHost.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: IPv6 host addresses + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + network: 2001:db8::/64 + router: 2001:db8::1 + server1: 2001:db8::a + server2: 2001:db8::b +messages: [] +hadErrors: false +``` + +## Parameters + +### cidrNotation + +The `cidrHost()` function expects the first parameter to be a string in valid +CIDR notation format, including both an IP address and prefix length (e.g., +`10.0.0.0/16`). + +```yaml +Type: string +Required: true +MinimumCount: 1 +MaximumCount: 1 +``` + +### hostNumber + +The second parameter specifies the host number offset from the network address. +The value must be a non-negative integer within the valid range of the network. + +- For a `/24` network (254 usable hosts), valid values are `0` to `255` +- For a `/16` network (65,534 usable hosts), valid values are `0` to `65535` +- Value `0` returns the network address itself +- Value `1` typically returns the first usable host (often used for gateways) + +The function raises an error if the host number exceeds the network capacity. + +```yaml +Type: integer +Required: true +MinimumCount: 1 +MaximumCount: 1 +``` + +## Output + +The `cidrHost()` function returns a string containing the calculated IP address +in standard notation (e.g., `10.0.1.15` for IPv4 or `2001:db8::a` for IPv6). + +```yaml +Type: string +``` + +## Exceptions + +The `cidrHost()` function raises errors for the following conditions: + +- **Invalid CIDR notation**: When the CIDR string is malformed or missing the + prefix length +- **Host number out of range**: When the host number exceeds the maximum number + of addresses in the network +- **Invalid host number**: When the host number is negative + +## Related functions + +- [`cidrSubnet()`][02] - Creates a subnet from a larger CIDR block +- [`parseCidr()`][03] - Parses CIDR notation and returns network details +- [`add()`][04] - Adds two numbers together +- [`parameters()`][05] - Retrieves parameter values + + +[01]: https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing +[02]: ./cidrSubnet.md +[03]: ./parseCidr.md +[04]: ./add.md +[05]: ./parameters.md diff --git a/docs/reference/schemas/config/functions/cidrSubnet.md b/docs/reference/schemas/config/functions/cidrSubnet.md new file mode 100644 index 000000000..bf4ac7e74 --- /dev/null +++ b/docs/reference/schemas/config/functions/cidrSubnet.md @@ -0,0 +1,307 @@ +--- +description: Reference for the 'cidrSubnet' DSC configuration document function +ms.date: 11/03/2025 +ms.topic: reference +title: cidrSubnet +--- + +# cidrSubnet + +## Synopsis + +Creates a subnet CIDR block from a larger network block. + +## Syntax + +```Syntax +cidrSubnet(, , ) +``` + +## Description + +The `cidrSubnet()` function calculates a subnet [CIDR][01] block from a larger +network block by subdividing it based on a new prefix length and subnet index. +This function is essential for network segmentation, allowing you to +systematically divide a large address space into smaller, manageable subnets +for different purposes like DMZs, application tiers, or regional deployments. + +The subnet number is zero-indexed, meaning the first subnet is `0`, the second +is `1`, and so on. The new prefix length must be greater than or equal to the +original prefix to create a valid subnet. + +## Examples + +### Example 1 - Create multiple subnets from a network block + +This configuration divides a `/16` network into multiple `/24` subnets, +demonstrating how to create separate network segments for different purposes. + +```yaml +# cidrSubnet.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Network segmentation + type: Microsoft.DSC.Debug/Echo + properties: + output: + baseNetwork: 10.0.0.0/16 + webTierSubnet: "[cidrSubnet('10.0.0.0/16', 24, 0)]" + appTierSubnet: "[cidrSubnet('10.0.0.0/16', 24, 1)]" + dataTierSubnet: "[cidrSubnet('10.0.0.0/16', 24, 2)]" + managementSubnet: "[cidrSubnet('10.0.0.0/16', 24, 3)]" +``` + +```bash +dsc config get --file cidrSubnet.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Network segmentation + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + baseNetwork: 10.0.0.0/16 + webTierSubnet: 10.0.0.0/24 + appTierSubnet: 10.0.1.0/24 + dataTierSubnet: 10.0.2.0/24 + managementSubnet: 10.0.3.0/24 +messages: [] +hadErrors: false +``` + +### Example 2 - Create subnets for multiple regions + +This configuration demonstrates creating dedicated subnets for different regions +or environments, showing how to systematically allocate non-overlapping network +segments from a larger address space. + +```yaml +# cidrSubnet.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + baseNetwork: + type: string + defaultValue: 10.144.0.0/20 + newPrefix: + type: int + defaultValue: 24 +resources: + - name: Regional subnets + type: Microsoft.DSC.Debug/Echo + properties: + output: + eastus: "[cidrSubnet(parameters('baseNetwork'), parameters('newPrefix'), 0)]" + westus: "[cidrSubnet(parameters('baseNetwork'), parameters('newPrefix'), 1)]" + northeurope: "[cidrSubnet(parameters('baseNetwork'), parameters('newPrefix'), 2)]" + westeurope: "[cidrSubnet(parameters('baseNetwork'), parameters('newPrefix'), 3)]" + southeastasia: "[cidrSubnet(parameters('baseNetwork'), parameters('newPrefix'), 4)]" +``` + +```bash +dsc config get --file cidrSubnet.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Regional subnets + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + eastus: 10.144.0.0/24 + westus: 10.144.1.0/24 + northeurope: 10.144.2.0/24 + westeurope: 10.144.3.0/24 + southeastasia: 10.144.4.0/24 +messages: [] +hadErrors: false +``` + +### Example 3 - Nested subnetting with host allocation + +This example demonstrates combining `cidrSubnet()` with [`cidrHost()`][03] +and [`parseCidr()`][04] to create a complete network configuration including +subnets and host IP assignments. + +```yaml +# cidrSubnet.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + vnetCidr: + type: string + defaultValue: 172.16.0.0/12 + subnetIndex: + type: int + defaultValue: 42 +resources: + - name: Complete network configuration + type: Microsoft.DSC.Debug/Echo + properties: + output: + vnetAddressSpace: "[parameters('vnetCidr')]" + subnetCidr: "[cidrSubnet(parameters('vnetCidr'), 24, parameters('subnetIndex'))]" + subnetDetails: "[parseCidr(cidrSubnet(parameters('vnetCidr'), 24, parameters('subnetIndex')))]" + gatewayIP: "[cidrHost(cidrSubnet(parameters('vnetCidr'), 24, parameters('subnetIndex')), 1)]" + loadBalancerIP: "[cidrHost(cidrSubnet(parameters('vnetCidr'), 24, parameters('subnetIndex')), 4)]" +``` + +```bash +dsc config get --file cidrSubnet.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Complete network configuration + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + vnetAddressSpace: 172.16.0.0/12 + subnetCidr: 172.16.42.0/24 + subnetDetails: + network: 172.16.42.0 + netmask: 255.255.255.0 + broadcast: 172.16.42.255 + firstUsable: 172.16.42.1 + lastUsable: 172.16.42.254 + cidr: 24 + gatewayIP: 172.16.42.1 + loadBalancerIP: 172.16.42.4 +messages: [] +hadErrors: false +``` + +### Example 4 - IPv6 subnet allocation + +This configuration demonstrates creating IPv6 subnets from a larger IPv6 address +block, showing support for both IPv4 and IPv6 address families. + +```yaml +# cidrSubnet.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + ipv6BaseNetwork: + type: string + defaultValue: 2001:db8::/32 + subnetPrefix: + type: int + defaultValue: 48 +resources: + - name: IPv6 subnets + type: Microsoft.DSC.Debug/Echo + properties: + output: + baseNetwork: "[parameters('ipv6BaseNetwork')]" + subnet0: "[cidrSubnet(parameters('ipv6BaseNetwork'), parameters('subnetPrefix'), 0)]" + subnet1: "[cidrSubnet(parameters('ipv6BaseNetwork'), parameters('subnetPrefix'), 1)]" + subnet10: "[cidrSubnet(parameters('ipv6BaseNetwork'), parameters('subnetPrefix'), 10)]" +``` + +```bash +dsc config get --file cidrSubnet.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: IPv6 subnets + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + baseNetwork: 2001:db8::/32 + subnet0: 2001:db8::/48 + subnet1: 2001:db8:1::/48 + subnet10: 2001:db8:a::/48 +messages: [] +hadErrors: false +``` + +## Parameters + +### cidrNotation + +The first parameter specifies the base network in CIDR notation from which +subnets will be created. This must be a valid CIDR string including both an +IP address and prefix length (e.g., `10.0.0.0/16`). + +```yaml +Type: string +Required: true +MinimumCount: 1 +MaximumCount: 1 +``` + +### newPrefixLength + +The second parameter specifies the prefix length for the new subnet. This value +must be greater than or equal to the base network's prefix length. + +For example: + +- To divide a `/16` into `/24` subnets, use `24` (creates 256 subnets) +- To divide a `/20` into `/24` subnets, use `24` (creates 16 subnets) +- To divide a `/8` into `/16` subnets, use `16` (creates 256 subnets) + +The function raises an error if the new prefix length is smaller than the +original, as this would create a larger network rather than a subnet. + +```yaml +Type: integer +Required: true +MinimumCount: 1 +MaximumCount: 1 +``` + +### subnetNumber + +The third parameter specifies which subnet to calculate, using zero-based +indexing. The valid range depends on how many subnets the prefix length +difference allows. + +For example, dividing a `/16` into `/24` subnets allows subnet numbers from +`0` to `255` (2^(24-16) = 256 subnets). + +The function raises an error if the subnet number exceeds the maximum number +of subnets available in the base network. + +```yaml +Type: integer +Required: true +MinimumCount: 1 +MaximumCount: 1 +``` + +## Output + +The `cidrSubnet()` function returns a string containing the calculated subnet +in CIDR notation (e.g., `10.0.5.0/24`). + +```yaml +Type: string +``` + +## Exceptions + +The `cidrSubnet()` function raises errors for the following conditions: + +- **Invalid CIDR notation**: When the base CIDR string is malformed or missing + the prefix length +- **Invalid prefix length**: When the new prefix is smaller than the base + network's prefix +- **Subnet number out of range**: When the subnet number exceeds the maximum + number of subnets possible with the given prefix lengths +- **Invalid subnet number**: When the subnet number is negative + +## Related functions + +- [`cidrHost()`][02] - Calculates a host IP address within a CIDR block +- [`parseCidr()`][03] - Parses CIDR notation and returns network details +- [`parameters()`][04] - Retrieves parameter values + + +[01]: https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing +[02]: ./cidrHost.md +[03]: ./parseCidr.md +[04]: ./parameters.md diff --git a/docs/reference/schemas/config/functions/parseCidr.md b/docs/reference/schemas/config/functions/parseCidr.md new file mode 100644 index 000000000..20cea2c70 --- /dev/null +++ b/docs/reference/schemas/config/functions/parseCidr.md @@ -0,0 +1,263 @@ +--- +description: Reference for the 'parseCidr' DSC configuration document function +ms.date: 11/03/2025 +ms.topic: reference +title: parseCidr +--- + +# parseCidr + +## Synopsis + +Parses a CIDR notation string and returns network information. + +## Syntax + +```Syntax +parseCidr() +``` + +## Description + +The `parseCidr()` function takes a [CIDR][01] (Classless Inter-Domain Routing) +notation string and returns an object containing detailed network information +including the network address, netmask, broadcast address, and usable IP range. +This function is useful for calculating network details when configuring +networking resources, firewall rules, or IP address management systems. + +The function supports both IPv4 and IPv6 CIDR notation and always requires +explicit prefix length (e.g., `/24` for IPv4 or `/64` for IPv6). + +## Examples + +### Example 1 - Parse standard IPv4 CIDR + +This configuration parses a typical `/24` network and displays the network +details including usable IP range. + +```yaml +# parseCidr.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Parse IPv4 network + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr('192.168.1.0/24')]" +``` + +```bash +dsc config get --file parseCidr.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Parse IPv4 network + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + network: 192.168.1.0 + netmask: 255.255.255.0 + broadcast: 192.168.1.255 + firstUsable: 192.168.1.1 + lastUsable: 192.168.1.254 + cidr: 24 +messages: [] +hadErrors: false +``` + +### Example 2 - Calculate subnet details with parameters + +This example demonstrates using `parseCidr()` with the [`cidrSubnet()`][02] +function to create a subnet from a larger network block and extract its details. + +```yaml +# parseCidr.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + baseNetwork: + type: string + defaultValue: 10.0.0.0/16 + subnetPrefix: + type: int + defaultValue: 24 + subnetIndex: + type: int + defaultValue: 5 +resources: + - name: Calculate subnet details + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr(cidrSubnet(parameters('baseNetwork'), parameters('subnetPrefix'), parameters('subnetIndex')))]" +``` + +```bash +dsc config get --file parseCidr.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Calculate subnet details + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + network: 10.0.5.0 + netmask: 255.255.255.0 + broadcast: 10.0.5.255 + firstUsable: 10.0.5.1 + lastUsable: 10.0.5.254 + cidr: 24 +messages: [] +hadErrors: false +``` + +### Example 3 - Extract specific network properties + +The configuration extracts specific properties from the parsed CIDR result to +configure network settings, demonstrating how to access individual fields from +the returned object. + +```yaml +# parseCidr.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + networkCidr: + type: string + defaultValue: 10.144.0.0/20 +resources: + - name: Network configuration + type: Microsoft.DSC.Debug/Echo + properties: + output: + networkAddress: "[parseCidr(parameters('networkCidr')).network]" + subnetMask: "[parseCidr(parameters('networkCidr')).netmask]" + gatewayIP: "[parseCidr(parameters('networkCidr')).firstUsable]" + broadcastIP: "[parseCidr(parameters('networkCidr')).broadcast]" + prefixLength: "[parseCidr(parameters('networkCidr')).cidr]" +``` + +```bash +dsc config get --file parseCidr.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Network configuration + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + networkAddress: 10.144.0.0 + subnetMask: 255.255.240.0 + gatewayIP: 10.144.0.1 + broadcastIP: 10.144.15.255 + prefixLength: 20 +messages: [] +hadErrors: false +``` + +### Example 4 - Parse IPv6 CIDR notation + +This configuration demonstrates parsing IPv6 CIDR notation, showing that the +function supports both IPv4 and IPv6 address families. + +```yaml +# parseCidr.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Parse IPv6 network + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr('2001:db8::/32')]" +``` + +```bash +dsc config get --file parseCidr.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: Parse IPv6 network + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + network: 2001:db8:: + netmask: ffff:ffff:: + firstUsable: 2001:db8:: + lastUsable: 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff + cidr: 32 +messages: [] +hadErrors: false +``` + +## Parameters + +### cidrNotation + +The `parseCidr()` function expects a single string in valid CIDR notation +format. The string must include both an IP address and a prefix length +separated by a forward slash (e.g., `192.168.1.0/24` or `2001:db8::/32`). + +The function raises an error if: + +- The input doesn't contain a forward slash (`/`) +- The IP address format is invalid +- The prefix length is out of valid range (0-32 for IPv4, 0-128 for IPv6) + +```yaml +Type: string +Required: true +MinimumCount: 1 +MaximumCount: 1 +``` + +## Output + +The `parseCidr()` function returns an object with the following properties: + +For **IPv4** addresses: + +- `network`: The network address (string) +- `netmask`: The subnet mask in dotted decimal notation (string) +- `broadcast`: The broadcast address (string) +- `firstUsable`: The first usable host IP address (string) +- `lastUsable`: The last usable host IP address (string) +- `cidr`: The prefix length (integer) + +For **IPv6** addresses: + +- `network`: The network address (string) +- `netmask`: The network mask (string) +- `firstUsable`: The first usable address (same as network for IPv6) (string) +- `lastUsable`: The last address in the network (string) +- `cidr`: The prefix length (integer) + +**Note**: For `/32` IPv4 networks (single host), `firstUsable` and `lastUsable` +are both set to the network address since there are no additional host addresses. + +```yaml +Type: object +``` + +## Exceptions + +The `parseCidr()` function raises errors for the following conditions: + +- **Missing prefix**: When the CIDR string doesn't include a prefix length + (e.g., `192.168.1.0` without `/24`) +- **Invalid IP address**: When the IP address portion is malformed +- **Invalid prefix length**: When the prefix is out of valid range or not a number + +## Related functions + +- [`cidrSubnet()`][02] - Creates a subnet from a larger CIDR block +- [`cidrHost()`][03] - Calculates a host IP address within a CIDR block +- [`parameters()`][04] - Retrieves parameter values + + +[01]: https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing +[02]: ./cidrSubnet.md +[03]: ./cidrHost.md +[04]: ./parameters.md diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 70df91fa0..5ee2bacd9 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -1293,4 +1293,265 @@ Describe 'tests for function expressions' { $expected = "Microsoft.DSC.Debug/Echo:$([Uri]::EscapeDataString($name))" $out.results[0].result.actualState.output | Should -BeExactly $expected } + + It 'parseCidr parses IPv4 CIDR notation: ' -TestCases @( + @{ cidr = '192.168.1.0/24'; network = '192.168.1.0'; broadcast = '192.168.1.255'; firstUsable = '192.168.1.1'; lastUsable = '192.168.1.254'; netmask = '255.255.255.0'; prefix = 24 } + @{ cidr = '10.0.0.0/16'; network = '10.0.0.0'; broadcast = '10.0.255.255'; firstUsable = '10.0.0.1'; lastUsable = '10.0.255.254'; netmask = '255.255.0.0'; prefix = 16 } + @{ cidr = '10.144.0.0/20'; network = '10.144.0.0'; broadcast = '10.144.15.255'; firstUsable = '10.144.0.1'; lastUsable = '10.144.15.254'; netmask = '255.255.240.0'; prefix = 20 } + @{ cidr = '172.16.0.0/12'; network = '172.16.0.0'; broadcast = '172.31.255.255'; firstUsable = '172.16.0.1'; lastUsable = '172.31.255.254'; netmask = '255.240.0.0'; prefix = 12 } + @{ cidr = '192.168.1.100/32'; network = '192.168.1.100'; broadcast = '192.168.1.100'; firstUsable = '192.168.1.100'; lastUsable = '192.168.1.100'; netmask = '255.255.255.255'; prefix = 32 } + ) { + param($cidr, $network, $broadcast, $firstUsable, $lastUsable, $netmask, $prefix) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr('$cidr')]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result = $out.results[0].result.actualState.output + $result.network | Should -BeExactly $network + $result.netmask | Should -BeExactly $netmask + $result.broadcast | Should -BeExactly $broadcast + $result.firstUsable | Should -BeExactly $firstUsable + $result.lastUsable | Should -BeExactly $lastUsable + $result.cidr | Should -Be $prefix + } + + It 'parseCidr parses IPv6 CIDR notation: ' -TestCases @( + @{ cidr = '2001:db8::/32'; network = '2001:db8::'; prefix = 32 } + @{ cidr = 'fe80::/64'; network = 'fe80::'; prefix = 64 } + @{ cidr = '2001:db8::1/128'; network = '2001:db8::1'; prefix = 128 } + ) { + param($cidr, $network, $prefix) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr('$cidr')]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result = $out.results[0].result.actualState.output + $result.network | Should -BeExactly $network + $result.cidr | Should -Be $prefix + $result.netmask | Should -Not -BeNullOrEmpty + $result.firstUsable | Should -Not -BeNullOrEmpty + $result.lastUsable | Should -Not -BeNullOrEmpty + } + + It 'parseCidr handles CIDR with host bits set' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr('192.168.1.100/24')]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result = $out.results[0].result.actualState.output + $result.network | Should -BeExactly '192.168.1.0' + $result.broadcast | Should -BeExactly '192.168.1.255' + } + + It 'parseCidr fails with invalid CIDR: ' -TestCases @( + @{ cidr = '192.168.1/24'; errorMatch = 'Invalid CIDR notation' } + @{ cidr = '192.168.1.0/33'; errorMatch = 'Invalid CIDR notation' } + @{ cidr = '192.168.1.256/24'; errorMatch = 'Invalid CIDR notation' } + ) { + param($cidr, $errorMatch) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr('$cidr')]" +"@ + $errorContent = & { $config_yaml | dsc config get -f - 2>&1 } | Out-String + $LASTEXITCODE | Should -Be 2 + $errorContent | Should -Match $errorMatch + } + + It 'cidrSubnet splits IPv4 network into subnets: / index ' -TestCases @( + @{ network = '10.144.0.0/20'; newCidr = 24; index = 0; expected = '10.144.0.0/24' } + @{ network = '10.144.0.0/20'; newCidr = 24; index = 1; expected = '10.144.1.0/24' } + @{ network = '10.144.0.0/20'; newCidr = 24; index = 2; expected = '10.144.2.0/24' } + @{ network = '10.144.0.0/20'; newCidr = 24; index = 3; expected = '10.144.3.0/24' } + @{ network = '10.144.0.0/20'; newCidr = 24; index = 4; expected = '10.144.4.0/24' } + @{ network = '10.144.0.0/20'; newCidr = 24; index = 15; expected = '10.144.15.0/24' } + @{ network = '10.0.0.0/16'; newCidr = 18; index = 0; expected = '10.0.0.0/18' } + @{ network = '10.0.0.0/16'; newCidr = 18; index = 1; expected = '10.0.64.0/18' } + @{ network = '192.168.0.0/24'; newCidr = 28; index = 0; expected = '192.168.0.0/28' } + ) { + param($network, $newCidr, $index, $expected) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrSubnet('$network', $newCidr, $index)]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly $expected + } + + It 'cidrSubnet splits IPv6 network into subnets' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrSubnet('2001:db8::/32', 48, 0)]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly '2001:db8::/48' + } + + It 'cidrSubnet with same prefix returns original network' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrSubnet('10.144.0.0/20', 20, 0)]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly '10.144.0.0/20' + } + + It 'cidrSubnet fails with invalid parameters: ' -TestCases @( + @{ testName = 'index out of range'; network = '10.144.0.0/20'; newCidr = 24; index = 16; errorMatch = 'out of range' } + @{ testName = 'negative index'; network = '10.144.0.0/20'; newCidr = 24; index = -1; errorMatch = 'negative' } + @{ testName = 'new CIDR too small'; network = '10.144.0.0/20'; newCidr = 16; index = 0; errorMatch = 'equal to or larger' } + @{ testName = 'invalid IPv4 prefix'; network = '10.144.0.0/20'; newCidr = 33; index = 0; errorMatch = 'Invalid IPv4 prefix' } + @{ testName = 'invalid IPv6 prefix'; network = '2001:db8::/32'; newCidr = 129; index = 0; errorMatch = 'Invalid IPv6 prefix' } + @{ testName = 'invalid CIDR format'; network = '10.0.0/16'; newCidr = 24; index = 0; errorMatch = 'Invalid CIDR notation' } + ) { + param($testName, $network, $newCidr, $index, $errorMatch) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrSubnet('$network', $newCidr, $index)]" +"@ + $errorContent = & { $config_yaml | dsc config get -f - 2>&1 } | Out-String + $LASTEXITCODE | Should -Be 2 + $errorContent | Should -Match $errorMatch + } + + It 'cidrSubnet can be used with parseCidr' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parseCidr(cidrSubnet('10.144.0.0/20', 24, 2))]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $result = $out.results[0].result.actualState.output + $result.network | Should -BeExactly '10.144.2.0' + $result.cidr | Should -Be 24 + } + + It 'cidrHost returns usable host IP: index ' -TestCases @( + @{ network = '192.168.1.0/24'; index = 0; expected = '192.168.1.1' } + @{ network = '192.168.1.0/24'; index = 1; expected = '192.168.1.2' } + @{ network = '192.168.1.0/24'; index = 10; expected = '192.168.1.11' } + @{ network = '192.168.1.0/24'; index = 253; expected = '192.168.1.254' } + @{ network = '10.0.0.0/16'; index = 0; expected = '10.0.0.1' } + @{ network = '10.0.0.0/16'; index = 99; expected = '10.0.0.100' } + @{ network = '10.0.0.0/16'; index = 255; expected = '10.0.1.0' } + ) { + param($network, $index, $expected) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrHost('$network', $index)]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly $expected + } + + It 'cidrHost handles /31 point-to-point: index ' -TestCases @( + @{ network = '192.168.1.0/31'; index = 0; expected = '192.168.1.0' } + @{ network = '192.168.1.0/31'; index = 1; expected = '192.168.1.1' } + ) { + param($network, $index, $expected) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrHost('$network', $index)]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly $expected + } + + It 'cidrHost works with IPv6' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrHost('2001:db8::/64', 0)]" +"@ + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly '2001:db8::1' + } + + It 'cidrHost fails with invalid parameters: ' -TestCases @( + @{ testName = '/32 has no usable hosts'; network = '192.168.1.1/32'; index = 0; errorMatch = 'no usable host' } + @{ testName = '/128 has no usable hosts'; network = '2001:db8::1/128'; index = 0; errorMatch = 'no usable host' } + @{ testName = 'index out of range'; network = '192.168.1.0/24'; index = 254; errorMatch = 'out of range' } + @{ testName = 'negative index'; network = '192.168.1.0/24'; index = -1; errorMatch = 'negative' } + @{ testName = 'invalid CIDR'; network = '192.168.1.0.0/24'; index = 0; errorMatch = 'Invalid CIDR notation' } + ) { + param($testName, $network, $index, $errorMatch) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[cidrHost('$network', $index)]" +"@ + $errorContent = & { $config_yaml | dsc config get -f - 2>&1 } | Out-String + $LASTEXITCODE | Should -Be 2 + $errorContent | Should -Match $errorMatch + } } diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index b9eb58fad..f00193ec2 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -43,6 +43,7 @@ uuid = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } which = { workspace = true } +ipnetwork = { workspace = true } # workspace crate dependencies dsc-lib-osinfo = { workspace = true } dsc-lib-security_context = { workspace = true } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 40e21a47d..5061ef205 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -463,6 +463,32 @@ keyNotObject = "Parameter '%{key}' is not an object" keyNotArray = "Parameter '%{key}' is not an array" keyNotFound = "Parameter '%{key}' not found in context" +[functions.parseCidr] +description = "Parses an IP address range in CIDR notation and returns network properties" +invoked = "parseCidr function" +invalidCidr = "Invalid CIDR notation: '%{cidr}'. Expected format: IP/prefix (e.g., '192.168.1.0/24' or '2001:db8::/32')" + +[functions.cidrHost] +description = "Calculates the usable IP address of the host with the specified index on the specified IP address range in CIDR notation" +invoked = "cidrHost function" +negativeHostIndex = "HostIndex cannot be negative" +invalidCidr = "Invalid CIDR notation: '%{cidr}'. Expected format: IP/prefix (e.g., '192.168.1.0/24' or '2001:db8::/32')" +noUsableHosts = "Network has no usable host addresses (single IP address)" +hostIndexOutOfRange = "Host index %{index} is out of range. Maximum index is %{maxIndex}" +hostCalculationFailed = "Failed to calculate host address" + +[functions.cidrSubnet] +description = "Splits the specified IP address range in CIDR notation into subnets with a new CIDR value and returns the IP address range of the subnet with the specified index" +invoked = "cidrSubnet function" +negativeSubnetIndex = "SubnetIndex cannot be negative" +invalidCidr = "Invalid CIDR notation: '%{cidr}'. Expected format: IP/prefix (e.g., '192.168.1.0/24' or '2001:db8::/32')" +invalidPrefixV4 = "Invalid IPv4 prefix: %{prefix}. Must be between 0 and 32" +invalidPrefixV6 = "Invalid IPv6 prefix: %{prefix}. Must be between 0 and 128" +newCidrTooSmall = "New CIDR prefix (%{newCidr}) must be equal to or larger than the current CIDR prefix (%{currentCidr})" +subnetIndexOutOfRange = "Subnet index %{index} is out of range. Maximum index is %{maxIndex}" +tooManySubnets = "The difference between new and current CIDR is too large (>32 bits). This would create too many subnets" +subnetCreationFailed = "Failed to create subnet" + [functions.path] description = "Concatenates multiple strings into a file path" traceArgs = "Executing path function with args: %{args}" diff --git a/lib/dsc-lib/src/functions/cidr_host.rs b/lib/dsc-lib/src/functions/cidr_host.rs new file mode 100644 index 000000000..aee3b66f8 --- /dev/null +++ b/lib/dsc-lib/src/functions/cidr_host.rs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::Function; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, FunctionCategory, FunctionMetadata}; +use crate::DscError; +use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network}; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct CidrHost {} + +impl Function for CidrHost { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "cidrHost".to_string(), + description: t!("functions.cidrHost.description").to_string(), + category: vec![FunctionCategory::Cidr], + min_args: 2, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::String], + vec![FunctionArgKind::Number], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::String], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.cidrHost.invoked")); + + let cidr_string = args[0].as_str().unwrap(); + let host_index = args[1].as_i64().unwrap(); + + if host_index < 0 { + return Err(DscError::FunctionArg( + "cidrHost".to_string(), + t!("functions.cidrHost.negativeHostIndex").to_string(), + )); + } + + let network = cidr_string.parse::().map_err(|_| { + DscError::FunctionArg( + "cidrHost".to_string(), + t!("functions.cidrHost.invalidCidr", cidr = cidr_string).to_string(), + ) + })?; + + let result = match network { + IpNetwork::V4(net) => calculate_ipv4_host(&net, host_index as u32)?, + IpNetwork::V6(net) => calculate_ipv6_host(&net, host_index as u128)?, + }; + + Ok(Value::String(result)) + } +} + +fn calculate_ipv4_host(net: &Ipv4Network, host_index: u32) -> Result { + let prefix = net.prefix(); + + // Special case: /32 has no usable hosts + if prefix == 32 { + return Err(DscError::FunctionArg( + "cidrHost".to_string(), + t!("functions.cidrHost.noUsableHosts").to_string(), + )); + } + + // Special case: /31 (point-to-point) has 2 usable hosts (both IPs are usable) + if prefix == 31 { + let host_ip = net.nth(host_index).ok_or_else(|| { + DscError::FunctionArg( + "cidrHost".to_string(), + t!( + "functions.cidrHost.hostIndexOutOfRange", + index = host_index, + maxIndex = 1 + ) + .to_string(), + ) + })?; + return Ok(host_ip.to_string()); + } + + // Regular case: skip network address (0) and broadcast (last) + // Usable hosts are at positions 1 to (size - 2) + let size = net.size(); + let max_usable_index = size.saturating_sub(2); // size - 2 (network and broadcast) + + if host_index >= max_usable_index { + return Err(DscError::FunctionArg( + "cidrHost".to_string(), + t!( + "functions.cidrHost.hostIndexOutOfRange", + index = host_index, + maxIndex = max_usable_index.saturating_sub(1) + ) + .to_string(), + )); + } + + // Get the host at position (host_index + 1) to skip network address + let host_ip = net.nth(host_index + 1).ok_or_else(|| { + DscError::FunctionArg( + "cidrHost".to_string(), + t!("functions.cidrHost.hostCalculationFailed").to_string(), + ) + })?; + + Ok(host_ip.to_string()) +} + +fn calculate_ipv6_host(net: &Ipv6Network, host_index: u128) -> Result { + let prefix = net.prefix(); + + if prefix == 128 { + return Err(DscError::FunctionArg( + "cidrHost".to_string(), + t!("functions.cidrHost.noUsableHosts").to_string(), + )); + } + + // /127 is a special case where both addresses are usable + if prefix == 127 { + if host_index > 1 { + return Err(DscError::FunctionArg( + "cidrHost".to_string(), + t!( + "functions.cidrHost.hostIndexOutOfRange", + index = host_index, + maxIndex = 1 + ) + .to_string(), + )); + } + + // For IPv6 /127, both addresses are usable + let host_ip = net.iter().nth(host_index as usize).ok_or_else(|| { + DscError::FunctionArg( + "cidrHost".to_string(), + t!("functions.cidrHost.hostCalculationFailed").to_string(), + ) + })?; + return Ok(host_ip.to_string()); + } + + // For IPv6, typically the network address (first) is not used for hosts + // but the broadcast concept doesn't apply. However, following the pattern: + // Skip the first address (network identifier) + // The last address in the subnet is technically usable unlike IPv4 + + // Check bounds - we need to be careful with large IPv6 networks + // For practical purposes, limit the index check + let host_bits = 128 - prefix; + + // If the network is very large (more than 32 host bits), we can't easily check bounds + if host_bits > 32 { + let actual_index = (host_index as usize).saturating_add(1); + let host_ip = net.iter().nth(actual_index).ok_or_else(|| { + DscError::FunctionArg( + "cidrHost".to_string(), + t!( + "functions.cidrHost.hostIndexOutOfRange", + index = host_index, + maxIndex = "unknown" + ) + .to_string(), + ) + })?; + return Ok(host_ip.to_string()); + } + + let size = 2_u128.pow(host_bits as u32); + let max_usable_index = size.saturating_sub(1); // Skip network address + + if host_index >= max_usable_index { + return Err(DscError::FunctionArg( + "cidrHost".to_string(), + t!( + "functions.cidrHost.hostIndexOutOfRange", + index = host_index, + maxIndex = max_usable_index.saturating_sub(1) + ) + .to_string(), + )); + } + + let actual_index = (host_index as usize).saturating_add(1); + let host_ip = net.iter().nth(actual_index).ok_or_else(|| { + DscError::FunctionArg( + "cidrHost".to_string(), + t!("functions.cidrHost.hostCalculationFailed").to_string(), + ) + })?; + + Ok(host_ip.to_string()) +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + + #[test] + fn cidr_host_ipv4_basic() { + let mut parser = Statement::new().unwrap(); + + let result = parser + .parse_and_execute("[cidrHost('192.168.1.0/24', 0)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "192.168.1.1"); + + let result = parser + .parse_and_execute("[cidrHost('192.168.1.0/24', 1)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "192.168.1.2"); + + let result = parser + .parse_and_execute("[cidrHost('192.168.1.0/24', 253)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "192.168.1.254"); + } + + #[test] + fn cidr_host_ipv4_larger_network() { + let mut parser = Statement::new().unwrap(); + + let result = parser + .parse_and_execute("[cidrHost('10.0.0.0/16', 0)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "10.0.0.1"); + + let result = parser + .parse_and_execute("[cidrHost('10.0.0.0/16', 99)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "10.0.0.100"); + } + + #[test] + fn cidr_host_ipv4_slash_31() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[cidrHost('192.168.1.0/31', 0)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "192.168.1.0"); + + let result = parser + .parse_and_execute("[cidrHost('192.168.1.0/31', 1)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "192.168.1.1"); + } + + #[test] + fn cidr_host_ipv4_slash_32_no_hosts() { + let mut parser = Statement::new().unwrap(); + + let result = parser.parse_and_execute("[cidrHost('192.168.1.1/32', 0)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_host_ipv4_index_out_of_range() { + let mut parser = Statement::new().unwrap(); + + let result = parser.parse_and_execute("[cidrHost('192.168.1.0/24', 254)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_host_ipv4_negative_index() { + let mut parser = Statement::new().unwrap(); + + let result = parser.parse_and_execute("[cidrHost('192.168.1.0/24', -1)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_host_ipv6_basic() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[cidrHost('2001:db8::/64', 0)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "2001:db8::1"); + } + + #[test] + fn cidr_host_ipv6_slash_127() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[cidrHost('2001:db8::1:0/127', 0)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "2001:db8::1:0"); + + let result = parser + .parse_and_execute("[cidrHost('2001:db8::1:0/127', 1)]", &Context::new()) + .unwrap(); + assert_eq!(result.as_str().unwrap(), "2001:db8::1:1"); + } + + #[test] + fn cidr_host_ipv6_slash_128_no_hosts() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[cidrHost('2001:db8::1/128', 0)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_host_invalid_cidr() { + let mut parser = Statement::new().unwrap(); + + let result = parser.parse_and_execute("[cidrHost('invalid', 0)]", &Context::new()); + assert!(result.is_err()); + } +} diff --git a/lib/dsc-lib/src/functions/cidr_subnet.rs b/lib/dsc-lib/src/functions/cidr_subnet.rs new file mode 100644 index 000000000..af0109fbe --- /dev/null +++ b/lib/dsc-lib/src/functions/cidr_subnet.rs @@ -0,0 +1,325 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::Function; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, FunctionCategory, FunctionMetadata}; +use crate::DscError; +use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network}; +use rust_i18n::t; +use serde_json::Value; +use std::net::{Ipv4Addr, Ipv6Addr}; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct CidrSubnet {} + +impl Function for CidrSubnet { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "cidrSubnet".to_string(), + description: t!("functions.cidrSubnet.description").to_string(), + category: vec![FunctionCategory::Cidr], + min_args: 3, + max_args: 3, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::String], + vec![FunctionArgKind::Number], + vec![FunctionArgKind::Number], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::String], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.cidrSubnet.invoked")); + + let cidr_string = args[0].as_str().unwrap(); + let new_cidr = args[1].as_i64().unwrap() as u8; + let subnet_index = args[2].as_i64().unwrap(); + + if subnet_index < 0 { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!("functions.cidrSubnet.negativeSubnetIndex").to_string(), + )); + } + + let network = cidr_string.parse::().map_err(|_| { + DscError::FunctionArg( + "cidrSubnet".to_string(), + t!("functions.cidrSubnet.invalidCidr", cidr = cidr_string).to_string(), + ) + })?; + + let result = match network { + IpNetwork::V4(net) => { + if new_cidr > 32 { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!("functions.cidrSubnet.invalidPrefixV4", prefix = new_cidr).to_string(), + )); + } + + if new_cidr < net.prefix() { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!( + "functions.cidrSubnet.newCidrTooSmall", + newCidr = new_cidr, + currentCidr = net.prefix() + ) + .to_string(), + )); + } + + calculate_ipv4_subnet(net, new_cidr, subnet_index as usize)? + } + IpNetwork::V6(net) => { + if new_cidr > 128 { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!("functions.cidrSubnet.invalidPrefixV6", prefix = new_cidr).to_string(), + )); + } + + if new_cidr < net.prefix() { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!( + "functions.cidrSubnet.newCidrTooSmall", + newCidr = new_cidr, + currentCidr = net.prefix() + ) + .to_string(), + )); + } + + calculate_ipv6_subnet(net, new_cidr, subnet_index as usize)? + } + }; + + Ok(Value::String(result)) + } +} + +fn calculate_ipv4_subnet( + net: Ipv4Network, + new_prefix: u8, + index: usize, +) -> Result { + let old_prefix = net.prefix(); + let network_addr = net.network(); + + let subnet_bits = new_prefix - old_prefix; + let num_subnets = 2_usize.pow(subnet_bits as u32); + + if index >= num_subnets { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!( + "functions.cidrSubnet.subnetIndexOutOfRange", + index = index, + maxIndex = num_subnets - 1 + ) + .to_string(), + )); + } + + let host_bits = 32 - new_prefix; + let subnet_size = 2_u32.pow(host_bits as u32); + + let base_addr = u32::from(network_addr); + let subnet_addr = base_addr + (index as u32 * subnet_size); + let subnet_ip = Ipv4Addr::from(subnet_addr); + + let subnet = Ipv4Network::new(subnet_ip, new_prefix).map_err(|_| { + DscError::FunctionArg( + "cidrSubnet".to_string(), + t!("functions.cidrSubnet.subnetCreationFailed").to_string(), + ) + })?; + + Ok(format!("{}/{}", subnet.network(), new_prefix)) +} + +fn calculate_ipv6_subnet( + net: Ipv6Network, + new_prefix: u8, + index: usize, +) -> Result { + let old_prefix = net.prefix(); + let network_addr = net.network(); + + let subnet_bits = new_prefix - old_prefix; + + if subnet_bits > 32 { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!("functions.cidrSubnet.tooManySubnets").to_string(), + )); + } + + let num_subnets = 2_usize.pow(subnet_bits as u32); + + if index >= num_subnets { + return Err(DscError::FunctionArg( + "cidrSubnet".to_string(), + t!( + "functions.cidrSubnet.subnetIndexOutOfRange", + index = index, + maxIndex = num_subnets - 1 + ) + .to_string(), + )); + } + + let host_bits = 128 - new_prefix; + let base_addr = u128::from(network_addr); + + let subnet_size = if host_bits < 128 { + 2_u128.pow(host_bits as u32) + } else { + 0 + }; + + let subnet_addr = base_addr + (index as u128 * subnet_size); + let subnet_ip = Ipv6Addr::from(subnet_addr); + + let subnet = Ipv6Network::new(subnet_ip, new_prefix).map_err(|_| { + DscError::FunctionArg( + "cidrSubnet".to_string(), + t!("functions.cidrSubnet.subnetCreationFailed").to_string(), + ) + })?; + + Ok(format!("{}/{}", subnet.network(), new_prefix)) +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + + #[test] + fn cidr_subnet_ipv4_basic() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[cidrSubnet('10.144.0.0/20', 24, 0)]", &Context::new()) + .unwrap(); + + assert_eq!(result.as_str().unwrap(), "10.144.0.0/24"); + } + + #[test] + fn cidr_subnet_ipv4_multiple_subnets() { + let mut parser = Statement::new().unwrap(); + + // Test first few subnets of 10.144.0.0/20 split into /24s + let test_cases = vec![ + (0, "10.144.0.0/24"), + (1, "10.144.1.0/24"), + (2, "10.144.2.0/24"), + (3, "10.144.3.0/24"), + (4, "10.144.4.0/24"), + (15, "10.144.15.0/24"), + ]; + + for (index, expected) in test_cases { + let result = parser + .parse_and_execute( + &format!("[cidrSubnet('10.144.0.0/20', 24, {})]", index), + &Context::new(), + ) + .unwrap(); + + assert_eq!(result.as_str().unwrap(), expected); + } + } + + #[test] + fn cidr_subnet_ipv4_larger_subnets() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[cidrSubnet('10.0.0.0/16', 18, 0)]", &Context::new()) + .unwrap(); + + assert_eq!(result.as_str().unwrap(), "10.0.0.0/18"); + + let result = parser + .parse_and_execute("[cidrSubnet('10.0.0.0/16', 18, 1)]", &Context::new()) + .unwrap(); + + assert_eq!(result.as_str().unwrap(), "10.0.64.0/18"); + } + + #[test] + fn cidr_subnet_ipv6() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[cidrSubnet('2001:db8::/32', 48, 0)]", &Context::new()) + .unwrap(); + + assert_eq!(result.as_str().unwrap(), "2001:db8::/48"); + + let result = parser + .parse_and_execute("[cidrSubnet('2001:db8::/32', 48, 1)]", &Context::new()) + .unwrap(); + + assert_eq!(result.as_str().unwrap(), "2001:db8:1::/48"); + } + + #[test] + fn cidr_subnet_invalid_index() { + let mut parser = Statement::new().unwrap(); + // 10.144.0.0/20 split into /24s gives 16 subnets (0-15) + let result = + parser.parse_and_execute("[cidrSubnet('10.144.0.0/20', 24, 16)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_subnet_negative_index() { + let mut parser = Statement::new().unwrap(); + let result = + parser.parse_and_execute("[cidrSubnet('10.144.0.0/20', 24, -1)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_subnet_new_cidr_too_small() { + let mut parser = Statement::new().unwrap(); + // New CIDR must be >= current CIDR + let result = + parser.parse_and_execute("[cidrSubnet('10.144.0.0/20', 16, 0)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_subnet_invalid_prefix_v4() { + let mut parser = Statement::new().unwrap(); + let result = + parser.parse_and_execute("[cidrSubnet('10.144.0.0/20', 33, 0)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_subnet_invalid_prefix_v6() { + let mut parser = Statement::new().unwrap(); + let result = + parser.parse_and_execute("[cidrSubnet('2001:db8::/32', 129, 0)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn cidr_subnet_same_prefix() { + let mut parser = Statement::new().unwrap(); + // If new CIDR == current CIDR, only 1 subnet exists (index 0) + let result = parser + .parse_and_execute("[cidrSubnet('10.144.0.0/20', 20, 0)]", &Context::new()) + .unwrap(); + + assert_eq!(result.as_str().unwrap(), "10.144.0.0/20"); + } +} diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 94d008202..6cd7810f7 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -18,6 +18,8 @@ pub mod array; pub mod base64; pub mod base64_to_string; pub mod bool; +pub mod cidr_host; +pub mod cidr_subnet; pub mod coalesce; pub mod concat; pub mod contains; @@ -55,6 +57,7 @@ pub mod not; pub mod null; pub mod or; pub mod parameters; +pub mod parse_cidr; pub mod path; pub mod range; pub mod reference; @@ -149,6 +152,8 @@ impl FunctionDispatcher { Box::new(base64::Base64{}), Box::new(base64_to_string::Base64ToString{}), Box::new(bool::Bool{}), + Box::new(cidr_host::CidrHost{}), + Box::new(cidr_subnet::CidrSubnet{}), Box::new(coalesce::Coalesce{}), Box::new(concat::Concat{}), Box::new(contains::Contains{}), @@ -186,6 +191,7 @@ impl FunctionDispatcher { Box::new(null::Null{}), Box::new(or::Or{}), Box::new(parameters::Parameters{}), + Box::new(parse_cidr::ParseCidr{}), Box::new(path::Path{}), Box::new(range::Range{}), Box::new(reference::Reference{}), @@ -340,6 +346,7 @@ pub struct FunctionDefinition { #[serde(deny_unknown_fields)] pub enum FunctionCategory { Array, + Cidr, Comparison, Date, Deployment, @@ -356,6 +363,7 @@ impl Display for FunctionCategory { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { FunctionCategory::Array => write!(f, "Array"), + FunctionCategory::Cidr => write!(f, "CIDR"), FunctionCategory::Comparison => write!(f, "Comparison"), FunctionCategory::Date => write!(f, "Date"), FunctionCategory::Deployment => write!(f, "Deployment"), diff --git a/lib/dsc-lib/src/functions/parse_cidr.rs b/lib/dsc-lib/src/functions/parse_cidr.rs new file mode 100644 index 000000000..7f9911bee --- /dev/null +++ b/lib/dsc-lib/src/functions/parse_cidr.rs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::Function; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, FunctionCategory, FunctionMetadata}; +use crate::DscError; +use ipnetwork::IpNetwork; +use rust_i18n::t; +use serde_json::{json, Value}; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct ParseCidr {} + +impl Function for ParseCidr { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "parseCidr".to_string(), + description: t!("functions.parseCidr.description").to_string(), + category: vec![FunctionCategory::Cidr], + min_args: 1, + max_args: 1, + accepted_arg_ordered_types: vec![vec![FunctionArgKind::String]], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Object], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.parseCidr.invoked")); + + let cidr_string = args[0].as_str().unwrap(); + + // Validate that the input contains a CIDR prefix (contains '/') + if !cidr_string.contains('/') { + return Err(DscError::FunctionArg( + "parseCidr".to_string(), + t!("functions.parseCidr.invalidCidr", cidr = cidr_string).to_string(), + )); + } + + let network = cidr_string.parse::().map_err(|_| { + DscError::FunctionArg( + "parseCidr".to_string(), + t!("functions.parseCidr.invalidCidr", cidr = cidr_string).to_string(), + ) + })?; + + let result = match network { + IpNetwork::V4(net) => { + let network_addr = net.network(); + let broadcast_addr = net.broadcast(); + let first_usable = if net.prefix() == 32 { + network_addr + } else { + let first_ip = u32::from(network_addr); + std::net::Ipv4Addr::from(first_ip + 1) + }; + let last_usable = if net.prefix() == 32 { + broadcast_addr + } else { + let last_ip = u32::from(broadcast_addr); + std::net::Ipv4Addr::from(last_ip - 1) + }; + + json!({ + "network": network_addr.to_string(), + "netmask": net.mask().to_string(), + "broadcast": broadcast_addr.to_string(), + "firstUsable": first_usable.to_string(), + "lastUsable": last_usable.to_string(), + "cidr": net.prefix() + }) + } + IpNetwork::V6(net) => { + let network_addr = net.network(); + let broadcast_addr = net.broadcast(); + + json!({ + "network": network_addr.to_string(), + "netmask": net.mask().to_string(), + "firstUsable": network_addr.to_string(), + "lastUsable": broadcast_addr.to_string(), + "cidr": net.prefix() + }) + } + }; + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + + #[test] + fn parse_cidr_ipv4_standard() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[parseCidr('192.168.1.0/24')]", &Context::new()) + .unwrap(); + + let obj = result.as_object().unwrap(); + assert_eq!(obj.get("network").unwrap().as_str().unwrap(), "192.168.1.0"); + assert_eq!( + obj.get("netmask").unwrap().as_str().unwrap(), + "255.255.255.0" + ); + assert_eq!( + obj.get("broadcast").unwrap().as_str().unwrap(), + "192.168.1.255" + ); + assert_eq!( + obj.get("firstUsable").unwrap().as_str().unwrap(), + "192.168.1.1" + ); + assert_eq!( + obj.get("lastUsable").unwrap().as_str().unwrap(), + "192.168.1.254" + ); + assert_eq!(obj.get("cidr").unwrap().as_u64().unwrap(), 24); + } + + #[test] + fn parse_cidr_ipv4_slash_32() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[parseCidr('192.168.1.100/32')]", &Context::new()) + .unwrap(); + + let obj = result.as_object().unwrap(); + assert_eq!( + obj.get("network").unwrap().as_str().unwrap(), + "192.168.1.100" + ); + assert_eq!( + obj.get("broadcast").unwrap().as_str().unwrap(), + "192.168.1.100" + ); + assert_eq!( + obj.get("firstUsable").unwrap().as_str().unwrap(), + "192.168.1.100" + ); + assert_eq!( + obj.get("lastUsable").unwrap().as_str().unwrap(), + "192.168.1.100" + ); + assert_eq!(obj.get("cidr").unwrap().as_u64().unwrap(), 32); + } + + #[test] + fn parse_cidr_ipv4_slash_16() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[parseCidr('10.0.0.0/16')]", &Context::new()) + .unwrap(); + + let obj = result.as_object().unwrap(); + assert_eq!(obj.get("network").unwrap().as_str().unwrap(), "10.0.0.0"); + assert_eq!(obj.get("netmask").unwrap().as_str().unwrap(), "255.255.0.0"); + assert_eq!( + obj.get("broadcast").unwrap().as_str().unwrap(), + "10.0.255.255" + ); + assert_eq!( + obj.get("firstUsable").unwrap().as_str().unwrap(), + "10.0.0.1" + ); + assert_eq!( + obj.get("lastUsable").unwrap().as_str().unwrap(), + "10.0.255.254" + ); + assert_eq!(obj.get("cidr").unwrap().as_u64().unwrap(), 16); + } + + #[test] + fn parse_cidr_ipv4_slash_20() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[parseCidr('10.144.0.0/20')]", &Context::new()) + .unwrap(); + + let obj = result.as_object().unwrap(); + assert_eq!(obj.get("network").unwrap().as_str().unwrap(), "10.144.0.0"); + assert_eq!( + obj.get("netmask").unwrap().as_str().unwrap(), + "255.255.240.0" + ); + assert_eq!( + obj.get("broadcast").unwrap().as_str().unwrap(), + "10.144.15.255" + ); + assert_eq!( + obj.get("firstUsable").unwrap().as_str().unwrap(), + "10.144.0.1" + ); + assert_eq!( + obj.get("lastUsable").unwrap().as_str().unwrap(), + "10.144.15.254" + ); + assert_eq!(obj.get("cidr").unwrap().as_u64().unwrap(), 20); + } + + #[test] + fn parse_cidr_ipv6() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[parseCidr('2001:db8::/32')]", &Context::new()) + .unwrap(); + + let obj = result.as_object().unwrap(); + assert_eq!(obj.get("network").unwrap().as_str().unwrap(), "2001:db8::"); + assert_eq!(obj.get("cidr").unwrap().as_u64().unwrap(), 32); + assert!(obj.get("netmask").is_some()); + assert!(obj.get("broadcast").is_none()); + assert!(obj.get("firstUsable").is_some()); + assert!(obj.get("lastUsable").is_some()); + } + + #[test] + fn parse_cidr_ipv6_full() { + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[parseCidr('fe80::1/64')]", &Context::new()) + .unwrap(); + + let obj = result.as_object().unwrap(); + assert_eq!(obj.get("network").unwrap().as_str().unwrap(), "fe80::"); + assert_eq!(obj.get("cidr").unwrap().as_u64().unwrap(), 64); + assert!(obj.get("netmask").is_some()); + assert!(obj.get("broadcast").is_none()); + } + + #[test] + fn parse_cidr_invalid_format() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[parseCidr('invalid')]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn parse_cidr_invalid_prefix() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[parseCidr('192.168.1.0/33')]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn parse_cidr_no_prefix() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[parseCidr('192.168.1.0')]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn parse_cidr_with_host_bits() { + // CIDR with host bits set should still be parsed (normalized to network address) + let mut parser = Statement::new().unwrap(); + let result = parser + .parse_and_execute("[parseCidr('192.168.1.100/24')]", &Context::new()) + .unwrap(); + + let obj = result.as_object().unwrap(); + assert_eq!(obj.get("network").unwrap().as_str().unwrap(), "192.168.1.0"); + assert_eq!( + obj.get("broadcast").unwrap().as_str().unwrap(), + "192.168.1.255" + ); + } +}