SophosFirewall.Core.psm1

#requires -Version 5.1
<#
.SYNOPSIS
    Core helper functions for Sophos Firewall API modules.
 
.DESCRIPTION
    Provides shared functionality for all Sophos Firewall PowerShell modules including:
    - Session management (Connect/Disconnect)
    - API communication (Invoke-SfosApi)
    - Response parsing and validation
    - XML escaping for security
    - Parameter resolution from session context
 
.NOTES
    Module Name: SophosFirewall.Core
    Author: Jan Weis
    Homepage: https://www.it-explorations.de
    Version: 1.3.0
    Total Functions: 8
    PowerShell Version: 5.1+
     
.LINK
    https://docs.sophos.com/nsg/sophos-firewall/22.0/api/
#>


#region Module Variables

# Default Sophos Firewall API port
[int]$script:DefaultSfosPort = 4444

# Session context for connection reuse across cmdlets
$script:SfosConnection = $null

# Named session registry for Connect-SfosFirewall -Name / -Session everywhere. A plain
# @{} hashtable literal's case sensitivity is not guaranteed across PowerShell versions,
# so the comparer is set explicitly - session names are looked up case-insensitively.
$script:SfosSessions = [System.Collections.Hashtable]::new([StringComparer]::OrdinalIgnoreCase)

# Guards the process-wide certificate callback under PS 5.1. ServicePointManager is static,
# so two calls in parallel runspaces could each save the other's temporary "accept all"
# callback as the original and leave validation permanently disabled.
$script:CertCallbackLock = [object]::new()

#endregion

#region XML Helper Functions

<#
.SYNOPSIS
    Escapes XML special characters in text strings.
 
.DESCRIPTION
    Converts special characters to XML-safe entities to prevent injection attacks
    and ensure proper XML formatting.
 
.PARAMETER Text
    The text string to escape.
 
.OUTPUTS
    System.String. The XML-escaped string.
 
.EXAMPLE
    # Escape a value before interpolating it into request XML.
    # Returns: Smith &amp; Sons
    ConvertTo-SfosXmlEscaped -Text "Smith & Sons"
 
    # Angle brackets are deliberately absent from this example: PowerShell's help renderer
    # treats raw < > in an .EXAMPLE as markup and silently drops them together with the rest
    # of the line, so an example containing them reaches the reader mutilated. The cmdlet
    # escapes them all the same - see .DESCRIPTION.
#>

