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 = 4000 } } 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 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.ArrayList]$KeyOrder = @() # Tracks insertion order for eviction hidden [UInt64] $Hit = 0 hidden [UInt64] $Miss = 0 hidden [int] $Limit = 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[0] [QueryCache]::Records.Remove($evictKey) [QueryCache]::KeyOrder.RemoveAt(0) } [QueryCache]::Records[$_key] = $Value [QueryCache]::KeyOrder.Add($_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 } } function Get-IPInfoLiteEntry { [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 Continent = $response.continent Continent_Code = $response.continent_code CacheHit = $false } } catch { return New-ErrorResponse ` -ErrorCode "ERR_API_FAILURE" ` -ErrorMessage "An error occurred while querying the IPInfo API for the current IP." ` -ErrorTarget "self" ` -ErrorDetails $_.ErrorDetails.Message ` -IP $null } } # Validate input IP if (Test-BogonIP -ip $ip) { return New-ErrorResponse ` -ErrorCode "ERR_BOGON_INPUT" ` -ErrorMessage "The specified IP address is a bogon (non-routable/private) and will not be queried." ` -ErrorTarget $ip ` -IP $ip } # Use cache for normal IP lookups $cache = $script:QueryCache try { if ($cache.ContainsKey($ip)) { $cached = $cache.Get($ip) | Select-Object * -ExcludeProperty CacheHit $cached | Add-Member -NotePropertyName 'CacheHit' -NotePropertyValue $true return $cached } $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 Continent = $response.continent Continent_Code = $response.continent_code CacheHit = $false } $cache.Add($ip, $result) return $result } catch { $parsedJson = $null if ($_.ErrorDetails.Message) { try { $parsedJson = $_.ErrorDetails.Message | ConvertFrom-Json } catch { # Fallback: leave $parsedJson as null } } return New-ErrorResponse ` -ErrorCode "ERR_API_FAILURE" ` -ErrorMessage "An error occurred while querying the IPInfo API. See ErrorDetails for the full response." ` -ErrorTarget $ip ` -ErrorDetails $parsedJson ` -IP $ip } } function Get-IPInfoLiteBatch { [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 = @() $cache = $script:QueryCache # Use shared cache instance foreach ($ip in $ips) { try { # Skip bogon IPs if (Test-BogonIP -ip $ip) { $results += New-ErrorResponse ` -ErrorCode "ERR_BOGON_INPUT" ` -ErrorMessage "The specified IP address is a bogon (non-routable/private) and will not be queried." ` -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 += $cached continue } # Remote query $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 Continent = $response.continent Continent_Code = $response.continent_code CacheHit = $false } $cache.Add($ip, $result) $results += $result } catch { $results += New-ErrorResponse ` -ErrorCode "ERR_LOOKUP_FAILED" ` -ErrorMessage "Lookup failed for IP $ip" ` -ErrorTarget $ip ` -ErrorDetails $_.Exception.Message ` -IP $ip } } return $results } 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 'data\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 } # Initialize query cache instance $script:QueryCache = [QueryCache]::new($script:config.cache.queryLimit) # Initialize static bogon range cache $Script:BogonRanges = Initialize-BogonRanges Export-ModuleMember -Function Get-IPInfoLiteEntry, Get-IPInfoLiteBatch |