Powershell sort IP Addresses in txt file

Viewed 511

I have a plain text file containing some IPs like this:

194.225.0.0 - 194.225.15.255
194.225.24.0 - 194.225.31.255
62.193.0.0 - 62.193.31.255
195.146.53.128 - 195.146.53.225
217.218.0.0 - 217.219.255.255
195.146.40.0 - 195.146.40.255
85.185.240.128 - 85.185.240.159
78.39.194.0 - 78.39.194.255
78.39.193.192 - 78.39.193.207

I want to sort file by IP Addresses. I mean only the first part is important.

I googled and found some programs but I want to know whether that's possible via Powershell with no other applications.

I have a Linux way like this but was unable to reach it in Windows:

sort -n -t . -k 1,1 -k 2,2 -k 3,3 -k 4,4 file

Update1

@TheMadTechnician, this is the output when I run your command:

85.185.240.128 - 85.185.240.159
195.146.40.0 - 195.146.40.255
78.39.193.192 - 78.39.193.207
78.39.194.0 - 78.39.194.255
217.218.0.0 - 217.219.255.255
194.225.24.0 - 194.225.31.255
194.225.0.0 - 194.225.15.255
195.146.53.128 - 195.146.53.225
62.193.0.0 - 62.193.31.255
7 Answers

A simple solution using RegEx-replace: To make IP addresses sortable, we just need to pad each octet on the left side so they all have the same width. Then a simple string comparison yields the correct result.

For PS 6+:

Get-Content IpList.txt | Sort-Object {
    $_ -replace '\d+', { $_.Value.PadLeft(3, '0') }
}

For PS 5.x:

Get-Content IpList.txt | Sort-Object {
    [regex]::Replace( $_, '\d+', { $args.Value.PadLeft(3, '0') } )
}
  • The -replace operator tries to find matches of a regular expression pattern within a given string and replaces them by given values.
  • For PS 5.x we need a different syntax, because -replace doesn't support a scriptblock. Using the .NET Regex.Replace method we can achieve the same.
  • The first $_ denotes the current line of the text file.
  • \d+ is a pattern that matches each octet of each IP address. For detailed explanation see example at regex101.
  • {} defines a scriptblock that outputs the replacement value
    • Here $_ denotes the current match (octet). We take its value and fill it with zeros on the left side, so each octet will be 3 characters in total (e. g. 2 becomes 002 and 92 becomes 092). A final IP may look like 194.225.024.000.

Another solution using the Tuple class. It is slightly longer, but cleaner because it actually compares numbers instead of strings.

Get-Content IpList.txt | Sort-Object {
    # Extract the first 4 numbers from the current line
    [int[]] $octets = [regex]::Matches( $_, '\d+' )[ 0..3 ].Value
    
    # Create and output a tuple that consists of the numbers
    [Tuple]::Create( $octets[0], $octets[1], $octets[2], $octets[3] )  
}
  • Using [regex]::Matches() we find all numbers of the current line. From the returned MatchCollection we take the first four elements. Then we use member access enumeration to create a string array of the Value member of each MatchCollection element.

  • By simply assigning the string array to a variable with the [int[]] type constraint (array of ints), PowerShell automatically parses the strings as integers.

  • The sorting works because Tuple implements the IComparable interface, which Sort-Object uses when available. Tuples are sorted lexicographically, as expected.

  • Using dynamic method invocation, we may shorten the [Tuple]::Create call like this (which works for up to 8 elements1):

     [Tuple]::Create.Invoke( [object[]] $octets )
    

    Note the conversion to [object[]], otherwise [Tuple]::Create would be called with only a single argument, that is the $octets array.


[1] Actually tuples bigger than 8 elements can be created by creating nested tuples (create a tuple for the remaining elements and store it in the last element of the base tuple). To do this generically, it would require either recursion or a reverse loop, creating most nested tuple first.