function ConvertTo-SfosXmlEscaped {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowEmptyString()]
        [string]$Text
    )
    
    process {
        return ($Text `
                -replace '&', '&amp;' `
                -replace '<', '&lt;' `
                -replace '>', '&gt;' `
                -replace '"', '&quot;' `
                -replace "'", '&apos;')
    }
}

<#
.SYNOPSIS
    Throws when an API response reports a failed login. Internal helper, not exported.
 
.DESCRIPTION
    SFOS reports authentication outside the entity status: a lowercase <status> element
    directly under <Login>, with no code attribute, in an otherwise empty HTTP 200 body.
    Because it matches neither status path, an unchecked response looks like "no records"
    to Get-* and like success to every write operation.
 
.PARAMETER Content
    Raw response body.
#>

function Assert-SfosApiLoginSuccess {
    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [AllowNull()]
        [string]$Content
    )

    if (-not $Content) {
        return
    }

    # A non-XML body is not this function's problem - the caller parses and reports it.
    $xml = $null
    try {
        $xml = [xml]$Content
    }
    catch {
        return
    }

    $loginNode = $xml.SelectSingleNode('/Response/Login/status')
    if (-not $loginNode) {
        return
    }

    $loginStatus = [string]$loginNode.InnerText
    if ($loginStatus -and $loginStatus -notmatch 'Success') {
        throw "Sophos API login failed: $loginStatus"
    }
}

<#
.SYNOPSIS
    Invokes a Sophos Firewall API request.
.DESCRIPTION
    Sends an XML request to the Sophos Firewall API endpoint and returns the response.
.PARAMETER Firewall
    The Sophos Firewall hostname or IP address.
.PARAMETER Port
    The management/API port number (default: 4444).
.PARAMETER Username
    The username for authentication (protected via XML-escaping).
.PARAMETER Password
    The password for authentication (as SecureString for security).
.PARAMETER InnerXml
    The inner XML content of the API request.
.PARAMETER ApiVersion
    Optional APIVersion attribute for the <Request> element (for example '2200.1').
    When omitted, the firewall processes the request using its own current schema
    version, which is what keeps one module compatible with several firmware levels.
.PARAMETER TimeoutSec
    Maximum time in seconds to wait for the HTTP response. Passed straight through to
    Invoke-WebRequest's own -TimeoutSec, which exists unchanged on both PS 5.1 and PS 7+, so
    no version branching is needed here. Default 30. Pass 0 to omit the parameter entirely
    and fall back to Invoke-WebRequest's own default instead. Without an enforced limit an
    unreachable host used to block for the operating system's own default (around 21
    seconds under Windows) with no way for a caller to shorten it.
.PARAMETER SkipCertificateCheck
    Skips SSL certificate validation for self-signed certificates. Part of the 'Explicit'
    parameter set; when calling with -Session, the session's own SkipCertificateCheck value
    is used instead.
.PARAMETER Session
    A registered session name or a session object returned by Connect-SfosFirewall. Resolves
    Firewall, Port, Username, Password and SkipCertificateCheck from it instead of from the
    individual connection parameters, for raw multi-session XML work. Mandatory in this
    parameter set; the 'Explicit' set (Firewall/Port/Username/Password/SkipCertificateCheck)
    remains the default and is unchanged.
.OUTPUTS
    The response from the API as a WebResponseObject.
.EXAMPLE
    # -Username is a plain string; only -Password is a SecureString. Passing a SecureString
    # for the user name converts it to the text "System.Security.SecureString" and the login
    # fails. The inner XML is shown entity-encoded because PowerShell's help renderer drops
    # raw angle brackets from examples - pass it with real < and >.
    $securePw = Read-Host -AsSecureString
    $inner = "&lt;Get&gt;&lt;IPHost&gt;&lt;/IPHost&gt;&lt;/Get&gt;"
    Invoke-SfosApi -Firewall "firewall.example.com" -Port 4444 -Username "admin" -Password $securePw -InnerXml $inner -SkipCertificateCheck
.EXAMPLE
    # Fail fast against a host that might be unreachable, instead of waiting out the
    # operating system's own default timeout.
    Invoke-SfosApi -Firewall "firewall.example.com" -Username "admin" -Password $securePw -InnerXml $inner -TimeoutSec 5
.EXAMPLE
    # Raw multi-session call against a registered session instead of individual connection
    # parameters. 'fw2' has to be registered first, e.g. via
    # Connect-SfosFirewall -Firewall "fw2.example.test" -Credential $cred -Name 'fw2' -NoDefault
    Invoke-SfosApi -Session 'fw2' -InnerXml $inner
#>

function Invoke-SfosApi {
    [CmdletBinding(DefaultParameterSetName = 'Explicit')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Explicit')]
        [string]$Firewall,

        [Parameter(ParameterSetName = 'Explicit')]
        [int]$Port = $script:DefaultSfosPort,

        [Parameter(Mandatory, ParameterSetName = 'Explicit')]
        [string]$Username,

        [Parameter(Mandatory, ParameterSetName = 'Explicit')]
        [SecureString]$Password,

        [Parameter(Mandatory)]
        [string]$InnerXml,

        [string]$ApiVersion,

        [int]$TimeoutSec = 30,

        [Parameter(ParameterSetName = 'Explicit')]
        [switch]$SkipCertificateCheck,

        [Parameter(Mandatory, ParameterSetName = 'Session')]
        [object]$Session
    )

    if ($PSCmdlet.ParameterSetName -eq 'Session') {
        $resolvedSession = Resolve-SfosSessionArgument -Session $Session
        if (-not $resolvedSession) {
            throw 'Invoke-SfosApi -Session did not resolve to a usable session. Pass a registered session name or the object returned by Connect-SfosFirewall.'
        }
        $Firewall = $resolvedSession.Firewall
        $Port = $resolvedSession.Port
        $Username = $resolvedSession.Username
        $Password = $resolvedSession.Password
        $SkipCertificateCheck = [bool]$resolvedSession.SkipCertificateCheck
    }

    # Variables for secure handling and cleanup
    $plainPassword = $null
    $passwordBstr = $null
    $savedCertCallback = $null
    $certCallbackChanged = $false
    $certLockTaken = $false

    try {
        # Security: XML-escape credentials to prevent injection attacks
        $usernameEscaped = ConvertTo-SfosXmlEscaped -Text $Username
        
        # Convert Password SecureString to plaintext with BSTR cleanup.
        # PtrToStringBSTR, not PtrToStringAuto: a BSTR is length-prefixed and may contain
        # embedded null characters, which PtrToStringAuto would silently truncate at.
        $passwordBstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
        $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordBstr)
        $passwordEscaped = ConvertTo-SfosXmlEscaped -Text $plainPassword
        
        $uri = ("https://{0}:{1}/webconsole/APIController" -f $Firewall, $Port)

        # APIVersion is optional. When omitted the firewall answers using its own current
        # schema version, which keeps a single module usable across firmware levels.
        $versionAttribute = ''
        if ($ApiVersion) {
            $versionAttribute = " APIVersion=`"$ApiVersion`""
        }
        $requestXml = "<Request$versionAttribute><Login><Username>$usernameEscaped</Username><Password>$passwordEscaped</Password></Login>$InnerXml</Request>"

        # The body is form-encoded, so the XML has to be URL-encoded. Left unencoded, any
        # '&' - including every '&amp;' produced by XML escaping - terminates the reqxml
        # field and SFOS rejects the request with code 529 'Input request file is Invalid'.
        $body = 'reqxml=' + [uri]::EscapeDataString($requestXml)
        
        $invokeParams = @{
            Uri         = $uri
            Method      = 'Post'
            Body        = $body
            ErrorAction = 'Stop'
        }

        # -TimeoutSec is identical on Invoke-WebRequest under PS 5.1 and PS 7+, so no version
        # branch is needed the way there is for -UseBasicParsing/-SkipCertificateCheck below.
        # 0 means "no enforced limit" - omit the parameter and let Invoke-WebRequest use its
        # own default rather than passing a literal 0, which Invoke-WebRequest would reject.
        if ($TimeoutSec -gt 0) {
            $invokeParams['TimeoutSec'] = $TimeoutSec
        }

        # -UseBasicParsing under PS 5.1: without it Invoke-WebRequest hands the response to
        # the Internet Explorer DOM parser, which throws NullReferenceException on any
        # machine that has no IE engine - Windows Server included. Every call would fail.
        # PS 7 dropped the parameter; passing it there is harmless but pointless.
        if ($PSVersionTable.PSVersion.Major -le 5) {
            $invokeParams['UseBasicParsing'] = $true
        }

        # Handle certificate validation for PS 5.1 vs PS 7+
        if ($SkipCertificateCheck) {
            if ($PSVersionTable.PSVersion.Major -le 5) {
                # Serialise the swap: the callback is process-wide, so a concurrent call
                # must not observe - and later restore - this call's temporary value.
                [System.Threading.Monitor]::Enter($script:CertCallbackLock)
                $certLockTaken = $true
                $savedCertCallback = [Net.ServicePointManager]::ServerCertificateValidationCallback
                $certCallbackChanged = $true
                [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
            }
            elseif ($PSVersionTable.PSVersion.Major -gt 5) {
                # PS 7+: Use parameter instead of global callback
                $invokeParams['SkipCertificateCheck'] = $true
            }
        }

        try {
            $response = Invoke-WebRequest @invokeParams
        }
        catch {
            # Flatten the exception chain. PowerShell reports "The SSL connection could not
            # be established, see inner exception", and the domain functions re-throw only
            # that top-level text - the inner exception naming the actual cause
            # (RemoteCertificateNameMismatch, connection refused, ...) never reaches the
            # caller. Doing it here fixes it for all 53 of them at once.
            $messages = @()
            $current = $_.Exception
            while ($current) {
                if ($current.Message -and $messages -notcontains $current.Message) {
                    $messages += $current.Message
                }
                $current = $current.InnerException
            }
            throw ($messages -join ' -> ')
        }

        # Every response passes through here, so this is the one place that can catch a
        # failed login. SFOS answers it with HTTP 200 and nothing but the lowercase
        # <status> under <Login> - no entity, no status code. Left unchecked, Get-* would
        # return an empty result and every write would report success.
        Assert-SfosApiLoginSuccess -Content $response.Content

        return $response
    }
    finally {
        # Restore previous certificate validation callback. The flag is required: the
        # saved callback is normally $null, so a null check would skip the restore and
        # leave certificate validation disabled for the rest of the process.
        if ($certCallbackChanged) {
            [Net.ServicePointManager]::ServerCertificateValidationCallback = $savedCertCallback
        }

        if ($certLockTaken) {
            [System.Threading.Monitor]::Exit($script:CertCallbackLock)
        }
        
        # Free BSTR memory to prevent leaks
        if ($passwordBstr -ne [IntPtr]::Zero) {
            [Runtime.InteropServices.Marshal]::FreeBSTR($passwordBstr)
        }
        
        # Clear plaintext variables from memory
        $plainPassword = $null
    }
}

