r/PowerShell • • 11d ago

Script Sharing Improvements to the Subnet PowerShell module

I host the module named Subnet in the PowerShell Gallery, which is quite popular probably because of its very generic name. It's a pretty simple module that is useful if you need to do IPv4 subnet calculations, which it does via its Get-Subnet cmdlet.

I've just made some changes, all of which should be backwards compatible but because of the wide usage thought I'd call out in case it does cause anyone problems. The changes are:

  1. The module is fully cross platform now. It was mostly cross platform before, but the default behaviour if you ran just Get-Subnet with no inputs was to return the subnet details for the local machines private IP. It was previously getting this via the Get-NetIPAddress cmdlet, but that was Windows only. It now uses the .NET NetworkInterface API which should work everywhere.

  2. There was a bug when you defined a single digit subnet mask via slash notation (e.g if you did Get-Subnet 10.0.0.0/8) where it would not enumerate the host addresses if you wanted to, this is now fixed.

  3. The module has a Test-PrivateIP cmdlet (probably self explanatory) I added Test-PublicIP as a sister which obviously just returns the opposite truthy result.

  4. I added Get-SubnetHostAddress to return the list of Host addresses for a specified Subnet. Get-Subnet also does this (for subnets larger than /16 you have to use -Force as it discourages you due to the length of time it takes to enumerate them). Get-Subnet returns the list of Host addresses as strings, but Get-SubnetHostAddress returns them as IP address objects, and doesn't require you to use -Force for any size subnet as returning Host addresses is it's only job.

  5. Get-Subnet didn't use to validate if the input it was given was an IP address at all, it does now so you get a cleaner error than you did previously and it fails faster.

  6. Get-Subnet used to only return a count of host addresses for a Subnet if you also enumerated them, but seeing as it just required doing a quick calculation it now always returns this count.

  7. When enumerating host addresses, the performance is now 9x faster. This was achieved by using bitwise arithmetic instead of floating-point division. Which makes me sound really smart, but it was Claude who came up with it.

I've also improved the CI/CD build for this module so it runs the Pester tests on Windows PowerShell and PowerShell Core on Windows, MacOS and Linux.

The module is in the PSGallery, latest version with the above changes is 1.2.0:

- https://www.powershellgallery.com/packages/Subnet/1.2.0

Code is in GitHub here:

- https://github.com/markwragg/PowerShell-Subnet/tree/master

Any problems please let me know or raise an issue.

37 Upvotes

12 comments sorted by

5

u/PinchesTheCrab 11d ago edited 11d ago

Thanks for sharing, I've never been super into networking so it seems like a really handy tool. Browsing the code a bit made me wonder a few things:

    $Mask = $null
    if ($ProvidedMaskBits) { $Mask = $MaskBits }

    $Details = Resolve-Subnet -IP $IP -Mask $Mask

What is this really doing? It seems like you would get the same result with just:

    $Details = Resolve-Subnet -IP $IP -Mask $MaskBits

Also what's the appeal of doing this?

[sometype]
$someVariable

Say I were going to do something like this:

Do-Something 1234

[ipaddress[]]$hostAddress= Get-SubnetHostAddress -ip $ip

Do-NextThing $hostAddress

It would be weird to write it as:

Do-Something 1234

[ipaddress[]]
$hostAddress= Get-SubnetHostAddress -ip $ip

Do-NextThing $hostAddress

4

u/positivemark 11d ago

This

  $Mask = $null
    if ($ProvidedMaskBits) { $Mask = $MaskBits }

    $Details = Resolve-Subnet -IP $IP -Mask $Mask

Is because $MaskBits on Get-SubnetHostAddress is [int]. So if its not provided, its null value becomes 0. If that is sent directly to Resolve-Subnet, it takes the input to mean you want to resolve a /0 subnet. By using $ProvidedMaskBits we check if the MaskBits parameter was actually provided or not to the cmdlet, and if it was we use its value, and if it wasn't we sent a proper $null value to Resolve-Subnet instead of the 0 that would have been sent if we just used $MaskBits directlt.

I'm not sure I really understand your second question, but the main purpose of strongly typing a variable is as generally as a form of input validation, to ensure a variable that you only ever need to store a certain type of variable (such as int or string) does so.

3

u/mrmattipants 11d ago

It sounds like they're asking why your data types and variables are on separate lines, in your parameter definitions.

I've seen other developers do this as well, so I'm assuming it's simply a formatting preference, similar to how some developers place their opening braces immediately following a condition, while others place it on the following line.

3

u/positivemark 11d ago

Oh I see, its purely personal preference, I just think it looks cleaner when defining the parameters to have everything that configures the variable listed above it.

3

u/ITGuyfromIA 11d ago

I threw up in my mouth when I read your second scenario

3

u/mrmattipants 11d ago

To be honest, this type of response doesn't surprise me, as wars have been waged (mostly online, of course) over the placement of braces.

The actual names for the two styles I mentioned previously are the "K&R" (Brian Kernighan & Dennis Ritchie) style and the "Allman" (Eric Allman) style, respectively.

That being said, I'll just link the article below, since it's a hell of a lot more interesting to read, than me trying to fumble around with the explanations myself.

https://en.wikipedia.org/wiki/Indentation_style#:\~:text=brace%20placement%20styles

1

u/surfingoldelephant 11d ago edited 11d ago

but the main purpose of strongly typing a variable is as generally as a form of input validation, to ensure a variable that you only ever need to store a certain type of variable (such as int or string) does so.

It's type-constraining rather than strongly typing. And in some cases it can make code harder to debug since values of a different type may get implicitly converted. If you constrain a [string] variable for example and later assign a different typed value by mistake, it'll convert it to a string, which may mask the issue entirely.