This answer was originally posted as my comments in a different answer

You can convert the IP address from a string object to a version object, which "coincidentally" has the same format as an IP address (4 sets of numbers separated by a .)

Get-Content .\abc.txt | Sort-Object { [System.Version]($_).split("-")[1] }

TheMadTechnician's answer works as long as the range start addresses differ in the first octet. To get it to sort by multiple octets, it doesn't look like Sort-Object will sort by successive values in an array returned by a single [ScriptBlock]; for that you'd need to pass a [ScriptBlock] for each octet. Santiago Squarzon's answer shows how to do that without the repetition of defining four almost-identical [ScriptBlock]s.

Instead, a single [ScriptBlock] can combine each octet into a [UInt32] on which to sort.

Using [Math]::Pow() to produce a sortable value

Get-Content -Path 'IPv4AddressRanges.txt' |
    Sort-Object -Property {
        # Split each line on a hyphen surrounded by optional whitespace
        $rangeStartAddress = ($_ -split '\s*-\s*')[0]
        # Split the start address on a period and parse the resulting [String]s to [Byte]s
        [Byte[]] $octets = $rangeStartAddress -split '.', 0, 'SimpleMatch'

        #TODO: Handle $octets.Length -ne 4
        # Alternative: [Byte[]] $octets = [IPAddress]::Parse($rangeStartAddress).GetAddressBytes()

        [UInt32] $sortValue = 0
        # $sortValue = (256 ^ 3) * $octets[0] + (256 ^ 2) * $octets[1] + 256 * $octets[2] + $octets[3]
        for ($i = 0; $i -lt $octets.Length; $i++)
        {
            $octetScale = [Math]::Pow(256, $octets.Length - $i - 1)
            $sortValue += $octetScale * $octets[$i]
        }

        return $sortValue
    }

...which outputs...

62.193.0.0 - 62.193.31.255
78.39.193.192 - 78.39.193.207
78.39.194.0 - 78.39.194.255
85.185.240.128 - 85.185.240.159
194.225.0.0 - 194.225.15.255
194.225.24.0 - 194.225.31.255
195.146.40.0 - 195.146.40.255
195.146.53.128 - 195.146.53.225
217.218.0.0 - 217.219.255.255

For good measure you can change the first line to...

@('255.255.255.255', '0.0.0.0') + (Get-Content -Path 'IPv4AddressRanges.txt') |

...and see that it sorts correctly without the sort value overflowing.

Using [BitConverter] to produce a sortable value

You can simplify the above by using the [BitConverter] class to convert the IP address bytes directly to a [UInt32]...

Get-Content -Path 'IPv4AddressRanges.txt' |
    Sort-Object -Property {
        # Split each line on a hyphen surrounded by optional whitespace
        $rangeStartAddress = ($_ -split '\s*-\s*')[0]
        # Split the start address on a period and parse the resulting [String]s to [Byte]s
        [Byte[]] $octets = $rangeStartAddress -split '.', 0, 'SimpleMatch'

        #TODO: Handle $octets.Length -ne 4
        # Alternative: [Byte[]] $octets = [IPAddress]::Parse($rangeStartAddress).GetAddressBytes()

        # [IPAddress]::NetworkToHostOrder() doesn't have an overload for [UInt32]
        if ([BitConverter]::IsLittleEndian)
        {
            [Array]::Reverse($octets)
        }

        return [BitConverter]::ToUInt32($octets, 0)
    }

Implementing [IComparable] in a PowerShell class to define its own sorting

A more sophisticated solution would be to store our addresses in a type implementing the [IComparable] interface so Sort-Object can sort the addresses directly without needing to specify a [ScriptBlock]. [IPAddress] is, of course, the most natural .NET type in which to store an IP address, but it doesn't implement any sorting interfaces. Instead, we can use PowerShell classes to implement our own sortable type...