#endregion

#region Response Parsing

<#
.SYNOPSIS
    Extracts status information from API XML response.
 
.DESCRIPTION
    Parses the XML response to find status codes and messages.
    Looks in /Response/ObjectName/Status or /Response/Status.
 
.PARAMETER Xml
    The XML response from the API.
 
.PARAMETER ObjectName
    Optional object name to search for specific status node.
 
.OUTPUTS
    PSCustomObject with Code, Message, and XPathHint properties.
 
.EXAMPLE
    Get-SfosApiStatus -Xml $response -ObjectName "Zone"
#>

function Get-SfosApiStatus {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [xml]$Xml,
        
        [string]$ObjectName
    )
    
    # SelectNodes, not property access: $Xml.Response.$ObjectName silently returns the CLR
    # member of XmlElement when the entity is called Name, Item or Count, and it collapses
    # several <Status> siblings - a bulk delete returns one per object - into a single
    # value whose .code reads "200 529".
    $statusNodes = @()
    $hint = $null

    if ($ObjectName) {
        # '<Status>' is not always an API status. Some entities carry a field of that name:
        # a FirewallRule and a NATRule both hold <Status>Enable</Status> as their enabled
        # flag, so a plain /Response/FirewallRule/Status matches six data fields on a
        # six-rule response and none of them says anything about the request.
        #
        # A node counts as an API status when it carries a 'code' attribute, or when its
        # parent is not a data object - data objects have a <Name>, status containers do
        # not. Both halves matter: dropping the @code test would hide a real error that
        # arrives alongside a named object, and dropping the Name test brings the
        # Enable/Disable fields back.
        $statusNodes = @($Xml.SelectNodes("/Response/$ObjectName/Status[@code or not(../Name)]"))
        $hint = "/Response/$ObjectName/Status"
    }

    if (-not $statusNodes.Count) {
        $statusNodes = @($Xml.SelectNodes('/Response/Status'))
        $hint = '/Response/Status'
    }

    if (-not $statusNodes.Count) {
        # A bare 'return', not 'return $null': the caller almost always wraps this in @(),
        # and @($null) is a one-element array holding $null rather than an empty one, which
        # reads as "one unreadable status" instead of "no status at all".
        return
    }

    # One object per status node, so a caller can tell which entity failed
    foreach ($statusNode in $statusNodes) {
        [PSCustomObject]@{
            Code      = [string]$statusNode.GetAttribute('code')
            Message   = [string]$statusNode.InnerText
            XPathHint = $hint
        }
    }
}