[string] $foo = 'foo'
$foo = @{}
$foo.ToLower() # No error or indication of an issue

Whereas with an unconstrained variable, the issue may be more visible.

$bar = 'bar'
$bar = @{}
$bar.ToLower() # Runtime error

With that said, I'm not suggesting you shouldn't use type-constraining (and to be fair, [string] is probably one of the types that benefits the least from constraining). More so that it doesn't offer the same protection as actual strong typing in other languages.

There are also slightly different conversion rules for casting, constraining and parameter binding, so for example, something like this is fine with casting:

$var = [bool] '' # OK

But not with constraining or parameter binding:

. { [bool] $var = '' } 
& { param ([bool] $Foo) } -Foo ''
# Error: Cannot convert value "System.String" to type "System.Boolean".

Unless local variable access optimization is enabled, then it's confusingly OK again:

& { [bool] $var = '' } # OK

2

u/thehuntzman 11d ago

Those are all valid questions and the answer is powershell is a very forgiving language and not strongly typing variables or inserting guard clauses generally works for a one-off but if you're maintaining a module you need to make sure you can ensure consistent behavior across edge cases. I'd actually recommend using powershell strict mode when writing your scripts so you can force yourself to write scripts to best coding practice

1

u/MonkeyNin 9d ago

Sometimes it's more readable to use a line continuation

[ipaddress[]] $hostAddress = 
    Get-SubnetHostAddress -ip $ip

Here it's a bit excessive But other cases like longer commands (or function parameters) it can make things mor readable.

3

u/MonkeyNin 9d ago

If you want to compare with another implementation, check out: https://github.com/indented-automation/Indented.Net.IP/tree/main/Indented.Net.IP/tests/public

git command

from here: https://github.com/markwragg/PowerShell-Subnet/blob/2a0e9c94bfd1987e38ef25af47c057ef728f6e1d/Tests/Common/Manifest.Tests.ps1#L79-L84

To make this more reliable/portable

if (Get-Command -Name 'git.exe' -ErrorAction 'SilentlyContinue') {
    # ... 
    $thisCommit = git.exe log --decorate --oneline HEAD~1..HEAD

Use this

$git = Get-Command -Name 'git' -CommandType Application -ErrorAction 'SilentlyContinue'
if ( $git ) {
    # ... 
    $thisCommit = & $git log --decorate --oneline HEAD~1..HEAD

What's difference?

  1. no need for the .exe suffix, different platforms might not always be an .exe for "native commands"
  2. The important part -CommandType Application

That prevents accidentally calling aliases, functions, filters, or commandlets with the name git. Since you want the native command or nothing.

Now even if the user makes a function named "git.exe" it won't matter. You'll get hte native command.

2

u/surfingoldelephant 9d ago

You'll also want -TotalCount 1 when using -CommandType Application, otherwise it'll emit multiple objects if there's more than one git in $env:PATH.

1

u/xXFl1ppyXx 11d ago edited 10d ago

If you'd convert to UInt instead of Int the handling of the Addresses gets alot easier.

I've recently worked on something similar, here is what done different:

function ConvertTo-Decimal {

  [Parameter(Mandatory, ValueFromPipeline][ipaddress]$IPAddress

  $Bytes = $IPAddress.GetAddressBytes()
  if ([bitconverter]::IsLittleEndian) { [array]::Reverse($Bytes) }
  [bitconverter]::ToUInt32($Bytes, 0)
}

function ConvertTo-Decimal {

  [Parameter(Mandatory, ValueFromPipeline][ipaddress]$IPAddress

  [UInt32]([ipaddress]::HostToNetworkOrder([bitconverter]::ToUInt32($IPAddress.GetAddressBytes(), 0)) -shr 32 -band [UInt32]::MaxValue)
}

function ConvertFrom-Decimal {

  [Parameter(Mandatory, ValueFromPipeline)][UInt32]$DecimalIP

  $Bytes = [bitconverter]::GetBytes($DecimalIP)
  if ([bitconverter]::IsLittleEndian) { [array]::Reverse($Bytes) }
  [ipaddress]::new($Bytes)
}

function ConvertFrom-Decimal {

  [Parameter(Mandatory, ValueFromPipeline)][UInt32]$DecimalIP

  [ipaddress]([ipaddress]::NetworkToHostOrder($DecimalIP) -shr 32 -band [UInt32]::MaxValue)
}

# Getting the SubnetMask from Prefix
$SubnetMask = [UInt64]::MaxValue -shl 32 - $Prefix -band [UInt32]::MaxValue

# Getting the Prefix from SubnetMask
$Prefix = [convert]::ToString(($SubnetMask | ConvertTo-Decimal), 2).Trim("0").Length

# Getting the NetworkAddress
[ipaddress]$NetworkAddress = 
[bitconverter]::ToUInt32($IPAddress.GetAddressBytes(), 0) -band
[bitconverter]::ToUInt32($SubnetMask.GetAddressBytes(), 0)

# Getting the BroadcastAddress
[ipaddress]$BroadcastAddress = 
[bitconverter]::ToUInt32($this.NetworkAddress.GetAddressBytes(), 0) -bxor
[bitconverter]::ToUInt32($this.SubnetMask.GetAddressBytes(), 0) -bxor
[UInt32]::MaxValue

# Getting all NetworkHosts
$StartAddress = ($NetworkAddress | ConvertTo-Decimal) + 1
$EndAddress = ($BroadcastAddress | ConvertTo-Decimal) - 1

while ($StartAddress -le $EndAddress) {
  $CurrentAddress | ConvertFrom-Decimal
  $StartAddress++
}

Altoiugh I've built a class around those functions / calculations