# Implement System.IComparable[Object] instead of System.IComparable[IPAddressRange]
# because PowerShell does not allow self-referential base type specifications.
# Sort-Object seems to only use the non-generic interface, anyways.
class IPAddressRange : Object, System.IComparable, System.IComparable[Object]
{
    [IPAddress] $StartAddress
    [IPAddress] $EndAddress

    IPAddressRange([IPAddress] $startAddress, [IPAddress] $endAddress)
    {
        #TODO: Ensure $startAddress and $endAddress are non-$null
        #TODO: Ensure the AddressFamily property of both $startAddress and
        #      $endAddress is [System.Net.Sockets.AddressFamily]::InterNetwork
        #TODO: Handle $startAddress -gt $endAddress

        $this.StartAddress = $startAddress
        $this.EndAddress = $endAddress
    }

    [Int32] CompareTo([Object] $other)
    {
        if ($null -eq $other)
        {
            return 1
        }

        if ($other -isnot [IPAddressRange])
        {
            throw [System.ArgumentOutOfRangeException]::new(
                'other',
                "Comparison against type ""$($other.GetType().FullName)"" is not supported."
            )
        }
        
        $result = [IPAddressRange]::CompareAddresses($this.StartAddress, $other.StartAddress)
        if ($result -eq 0)
        {
            $result = [IPAddressRange]::CompareAddresses($this.EndAddress, $other.EndAddress)
        }

        return $result
    }

    hidden static [Int32] CompareAddresses([IPAddress] $x, [IPAddress] $y)
    {
        $xBytes = $x.GetAddressBytes()
        $yBytes = $y.GetAddressBytes()

        for ($i = 0; $i -lt 4; $i++)
        {
            $result = $xBytes[$i].CompareTo($yBytes[$i])
            if ($result -ne 0)
            {
                return $result
            }
        }

        return 0
    }
}

The [IPAddressRange] type stores both the start and end address of a range, so it can represent an entire line of your input file. The CompareTo method compares each StartAddress byte-by-byte and only if those are equal does it then compare each EndAddress byte-by-byte. Executing this...

(
    '127.0.0.101 - 127.0.0.199',
    '127.0.0.200 - 127.0.0.200',
    '127.0.0.100 - 127.0.0.200',
    '127.0.0.100 - 127.0.0.101',
    '127.0.0.199 - 127.0.0.200',
    '127.0.0.100 - 127.0.0.199',
    '127.0.0.100 - 127.0.0.100',
    '127.0.0.101 - 127.0.0.200'
) + (Get-Content -Path 'IPv4AddressRanges.txt') |
    ForEach-Object -Process {
        $startAddress, $endAddress = [IPAddress[]] ($_ -split '\s*-\s*')

        return [IPAddressRange]::new($startAddress, $endAddress)
    } |
    Sort-Object

...sorts the 127.0.0.* ranges in the expected order...

StartAddress   EndAddress     
------------   ----------
62.193.0.0     62.193.31.255
78.39.193.192  78.39.193.207
78.39.194.0    78.39.194.255
85.185.240.128 85.185.240.159
127.0.0.100    127.0.0.100    
127.0.0.100    127.0.0.101
127.0.0.100    127.0.0.199
127.0.0.100    127.0.0.200
127.0.0.101    127.0.0.199
127.0.0.101    127.0.0.200
127.0.0.199    127.0.0.200
127.0.0.200    127.0.0.200
194.225.0.0    194.225.15.255
194.225.24.0   194.225.31.255
195.146.40.0   195.146.40.255
195.146.53.128 195.146.53.225
217.218.0.0    217.219.255.255

Note that we've only added the ability for Sort-Object to sort [IPAddressRange] instances and not its individual properties. Those are still of type [IPAddress] which doesn't provide its own ordering, so if we try something like ... | Sort-Object -Property 'EndAddress' it will not produce the desired results.

One simple way would be to split each line on ., take the first part (the first octet of each IP in a range), then cast it as an integer and sort on that.