<#
.SYNOPSIS
    Validates that an API response indicates success.
 
.DESCRIPTION
    Checks the login status and the entity status codes of an API response and throws if
    the request did not succeed. Codes follow the table published by Sophos: 200 and 216
    are success, 201/203/211-215 succeed with a warning, everything else is a failure.
    There is no code 202 in that table.
 
    The published table covers 200-216 and 500-599. Codes 217 and 222 were measured against
    a live firewall on operations that demonstrably succeeded and only produce a warning;
    every other undocumented code throws, so an unrecognised status is never mistaken for
    success. See the comments at the corresponding checks.
 
    If nothing is found at the path derived from -ObjectName (and not at the plain
    /Response/Status fallback either), the function searches the rest of the response once
    for any other node that still matches the API-status heuristic before giving up. A
    status found this way still throws on an error code (with a hint in the message that the
    -ObjectName needs measuring) and still succeeds on 200/216/... but with a warning naming
    the path deviation - a response with genuinely no status anywhere is unaffected and
    behaves exactly as before.
 
.PARAMETER Xml
    The XML response from the API.
 
.PARAMETER ObjectName
    Optional object name for status lookup.
 
.PARAMETER Action
    Description of the action being performed (for error messages).
 
.PARAMETER Target
    Target object name (for error messages).
 
.EXAMPLE
    Assert-SfosApiReturnSuccess -Xml $response -ObjectName "Zone" -Action "Create" -Target "DMZ"
#>

