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