Get-Content .\MyFile.txt | Sort-Object {$_.Split('.')[0] -as [int]}

scottwang provided a clever way to sort the IPs in a comment, using the Version Class which implements IComparable Interface.

Here is another alternative, clearly less efficient, using a hash table, the IPAddress Class and an array of expressions:

$ips = Get-Content ipfile.txt

$iptable = @{}
foreach($line in $ips) {
    if($ip = $line -replace ' -.+' -as [ipaddress]) {
        $iptable[$line] = $ip.GetAddressBytes()
    }
}

$expressions = foreach($i in 0..3) {
    { $iptable[$_] | Select-Object -Index $i }.GetNewClosure()
}

$ips | Sort-Object $expressions -Descending

Doing the same in a single pipeline is possible with an advanced function or an anonymous function:

Get-Content ipfile.txt | & {
    begin {
        $iptable = @{}
        $expressions = foreach($i in 0..3) {
            { $iptable[$_] | Select-Object -Index $i }.GetNewClosure()
        }
    }
    process {
        if ($ip = $_ -replace ' -.+' -as [ipaddress]) {
            $iptable[$_] = $ip.GetAddressBytes()
        }
    }
    end { $iptable.PSBase.Keys | Sort-Object $expressions -Descending }
}

My prior answer was quite inefficient hence I decided to provide another alternative using a Class that implements the IComparable Interface and holds an instance of IpAddress:

class IpComparer : IComparable {
    [ipaddress] $IpAddress

    IpComparer ([string] $IpAddress) {
        $this.IPAddress = $IpAddress
    }

    [int] GetHashCode() {
        return $this.IpAddress.GetHashCode()
    }

    [string] ToString() {
        return $this.IpAddress.ToString()
    }

    hidden static [bool] Equals ([object] $LHS, [object] $RHS) {
        return $LHS.IpAddress -eq $RHS.IpAddress
    }
    [bool] Equals ([object] $Ip) {
        return [IpComparer]::Equals([IpComparer] $this, [IpComparer] $Ip)
    }

    hidden static [int] CompareTo ([IpComparer] $LHS, [IpComparer] $RHS) {
        $x = $LHS.IpAddress.GetAddressBytes()
        $y = $RHS.IpAddress.GetAddressBytes()

        for($i = 0; $i -lt 4; $i++) {
            if($ne = $x[$i].CompareTo($y[$i])) {
                return $ne
            }
        }
        return 0
    }
    [int] CompareTo ([object] $Ip) {
        if($null -eq $Ip) {
            return 1
        }
        return [IpComparer]::CompareTo([IpComparer] $this, [IpComparer] $Ip)
    }
}

Now the instances can be Comparable

[IpComparer] '194.225.0.0' -lt '194.225.15.255'  # => True
[IpComparer] '194.225.15.255' -lt '194.225.0.0'  # => False
[IpComparer] '194.225.0.0' -gt '194.225.15.255'  # => False
[IpComparer] '194.225.15.255' -gt '194.225.0.0'  # => True

Tested for Equality

[IpComparer] '194.225.15.25' -ge '194.225.15.25' # => True
'194.225.15.25' -le [IpComparer] '194.225.15.25' # => True

$hs = [Collections.Generic.HashSet[IpComparer]]::new()
$hs.Add('194.225.0.0') # => True
$hs.Add('194.225.0.0') # => False

([IpComparer[]]('194.225.0.0', '194.225.0.0') | Select-Object -Unique).Count # => 1
([IpComparer] '194.225.15.255').Equals('194.225.15.255') # => True

And, in consequence, Sortable:

Get-Content ipfile.txt | Sort-Object { $_ -replace ' -.+' -as [IpComparer] } -Descending

Could be something like this.

  Get-Content .\abc.txt |ForEach-Object {($_).split("-")[1]}|Sort-Object
Related