function Assert-SfosApiReturnSuccess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [xml]$Xml,
        
        [string]$ObjectName,
        
        [string]$Action,
        
        [string]$Target
    )
    
    $actionPart = if ($Action) { $Action } else { 'execute request' }
    $targetPart = if ($Target) { " for '$Target'" } else { '' }

    # Authentication is reported outside the entity status and would otherwise slip past
    # every code check below. Invoke-SfosApi already catches this for live calls; the check
    # is repeated here for callers that hand in a parsed response directly.
    $loginNode = $Xml.SelectSingleNode('/Response/Login/status')
    if ($loginNode) {
        $loginStatus = [string]$loginNode.InnerText
        if ($loginStatus -and $loginStatus -notmatch 'Success') {
            throw "Sophos API login failed while trying to $actionPart$targetPart. $loginStatus"
        }
    }

    # Where-Object, not just @(): Get-SfosApiStatus returns $null when the response carries
    # no <Status> at all, and @($null) is a one-element array holding $null - not an empty
    # one. Without the filter the loop below inspects that $null and reports a status-less
    # response as a broken status.
    $statusList = @(Get-SfosApiStatus -Xml $Xml -ObjectName $ObjectName | Where-Object { $_ })

    # Fail-open guard, measured against New-SfosL2TPConnection: its create/update status
    # landed FLAT at /Response/Configuration/Status while the caller's -ObjectName pointed
    # at the nested Get/Remove shape, so the lookup above found nothing there and nothing at
    # the /Response/Status fallback either - and this function used to just return, reporting
    # success for a write that may have failed. Before treating "nothing at the expected
    # path" as "no status anywhere" (which is legitimately what an empty Get result looks
    # like), search the rest of the response once for any node the existing heuristic still
    # recognises as an API status: a Status carrying a code attribute, or a code-less Status
    # whose parent has no <Name> child. That second half is the same data-field exclusion
    # used above - a FirewallRule/NATRule's <Status>Enable</Status> sits next to <Name> and
    # stays excluded here too.
    $fallbackUsed = $false
    if (-not $statusList.Count) {
        $fallbackNodes = @($Xml.SelectNodes('//Status[@code or not(../Name)]') | Where-Object { $_ })
        if ($fallbackNodes.Count) {
            $fallbackUsed = $true
            $statusList = @($fallbackNodes | ForEach-Object {
                    $pathParts = @()
                    $ancestor = $_
                    while ($ancestor -and $ancestor.NodeType -eq [System.Xml.XmlNodeType]::Element) {
                        $pathParts = , $ancestor.Name + $pathParts
                        $ancestor = $ancestor.ParentNode
                    }
                    $actualPath = '/' + ($pathParts -join '/')
                    [PSCustomObject]@{
                        Code      = [string]$_.GetAttribute('code')
                        Message   = [string]$_.InnerText
                        XPathHint = "$actualPath (found outside the expected path for -ObjectName '$ObjectName' - measure and correct -ObjectName for this operation)"
                    }
                })
        }
    }

    if (-not $statusList.Count) {
        return
    }

    foreach ($status in $statusList) {
        # An empty result is reported as <Status>No. of records Zero.</Status> without a
        # code attribute. That is not a failure, so Get-* must not throw on it.
        #
        # Only that one wording is waved through. Treating *every* code-less status as an
        # empty result fails open: a filtered Get on ContentConditionList answers
        # <Status>Transaction fail</Status>, also without a code, and the caller would have
        # seen an empty list while matching objects existed. Same class of defect as the
        # login failure that used to read as success - so anything unrecognised throws.
        if (-not $status.Code) {
            if ($status.Message -match 'records\s+Zero') {
                continue
            }

            throw "Sophos API returned a status without a code while trying to $actionPart$targetPart. '$($status.Message)' (StatusPath=$($status.XPathHint))"
        }

        $code = 0
        if (-not [int]::TryParse($status.Code, [ref]$code)) {
            throw "Sophos API returned an unreadable status code while trying to $actionPart$targetPart. Code '$($status.Code)' - $($status.Message) (StatusPath=$($status.XPathHint))"
        }

        # Status codes per the table published by Sophos
        if ($code -eq 200 -or $code -eq 216) {
            if ($fallbackUsed) {
                Write-Warning "Sophos API reported success (code $code) while trying to $actionPart$targetPart, but the status was found outside the expected path for -ObjectName '$ObjectName'. The operation likely succeeded; measure and correct -ObjectName for this operation. (StatusPath=$($status.XPathHint))"
            }
            continue
        }

        if ($code -eq 201 -or $code -eq 203 -or ($code -ge 211 -and $code -le 215)) {
            Write-Warning "Sophos API reported code $code while trying to $actionPart$targetPart. $($status.Message)"
            continue
        }

        # The published table runs 200-216 and then resumes at 500, so 217-499 is undefined.
        # Only the two codes actually measured against a firewall are let through, and only
        # because the write demonstrably succeeded in both cases: creating a WebFilterCategory
        # with an external URL list answers 217 or 222 'Unable to get status message' and the
        # object is created correctly.
        #
        # The rest of that range still throws. Waving through every undocumented code would
        # fail open - an unrecognised code would be reported as success while the firewall
        # did nothing, which is exactly the defect class this module has been bitten by
        # before. A wrongly reported failure is visible and harmless; a wrongly reported
        # success is neither.
        if ($code -eq 217 -or $code -eq 222) {
            Write-Warning "Sophos API returned code $code while trying to $actionPart$targetPart, which the published status table does not describe. The operation is expected to have succeeded, but verify the result on the firewall. $($status.Message)"
            continue
        }

        throw "Sophos API error while trying to $actionPart$targetPart. Code $code - $($status.Message) (StatusPath=$($status.XPathHint))"
    }
}

#endregion

#region Session Management

<#
.SYNOPSIS
    Resolves a -Session argument to a session object. Internal helper, not exported.
 
.DESCRIPTION
    Accepts the same shapes a domain cmdlet's -Session parameter can receive: $null (passed
    straight through, meaning "no session"), a registered session name (looked up in the
    named-session registry, case-insensitively), an object already tagged with the
    PSTypeName 'SophosFirewall.Session' (the return value of Connect-SfosFirewall, passed
    through unchanged), or - as a duck-typing fallback - any other object that at least has a
    Firewall property, so a caller who built a compatible object by hand is not blocked.
    Anything else throws.
 
.PARAMETER Session
    The raw value bound to a cmdlet's -Session parameter.
#>

