IPInfoLite.psm1
### Module Configuration (Private) $script:config = @{ api = @{ baseUrl = "https://api.ipinfo.io/lite/" baseUrlMe = "https://api.ipinfo.io/lite/me" headers = @{ Accept = "application/json" } } cache = @{ queryLimit = 5000 } processing = @{ chunkSize = 200 } } function New-ErrorResponse { param ( [string]$ErrorCode, [string]$ErrorMessage, [string]$ErrorTarget = $null, $ErrorDetails = $null, $IP = $null ) return [PSCustomObject]@{ Success = $false IP = $IP ASN = $null ASN_Name = $null ASN_Domain = $null Country = $null Country_Code = $null Country_Flag_Emoji = $null Country_Flag_Unicode = $null Continent = $null Continent_Code = $null CacheHit = $null ErrorCode = $ErrorCode ErrorMessage = $ErrorMessage ErrorTarget = $ErrorTarget ErrorTimestamp = (Get-Date).ToUniversalTime().ToString("o") ErrorDetails = $ErrorDetails } } class CacheResolverException : Exception { CacheResolverException ([string]$Message, [Exception]$InnerException) : base ($Message, $InnerException) { } } class QueryCache { hidden static [Hashtable]$Records = @{} hidden static [System.Collections.Queue]$KeyOrder = [System.Collections.Queue]::new() # Tracks insertion order for eviction hidden [UInt64] $Hit = 0 hidden [UInt64] $Miss = 0 hidden [int] $Limit = 0 hidden [UInt64] $Evicted = 0 QueryCache () { $this.Init() } QueryCache ($Limit) { $this.Init() if ($Limit -gt 0) { $this.Limit = $Limit } } hidden [void] Init() { $this | Add-Member -MemberType ScriptProperty -Name 'Count' -Value { return [QueryCache]::Records.Count } } [void] Add ([string]$Key, $Value) { if ([String]::IsNullOrEmpty($Key)) { throw '$Key is null. Key must be a non-empty string.' } $_key = $Key.ToLower() if ([QueryCache]::Records.ContainsKey($_key)) { [QueryCache]::Records[$_key] = $Value return } if ($this.Limit -gt 0 -and [QueryCache]::Records.Count -ge $this.Limit) { $evictKey = [QueryCache]::KeyOrder.Dequeue() [QueryCache]::Records.Remove($evictKey) $this.Evicted++ # Tracks evictions } [QueryCache]::Records[$_key] = $Value [QueryCache]::KeyOrder.Enqueue($_key) } [bool] ContainsKey ([String]$Key) { if ([String]::IsNullOrEmpty($Key)) { throw '$Key is null. Key must be a non-empty string.' } $_key = $Key.ToLower() return [QueryCache]::Records.ContainsKey($_key) } [object] Get ([string]$Key) { if ([String]::IsNullOrEmpty($Key)) { throw '$Key is null. Key must be a non-empty string.' } $_key = $Key.ToLower() if ([QueryCache]::Records.ContainsKey($_key)) { $this.Hit++ return [QueryCache]::Records[$_key] } $this.Miss++ throw "Cache miss for key: $Key" } [object] GetStats () { return [PSCustomObject]@{ Count = [QueryCache]::Records.Count Hit = $this.Hit Miss = $this.Miss } } [void] Clear() { [QueryCache]::Records.Clear() [QueryCache]::KeyOrder.Clear() $this.Hit = 0 $this.Miss = 0 $this.Evicted = 0 } } function Get-IPInfoLiteCache { <# .SYNOPSIS Returns current statistics from the IPInfoLite query cache including entry count, cache hits, and evictions. .DESCRIPTION Get-IPInfoLiteCache retrieves internal cache performance metrics used by the IPInfoLite module. This includes the current number of entries in the cache, the number of successful cache hits, and the number of failed lookups (misses). It is useful for automation, monitoring, and diagnostics. .EXAMPLE Get-IPInfoLiteCache .OUTPUTS Returns a PSCustomObject with Count, Hit, and Miss properties. #> return [PSCustomObject]@{ Success = $true Count = [QueryCache]::Records.Count Hit = $script:QueryCache.Hit Evicted = $script:QueryCache.Evicted } } function Clear-IPInfoLiteCache { <# .SYNOPSIS Clears the shared query cache used by the module. .DESCRIPTION Removes all previously cached query results. Use this if you suspect the module is returning outdated or incorrect information due to cached data. .EXAMPLE Clear-IPInfoLiteCache #> try { if ($script:QueryCache) { $script:QueryCache.Clear() return [PSCustomObject]@{ Success = $true } } else { return [PSCustomObject]@{ Success = $false ErrorCode = "ERR_QUERYCACHE_UNINITIALIZED" ErrorMessage = "QueryCache has not been initialized." ErrorTarget = "QueryCache" ErrorTimestamp = (Get-Date).ToUniversalTime().ToString("o") ErrorDetails = $null } } } catch { return [PSCustomObject]@{ Success = $false ErrorCode = "ERR_QUERYCACHE_CLEAR_FAILED" ErrorMessage = "Failed to clear QueryCache." ErrorTarget = "QueryCache" ErrorTimestamp = (Get-Date).ToUniversalTime().ToString("o") ErrorDetails = $_ } } } function Get-IPInfoLiteEntry { <# .SYNOPSIS Gets a single IP geolocation and ASN info using IPinfo Lite API. .PARAMETER token Your IPinfo API token. .PARAMETER ip Optional. IP address to look up. If not supplied, looks up caller's IP. .OUTPUTS Returns an array of PSCustomObject results with country-level geolocation and ASN data, or an error message if the query fails. .EXAMPLE Get-IPInfoLiteEntry -token "your_token_here" -ip "8.8.8.8" Returns geolocation and ASN information for the IP address 8.8.8.8 using the IPInfo Lite API. .EXAMPLE Get-IPInfoLiteEntry -token "your_token_here" Returns geolocation and ASN information for the caller's public IP address using the IPInfo Lite API. #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [string]$token, [string]$ip = "" ) # Don't attempt to cache or bogon-check self queries if ($ip -eq "") { $url = "$($script:config.api.baseUrlMe)?token=$token" try { $response = Invoke-RestMethod -Uri $url -Method Get -Headers $script:config.api.headers return [PSCustomObject]@{ Success = $true IP = $response.ip ASN = $response.asn ASN_Name = $response.as_name ASN_Domain = $response.as_domain Country = $response.country Country_Code = $response.country_code Country_Flag_Emoji = $flags[$response.country_code].Emoji Country_Flag_Unicode = $flags[$response.country_code].unicode Continent = $response.continent Continent_Code = $response.continent_code CacheHit = $false } } catch { # Only sanitize if an error occurred $sanitizedUrl = "$($script:config.api.baseUrlMe)" + "?token=<REDACTED>" return New-ErrorResponse ` -ErrorCode "ERR_API_FAILURE" ` -ErrorMessage "External API request failed due to possible timeout, network error or unexpected response." ` -ErrorTarget $sanitizedUrl ` -ErrorDetails $_.ErrorDetails.Message ` -IP $null } } # Validate input IP if (Test-BogonIP -ip $ip) { return New-ErrorResponse ` -ErrorCode "ERR_BOGON_INPUT" ` -ErrorMessage "The provided IP address is classified as a bogon (non-routable or reserved) and is excluded from querying." ` -ErrorTarget $ip ` -IP $ip } # Use cache for normal IP lookups $cache = $script:QueryCache if ($cache.ContainsKey($ip)) { $cached = $cache.Get($ip) | Select-Object * -ExcludeProperty CacheHit $cached | Add-Member -NotePropertyName 'CacheHit' -NotePropertyValue $true return $cached } try { $url = "$($script:config.api.baseUrl)$ip" + "?token=$token" $response = Invoke-RestMethod -Uri $url -Method Get -Headers $script:config.api.headers $result = [PSCustomObject]@{ Success = $true IP = $response.ip ASN = $response.asn ASN_Name = $response.as_name ASN_Domain = $response.as_domain Country = $response.country Country_Code = $response.country_code Country_Flag_Emoji = $flags[$response.country_code].Emoji Country_Flag_Unicode = $flags[$response.country_code].unicode Continent = $response.continent Continent_Code = $response.continent_code CacheHit = $false } $cache.Add($ip, $result) return $result } catch { # Only sanitize if an error occurred $sanitizedUrl = "$($script:config.api.baseUrl)$ip" + "?token=<REDACTED>" return New-ErrorResponse ` -ErrorCode "ERR_API_FAILURE" ` -ErrorMessage "External API request failed due to possible timeout, network error or unexpected response." ` -ErrorTarget $sanitizedUrl ` -ErrorDetails $null ` -IP $ip } } function Get-IPInfoLiteBatch { <# .SYNOPSIS Performs sequential IP info lookups using the IPinfo Lite API. .PARAMETER token Your IPinfo API token. .PARAMETER ips Array of IP addresses to look up. .OUTPUTS Returns an array of PSCustomObject results with country-level geolocation and ASN data, or an error message if the query fails. .EXAMPLE Get-IPInfoLiteBatch -token "your_token_here" -ips @("8.8.8.8", "1.1.1.1") Performs a batch lookup for multiple IP addresses using the IPInfo Lite API. Returns a list of geolocation and ASN information for each IP. #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param ( [Parameter(Mandatory = $true)] [string]$token, [Parameter(Mandatory = $true)] [string[]]$ips ) # Validate token once at the top $testResult = Test-IPInfoLiteToken -token $token if (-not $testResult.Success) { return New-ErrorResponse ` -ErrorCode "ERR_TOKEN_VERIFICATION" ` -ErrorMessage "The API token provided could not be verified. Please ensure the token is correct, active, and has the necessary permissions" ` -ErrorTarget "Token Validation" ` -ErrorDetails $testResult } $results = New-Object System.Collections.Generic.List[PSObject] $cache = $script:QueryCache # Use shared cache instance # Configure HttpClient $httpClient = $script:httpClient # Apply default headers from config foreach ($key in $script:config.api.headers.Keys) { if (-not $httpClient.DefaultRequestHeaders.Contains($key)) { [void]$httpClient.DefaultRequestHeaders.Add($key, $script:config.api.headers[$key]) } } foreach ($ip in $ips) { # Skip bogon IPs if (Test-BogonIP -ip $ip) { $results.Add((New-ErrorResponse ` -ErrorCode "ERR_BOGON_INPUT" ` -ErrorMessage "The provided IP address is classified as a bogon (non-routable or reserved) and is excluded from querying." ` -ErrorTarget $ip ` -IP $ip)) continue } # Return cached value if available if ($cache.ContainsKey($ip)) { $cached = $cache.Get($ip) | Select-Object * -ExcludeProperty CacheHit $cached | Add-Member -NotePropertyName 'CacheHit' -NotePropertyValue $true $results.Add($cached) continue } try { # Remote Query $url = "$($script:config.api.baseUrl)$ip" + "?token=$token" $response = $httpClient.GetAsync($url).Result $jsonContent = $response.Content.ReadAsStringAsync().Result $json = $jsonContent | ConvertFrom-Json $result = [PSCustomObject]@{ Success = $true IP = $json.ip ASN = $json.asn ASN_Name = $json.as_name ASN_Domain = $json.as_domain Country = $json.country Country_Code = $json.country_code Country_Flag_Emoji = $flags[$json.country_code].Emoji Country_Flag_Unicode = $flags[$json.country_code].unicode Continent = $json.continent Continent_Code = $json.continent_code CacheHit = $false } $cache.Add($ip, $result) $results.Add($result) } catch { # Only sanitize if an error occurred $sanitizedUrl = "$($script:config.api.baseUrl)$ip" + "?token=<REDACTED>" $results.Add((New-ErrorResponse ` -ErrorCode "ERR_API_FAILURE" ` -ErrorMessage "External API request failed due to possible timeout, network error or unexpected response." ` -ErrorTarget $sanitizedUrl ` -ErrorDetails $null ` -IP $ip)) } } return ,$results.ToArray() } function Get-IPInfoLiteBatchParallel { <# .SYNOPSIS Performs high-efficiency batch IP lookups in parallel using the IPinfo Lite API. Requires PowerShell 7 or later. .PARAMETER token Your IPinfo API token. .PARAMETER ips Array of IP addresses to look up. .OUTPUTS Returns an array of PSCustomObject results with country-level geolocation and ASN data, or an error message if the query fails. .EXAMPLE Get-IPInfoLiteBatchParallel -Token "your_token_here" -ips @("8.8.8.8", "1.1.1.1") Executes a batch of parallel IP lookups using the IPinfo Lite API. Returns structured geolocation and ASN information for each IP address. #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param ( [Parameter(Mandatory = $true)] [string]$token, [Parameter(Mandatory = $true)] [string[]]$ips ) if ($PSVersionTable.PSVersion.Major -lt 7) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( ([System.NotSupportedException]::new("PowerShell 7 or later is required.")), "ERR_PS_VERSION_UNSUPPORTED", [System.Management.Automation.ErrorCategory]::NotInstalled, $null ) throw $errorRecord } # Validate token $testResult = Test-IPInfoLiteToken -token $token if (-not $testResult.Success) { return New-ErrorResponse ` -ErrorCode "ERR_TOKEN_VERIFICATION" ` -ErrorMessage "The API token provided could not be verified. Please ensure the token is correct, active, and has the necessary permissions" ` -ErrorTarget "Token Validation" ` -ErrorDetails $testResult } # Use shared cache instance $cache = $script:QueryCache # Create the ConcurrentBag typed to PSCustomObject for thread-safe results $resultsBag = [System.Collections.Concurrent.ConcurrentBag[PSCustomObject]]::new() # Use a high-performance list to track IPs to process $ipsToQueryList = [System.Collections.Generic.List[string]]::new() # Initialize an empty strongly-typed .NET List to hold chunks of IP addresses for batch processing $ipChunks = [System.Collections.Generic.List[object]]::new() foreach ($ip in $ips) { # Check if the IP is a bogon if (Test-BogonIP -ip $ip) { $bogonResponse = New-ErrorResponse ` -ErrorCode "ERR_BOGON_INPUT" ` -ErrorMessage "The provided IP address is classified as a bogon (non-routable or reserved) and is excluded from querying." ` -ErrorTarget $ip ` -IP $ip $resultsBag.Add($bogonResponse) continue } # Check if the IP is already cached if ($cache.ContainsKey($ip)) { $cached = $cache.Get($ip) | Select-Object * -ExcludeProperty CacheHit $cached | Add-Member -NotePropertyName 'CacheHit' -NotePropertyValue $true $resultsBag.Add($cached) continue } # If not bogon or cached, queue for parallel querying $ipsToQueryList.Add($ip) } # Convert to array for use with ForEach-Object -Parallel $ipsToQuery = $ipsToQueryList.ToArray() # Split the IP list into chunks of size $chunkSize for controlled parallel processing. # Helps manage resource usage and stay within API rate limits. $chunkSize = $config.processing.chunkSize for ($i = 0; $i -lt $ipsToQuery.Count; $i += $chunkSize) { $ipChunks.Add($ipsToQuery[$i..[Math]::Min($i + $chunkSize - 1, $ipsToQuery.Count - 1)]) } # Move the API configurations to local variables # This tends to work better with -Parallel $apiBaseUrl = $config.api.baseUrl $apiHeaders = $config.api.headers foreach ($ipChunk in $ipChunks) { $ipChunk | ForEach-Object -Parallel { $ip = $_ Try { # Prepare Local Variables $locaHeaders = $using:apiHeaders $localHttpClient = $using:httpClient # Prepare URL $localBaseUrl = $using:apiBaseUrl $localToken = $using:token $url = "${localBaseUrl}${ip}" + "?token=${localToken}" # Build HTTP request $request = [System.Net.Http.HttpRequestMessage]::new( [System.Net.Http.HttpMethod]::Get, $url ) # Add headers to the request foreach ($kvp in $locaHeaders.GetEnumerator()) { [void]$request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) #| Out-Null } # Send the request and read the response $response = $localHttpClient.Send($request) if (-not $response.IsSuccessStatusCode) { throw "HTTP request failed with status code $($response.StatusCode) for IP $ip" } $stream = $response.Content.ReadAsStream() $reader = New-Object System.IO.StreamReader($stream) $body = $reader.ReadToEnd() $json = $body | ConvertFrom-Json # Prepare Flag Data $localFlags = $using:flags $flagEmoji = $localFlags[$json.country_code].Emoji $flagUnicode = $localFlags[$json.country_code].unicode $result = [PSCustomObject]@{ Success = $true IP = $json.ip ASN = $json.asn ASN_Name = $json.as_name ASN_Domain = $json.as_domain Country = $json.country Country_Code = $json.country_code Country_Flag_Emoji = $flagEmoji Country_Flag_Unicode = $flagUnicode Continent = $json.continent Continent_Code = $json.continent_code CacheHit = $false } } catch { # Only sanitize if an error occurred $sanitizedUrl = "${localBaseUrl}${ip}" + "?token=<REDACTED>" $result = [PSCustomObject]@{ Success = $false IP = $ip ASN = $null ASN_Name = $null ASN_Domain = $null Country = $null Country_Code = $null Country_Flag_Emoji = $null Country_Flag_Unicode = $null Continent = $null Continent_Code = $null CacheHit = $null ErrorCode = "ERR_API_FAILURE" ErrorMessage = "External API request failed due to possible timeout, network error or unexpected response." ErrorTarget = $sanitizedUrl ErrorTimestamp = (Get-Date).ToUniversalTime().ToString("o") ErrorDetails = $null } } $bag = $using:resultsBag $bag.Add($result) } -ThrottleLimit 15 Start-Sleep -Milliseconds 250 # Backoff between chunks } # Update the shared cache with new successful results. foreach ($result in $resultsBag) { if ($result.Success -and -not $result.CacheHit -and $result.IP -and $result.IP.Trim() -ne "") { $cache_object = $result.PSObject.Copy() $cache_object.CacheHit = $true $cache.Add($result.ip, $cache_object) } } return $resultsBag.ToArray() } function Test-IPInfoLiteToken { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory)] [string]$token ) $url = "$($script:config.api.baseUrlMe)?token=$token" try { $null = Invoke-RestMethod -Uri $url -Headers $script:config.api.headers -Method Get -TimeoutSec 5 return [PSCustomObject]@{ Success = $true Message = "Token is valid." } } catch { return [PSCustomObject]@{ Success = $false Message = "Token validation failed: $($_.Exception.Message)" ErrorCode = $_.Exception.Response.StatusCode.Value__ } } } function Initialize-BogonRanges { $filePath = Join-Path $PSScriptRoot 'Resources\bogonRanges.json' if (-not (Test-Path $filePath)) { throw "Bogon range data file not found: $filePath" } $jsonData = Get-Content $filePath -Raw | ConvertFrom-Json return $jsonData | ForEach-Object { [PSCustomObject]@{ Network = [System.Net.IPAddress]::Parse($_.Network) PrefixLength = $_.PrefixLength } } } function Test-BogonIP { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$IPAddress ) $parsedIP = $null if (-not [System.Net.IPAddress]::TryParse($IPAddress, [ref]$parsedIP)) { Write-Warning "Invalid IP address format: $IPAddress" return $false } foreach ($range in $Script:BogonRanges) { if (Test-IPInCIDR -IPAddress $parsedIP -Network $range.Network -PrefixLength $range.PrefixLength) { return $true } } return $false } function Test-IPInCIDR { param ( [System.Net.IPAddress]$IPAddress, [System.Net.IPAddress]$Network, [int]$PrefixLength ) $ipBytes = $IPAddress.GetAddressBytes() $netBytes = $Network.GetAddressBytes() if ($ipBytes.Length -ne $netBytes.Length) { return $false } $fullBytes = [math]::Floor($PrefixLength / 8) $remainingBits = $PrefixLength % 8 for ($i = 0; $i -lt $fullBytes; $i++) { if ($ipBytes[$i] -ne $netBytes[$i]) { return $false } } if ($remainingBits -gt 0) { $mask = 0xFF -shl (8 - $remainingBits) if (($ipBytes[$fullBytes] -band $mask) -ne ($netBytes[$fullBytes] -band $mask)) { return $false } } return $true } function Initialize-CountryFlagTable { [CmdletBinding()] param () $filePath = Join-Path $PSScriptRoot 'Resources\countries_flags.json' if (-not (Test-Path -Path $filePath)) { throw "The file '$filePath' does not exist. Ensure the JSON file is present in the 'Resources' folder relative to the script location." } $jsonContent = Get-Content -Raw -Path $filePath | ConvertFrom-Json $countryFlagTable = @{} foreach ($property in $jsonContent.PSObject.Properties) { $countryCode = $property.Name $entry = $property.Value $countryFlagTable[$countryCode] = [PSCustomObject]@{ Emoji = $entry.emoji Unicode = $entry.unicode } } return $countryFlagTable } # Ensure the System.Net.Http assembly is loaded. # PowerShell 5.1 does not automatically load this .NET assembly, even though it exists on all supported systems. # This check ensures that HttpClient and related types are available before use. if ($PSVersionTable.PSVersion.Major -eq 5 -and $PSVersionTable.PSEdition -eq 'Desktop') { if (-not ("System.Net.Http.HttpClient" -as [type])) { Add-Type -AssemblyName "System.Net.Http" } } # Initialize a reusable HttpClient (if not already initialized) if (-not $script:httpClient) { $script:httpClient = [System.Net.Http.HttpClient]::new() $script:httpClient.Timeout = [System.TimeSpan]::FromSeconds(30) } # Initialize query cache instance $script:QueryCache = [QueryCache]::new($script:config.cache.queryLimit) # Initialize static bogon range cache $Script:BogonRanges = Initialize-BogonRanges # Initialize Country Flag Table $Script:flags = Initialize-CountryFlagTable Export-ModuleMember -Function Get-IPInfoLiteEntry, Get-IPInfoLiteBatch, Get-IPInfoLiteBatchParallel, Get-IPInfoLiteCache, Clear-IPInfoLiteCache |