function Resolve-SfosSessionArgument {
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [AllowNull()]
        [object]$Session
    )

    if ($null -eq $Session) {
        return $null
    }

    if ($Session -is [string]) {
        if ($script:SfosSessions.ContainsKey($Session)) {
            return $script:SfosSessions[$Session]
        }
        throw "No session named '$Session' is registered. Use Get-SfosSession to list registered sessions, or Connect-SfosFirewall -Name '$Session' to register one."
    }

    if ($Session.PSObject.TypeNames -contains 'SophosFirewall.Session') {
        return $Session
    }

    # Duck-typing fallback: accept anything that looks like a session object rather than
    # requiring the exact type, so a hand-built compatible object still works.
    if ($Session.PSObject.Properties.Match('Firewall').Count -gt 0) {
        return $Session
    }

    throw 'The value passed to -Session is neither a registered session name nor a session object. Use Get-SfosSession to list registered sessions, or pass the object returned by Connect-SfosFirewall.'
}

<#
.SYNOPSIS
    Resolves connection parameters from session context or explicit values.
 
.DESCRIPTION
    Looks up connection parameters from the module session variable if not explicitly provided.
    Ensures all required parameters are available for API calls.
 
    When the caller's bound parameters include a 'Session' key - i.e. the calling cmdlet
    declared a -Session parameter and it was bound, even to $null - that session becomes the
    base instead of the default session, and an explicit -Session $null disables the fallback
    to the default session entirely rather than silently keeping it. This is the same
    ContainsKey philosophy already used below for -Port 0 and -SkipCertificateCheck:$false.
    Without a 'Session' key in -BoundParameters (every call site that predates -Session),
    behaviour is unchanged: the default session set by Connect-SfosFirewall is the base.
 
.PARAMETER BoundParameters
    Hashtable of bound parameters from calling cmdlet.
 
.OUTPUTS
    Hashtable with resolved Firewall, Port, Username, Password, and SkipCertificateCheck.
 
.EXAMPLE
    $resolved = Resolve-SfosParameters -BoundParameters $PSBoundParameters
.EXAMPLE
    # A cmdlet with its own -Session parameter passes $PSBoundParameters straight through;
    # if -Session was bound, its value - a registered name or a session object - becomes the
    # base for this resolution instead of the default session.
    $resolved = Resolve-SfosParameters -BoundParameters $PSBoundParameters
#>

function Resolve-SfosParameters {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$BoundParameters
    )

    $base = $script:SfosConnection
    if ($BoundParameters.ContainsKey('Session')) {
        # ContainsKey, not a truthiness test on the value: an explicit -Session $null must
        # switch off the default-session fallback, not be treated as "not supplied".
        $base = Resolve-SfosSessionArgument -Session $BoundParameters['Session']
    }

    $resolved = @{
        Firewall             = $BoundParameters.Firewall
        Port                 = $BoundParameters.Port
        Username             = $BoundParameters.Username
        Password             = $BoundParameters.Password
        SkipCertificateCheck = $BoundParameters.SkipCertificateCheck
    }

    if ($base) {
        if (-not $resolved.Firewall) {
            $resolved.Firewall = $base.Firewall
        }
        # ContainsKey again: 0 is falsy, so -not would treat an explicit -Port 0 as
        # "not supplied" and quietly substitute another port instead of rejecting it.
        if (-not $BoundParameters.ContainsKey('Port')) {
            $resolved.Port = $base.Port
        }
        if (-not $resolved.Username) {
            $resolved.Username = $base.Username
        }
        if (-not $resolved.Password) {
            $resolved.Password = $base.Password
        }
        # ContainsKey, not -not: an explicit -SkipCertificateCheck:$false must win over
        # a session that was opened with the switch enabled.
        if (-not $BoundParameters.ContainsKey('SkipCertificateCheck')) {
            $resolved.SkipCertificateCheck = $base.SkipCertificateCheck
        }
    }

    if (-not $resolved.Firewall -or -not $resolved.Username -or -not $resolved.Password) {
        throw 'No active Sophos Firewall connection found. Use Connect-SfosFirewall to establish a connection, pass -Session, or provide Firewall, Username, and Password explicitly.'
    }

    if (-not $BoundParameters.ContainsKey('Port') -and -not $resolved.Port) {
        $resolved.Port = $script:DefaultSfosPort
    }

    # Connect-SfosFirewall validates the range, this path did not: a negative port used to
    # travel all the way into the URI and surface as an opaque UriFormatException.
    if ($resolved.Port -lt 1 -or $resolved.Port -gt 65535) {
        throw "Port $($resolved.Port) is outside the valid range 1-65535."
    }

    return $resolved
}

<#
.SYNOPSIS
    Establishes a connection to a Sophos Firewall.
 
.DESCRIPTION
    Stores connection details in the module session variable for reuse by other cmdlets.
    Credentials are stored as SecureString for security.
 
.PARAMETER Firewall
    Sophos Firewall hostname or IP address.
 
.PARAMETER Port
    Management/API port number (default: 4444).
 
.PARAMETER Credential
    PSCredential object containing username and password.
 
.PARAMETER SkipCertificateCheck
    Skips SSL certificate validation for self-signed certificates.
 
.PARAMETER Name
    Registers this connection under a name in the session registry, so it can be referenced
    later as -Session '<Name>' from any cmdlet, or listed with Get-SfosSession, without
    holding a reference to the returned object. Lookup is case-insensitive. Optional; a
    connection made without -Name still becomes the default session exactly as before, just
    without a registry entry.
 
.PARAMETER NoDefault
    Keeps the current default session (the one used when no -Session is passed anywhere)
    unchanged instead of replacing it with this connection. Only meaningful together with
    -Name - without -Name there would be no other way to reach this connection again, so
    -NoDefault alone is a no-op and this connection still becomes the default, exactly like
    calling Connect-SfosFirewall without -NoDefault at all.
 
.OUTPUTS
    PSCustomObject with connection details (PSTypeName 'SophosFirewall.Session'). The object
    shape is unchanged from earlier versions - Firewall, Port, Username, Password,
    SkipCertificateCheck - and carries no Name property, so it can still be splatted directly
    (@session) without colliding with a domain cmdlet's own -Name parameter. The
    Name-to-session mapping lives only in the session registry, queried via Get-SfosSession.
 
.EXAMPLE
    $cred = Get-Credential -Message "Sophos Firewall Admin"
    Connect-SfosFirewall -Firewall "192.168.1.1" -Port 4444 -Credential $cred -SkipCertificateCheck
.EXAMPLE
    # Hold two connections at once: fw1 becomes the default session (used by any call with
    # no -Session), fw2 is registered but does not replace it.
    Connect-SfosFirewall -Firewall "fw1.example.test" -Credential $cred -Name 'fw1'
    Connect-SfosFirewall -Firewall "fw2.example.test" -Credential $cred -Name 'fw2' -NoDefault
    Get-SfosSession
#>

function Connect-SfosFirewall {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Firewall,

        [ValidateRange(1, 65535)]
        [int]$Port = $script:DefaultSfosPort,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [pscredential]$Credential,

        [switch]$SkipCertificateCheck,

        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [switch]$NoDefault
    )

    $session = [PSCustomObject]@{
        Firewall             = $Firewall
        Port                 = $Port
        Username             = $Credential.UserName
        Password             = $Credential.Password
        SkipCertificateCheck = [bool]$SkipCertificateCheck
    }
    $session.PSObject.TypeNames.Insert(0, 'SophosFirewall.Session')

    if ($Name) {
        $script:SfosSessions[$Name] = $session
    }

    # -NoDefault only has an effect together with -Name: without a registered name there is
    # no other way to reach this connection again, so treating a bare -NoDefault as "make no
    # default at all" would just lose the session. See .PARAMETER NoDefault.
    if (-not ($NoDefault -and $Name)) {
        $script:SfosConnection = $session
    }

    Write-Verbose "Connected to Sophos Firewall at $Firewall`:$Port as $($Credential.UserName)"
    return $session
}

<#
.SYNOPSIS
    Disconnects from one, several, or all Sophos Firewall sessions.
 
.DESCRIPTION
    Four mutually exclusive ways to select what to disconnect:
    - no parameter (the default parameter set): clears the default session exactly as
      before - byte-identical behaviour for every existing caller.
    - -Name: removes the one named session from the registry, and also clears the default
      session if that named session happens to be the current default.
    - -Session: same as -Name, but takes the session object itself (or a name, resolved the
      same way -Session is resolved everywhere else) - accepts pipeline input, so
      Get-SfosSession | Disconnect-SfosFirewall works.
    - -All: clears the default session and empties the entire registry.
 
.PARAMETER Name
    The registered name of the session to remove.
 
.PARAMETER Session
    A registered session name or a session object returned by Connect-SfosFirewall.
 
.PARAMETER All
    Disconnects the default session and every registered named session.
 
.EXAMPLE
    Disconnect-SfosFirewall
.EXAMPLE
    Disconnect-SfosFirewall -Name 'fw2'
.EXAMPLE
    Get-SfosSession -Name 'fw2' | Disconnect-SfosFirewall
.EXAMPLE
    Disconnect-SfosFirewall -All
#>

function Disconnect-SfosFirewall {
    # PSReviewUnusedParameter: -All only selects the 'All' parameter set; $PSCmdlet.ParameterSetName
    # drives the body, so the switch's value itself is never read once it has done that job.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'All')]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Name')]
        [string]$Name,

        [Parameter(Mandatory, ParameterSetName = 'Session', ValueFromPipeline)]
        [object]$Session,

        [Parameter(ParameterSetName = 'All')]
        [switch]$All
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Default' {
                if ($script:SfosConnection) {
                    Write-Verbose "Disconnected from Sophos Firewall at $($script:SfosConnection.Firewall)"
                    $script:SfosConnection = $null
                }
            }
            'Name' {
                if (-not $script:SfosSessions.ContainsKey($Name)) {
                    throw "No session named '$Name' is registered. Use Get-SfosSession to list registered sessions."
                }
                $removed = $script:SfosSessions[$Name]
                $script:SfosSessions.Remove($Name)
                if ($script:SfosConnection -and $script:SfosConnection -eq $removed) {
                    $script:SfosConnection = $null
                }
                Write-Verbose "Disconnected session '$Name' from Sophos Firewall at $($removed.Firewall)"
            }
            'Session' {
                # Get-SfosSession's own view object (Name/Firewall/Port/Username/
                # SkipCertificateCheck/IsDefault, deliberately without Password) is not the
                # same object reference as the registry entry and carries no
                # 'SophosFirewall.Session' PSTypeName, so Resolve-SfosSessionArgument's
                # duck-typing fallback would pass it through unchanged - and the reference
                # match below would then find nothing and silently disconnect nothing, the
                # exact "answers success, changes nothing" failure this project's rules
                # single out as the worst outcome available. Route it through its own Name
                # instead so 'Get-SfosSession -Name x | Disconnect-SfosFirewall' resolves to
                # the actual registered object.
                $target = $Session
                if ($target -isnot [string] -and
                    $target.PSObject.TypeNames -notcontains 'SophosFirewall.Session' -and
                    $target.PSObject.Properties.Match('Name').Count -gt 0) {
                    $target = [string]$target.Name
                }

                $resolved = Resolve-SfosSessionArgument -Session $target
                if ($resolved) {
                    foreach ($key in @($script:SfosSessions.Keys)) {
                        if ($script:SfosSessions[$key] -eq $resolved) {
                            $script:SfosSessions.Remove($key)
                        }
                    }
                    if ($script:SfosConnection -and $script:SfosConnection -eq $resolved) {
                        $script:SfosConnection = $null
                    }
                    Write-Verbose "Disconnected session from Sophos Firewall at $($resolved.Firewall)"
                }
            }
            'All' {
                $script:SfosConnection = $null
                $script:SfosSessions.Clear()
                Write-Verbose 'Disconnected all Sophos Firewall sessions.'
            }
        }
    }
}

<#
.SYNOPSIS
    Lists registered Sophos Firewall sessions, or one specific session by name.
 
.DESCRIPTION
    Returns a view of the session registry populated by Connect-SfosFirewall -Name -
    Firewall, Port, Username, SkipCertificateCheck and whether the session is the current
    default. The Password is deliberately not included in the view.
 
.PARAMETER Name
    Return only the session registered under this name. Throws if no session with that name
    is registered.
 
.OUTPUTS
    PSCustomObject with Name, Firewall, Port, Username, SkipCertificateCheck, IsDefault.
 
.EXAMPLE
    Get-SfosSession
.EXAMPLE
    Get-SfosSession -Name 'fw2'
#>

function Get-SfosSession {
    # PSUseSingularNouns: 'Session' is already singular - this cmdlet returns either every
    # registered session (no -Name) or exactly one (-Name), same as every other Get-Sfos*.
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string]$Name
    )

    if ($Name) {
        if (-not $script:SfosSessions.ContainsKey($Name)) {
            throw "No session named '$Name' is registered. Use Connect-SfosFirewall -Name '$Name' to register one."
        }
        $entry = $script:SfosSessions[$Name]
        return [PSCustomObject]@{
            Name                 = $Name
            Firewall             = $entry.Firewall
            Port                 = $entry.Port
            Username             = $entry.Username
            SkipCertificateCheck = $entry.SkipCertificateCheck
            IsDefault            = [bool]($script:SfosConnection -and $script:SfosConnection -eq $entry)
        }
    }

    foreach ($key in @($script:SfosSessions.Keys | Sort-Object)) {
        $entry = $script:SfosSessions[$key]
        [PSCustomObject]@{
            Name                 = $key
            Firewall             = $entry.Firewall
            Port                 = $entry.Port
            Username             = $entry.Username
            SkipCertificateCheck = $entry.SkipCertificateCheck
            IsDefault            = [bool]($script:SfosConnection -and $script:SfosConnection -eq $entry)
        }
    }
}

#endregion

#region Module Exports

Export-ModuleMember -Function @(
    'Connect-SfosFirewall',
    'Disconnect-SfosFirewall',
    'Get-SfosSession',
    'Invoke-SfosApi',
    'Get-SfosApiStatus',
    'Assert-SfosApiReturnSuccess',
    'Resolve-SfosParameters',
    'ConvertTo-SfosXmlEscaped'
)

#endregion