From 2d00ee7e80e00def6159acfc486c006d98ce704e Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:23:37 -0800 Subject: [PATCH 01/12] Squashed changes from PR #1507 --- src/Private/Logging.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Private/Logging.ps1 b/src/Private/Logging.ps1 index 394311e4a..3b35edb35 100644 --- a/src/Private/Logging.ps1 +++ b/src/Private/Logging.ps1 @@ -63,10 +63,10 @@ function Get-PodeLoggingFileMethod { $item.ToString() | Out-File -FilePath $path -Encoding utf8 -Append -Force # if set, remove log files beyond days set (ensure this is only run once a day) - if (($options.MaxDays -gt 0) -and ($options.NextClearDown -lt [DateTime]::Now.Date)) { + if (($options.MaxDays -gt 0) -and ($options.NextClearDown -le [DateTime]::Now.Date)) { $date = [DateTime]::Now.Date.AddDays(-$options.MaxDays) - $null = Get-ChildItem -Path $options.Path -Filter '*.log' -Force | + $null = Get-ChildItem -Path $options.Path -Filter "$($options.Name)_*.log" -Force | Where-Object { $_.CreationTime -lt $date } | Remove-Item -Force From cc0560a77f2cefee653a0f60bac2b1d63701d306 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:23:43 -0800 Subject: [PATCH 02/12] Squashed changes from PR #1504 --- examples/OpenApi-TuttiFrutti.ps1 | 16 +++--- pode.build.ps1 | 58 +++++++++++++++++-- src/Pode.Internal.psm1 | 5 +- src/Pode.psd1 | 3 + src/Pode.psm1 | 20 +++++-- src/Private/Helpers.ps1 | 72 ++++++++++++++---------- src/Public/OAComponents.ps1 | 14 +---- src/Public/OAProperties.ps1 | 5 -- src/Public/Utilities.ps1 | 47 +++++++++------- tests/unit/OpenApi.Tests.ps1 | 96 ++++++++++++++++---------------- 10 files changed, 205 insertions(+), 131 deletions(-) diff --git a/examples/OpenApi-TuttiFrutti.ps1 b/examples/OpenApi-TuttiFrutti.ps1 index e80ff8023..d5c739e60 100644 --- a/examples/OpenApi-TuttiFrutti.ps1 +++ b/examples/OpenApi-TuttiFrutti.ps1 @@ -162,7 +162,7 @@ Some useful links: New-PodeOAStringProperty -Name 'shipDate' -Format Date-Time | New-PodeOAStringProperty -Name 'status' -Description 'Order Status' -Example 'approved' -Enum @('placed', 'approved', 'delivered') | New-PodeOABoolProperty -Name 'complete' | - New-PodeOASchemaProperty -Name 'Address' -Reference 'Address' | + New-PodeOAComponentSchemaProperty -Name 'Address' -Reference 'Address' | New-PodeOAObjectProperty -Name 'Order' -XmlName 'order' -AdditionalProperties (New-PodeOAStringProperty ) | Add-PodeOAComponentSchema -Name 'Order' @@ -196,10 +196,10 @@ Some useful links: New-PodeOAObjectProperty -Name 'Pet' -XmlName 'pet' -Properties ( New-PodeOAIntProperty -Name 'id'-Format Int64 -Example @(10, 2, 4) -ReadOnly | New-PodeOAStringProperty -Name 'name' -Example 'doggie' -Required | - New-PodeOASchemaProperty -Name 'category' -Reference 'Category' | + New-PodeOAComponentSchemaProperty -Name 'category' -Reference 'Category' | New-PodeOAStringProperty -Name 'petType' -Example 'dog' -Required | New-PodeOAStringProperty -Name 'photoUrls' -Array | - New-PodeOASchemaProperty -Name 'tags' -Reference 'Tag' | + New-PodeOAComponentSchemaProperty -Name 'tags' -Reference 'Tag' | New-PodeOAStringProperty -Name 'status' -Description 'pet status in the store' -Enum @('available', 'pending', 'sold') )) @@ -226,7 +226,7 @@ Some useful links: New-PodeOAStringProperty -Name 'name' | New-PodeOAStringProperty -Name 'type' | - New-PodeOASchemaProperty -Name 'children' -Array -Reference 'StructPart' | + New-PodeOAComponentSchemaProperty -Name 'children' -Array -Reference 'StructPart' | New-PodeOAObjectProperty | Add-PodeOAComponentSchema -Name 'StructPart' @@ -252,10 +252,10 @@ Some useful links: New-PodeOAObjectProperty -Name 'Pet' -XmlName 'pet' } -Properties @( (New-PodeOAIntProperty -Name 'id'-Format Int64 -Example 10 -ReadOnly), (New-PodeOAStringProperty -Name 'name' -Example 'doggie' -Required), - (New-PodeOASchemaProperty -Name 'category' -Component 'Category'), + (New-PodeOAComponentSchemaProperty -Name 'category' -Component 'Category'), (New-PodeOAStringProperty -Name 'petType' -Example 'dog' -Required), (New-PodeOAStringProperty -Name 'photoUrls' -Array), - (New-PodeOASchemaProperty -Name 'tags' -Component 'Tag') + (New-PodeOAComponentSchemaProperty -Name 'tags' -Component 'Tag') (New-PodeOAStringProperty -Name 'status' -Description 'pet status in the store' -Enum @('available', 'pending', 'sold')) )) #> @@ -309,7 +309,7 @@ Some useful links: Set-PodeOARouteInfo -Summary 'Find pets by ID' -Description 'Returns pets based on ID' -OperationId 'getPetsById' -PassThru | Set-PodeOARequest -PassThru -Parameters @( ( New-PodeOAStringProperty -Name 'id' -Description 'ID of pet to use' -array | ConvertTo-PodeOAParameter -In Path -Style Simple -Required )) | - Add-PodeOAResponse -StatusCode 200 -Description 'pet response' -Content (@{ '*/*' = New-PodeOASchemaProperty -Reference 'Pet' -array }) -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'pet response' -Content (@{ '*/*' = New-PodeOAComponentSchemaProperty -Reference 'Pet' -array }) -PassThru | Add-PodeOAResponse -Default -Description 'error payload' -Content (@{ 'text/html' = 'ApiResponse' }) -PassThru @@ -746,7 +746,7 @@ Some useful links: New-PodeOAStringProperty -name 'id' -format 'uuid' | New-PodeOAObjectProperty -name 'address' -NoProperties | New-PodeOAStringProperty -name 'children' -array | - New-PodeOASchemaProperty -Name 'addresses' -Reference 'Address' -Array | + New-PodeOAComponentSchemaProperty -Name 'addresses' -Reference 'Address' -Array | New-PodeOAObjectProperty }) | Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -PassThru | Add-PodeOAResponse -StatusCode 400 -Description 'Invalid ID supplied' -PassThru | diff --git a/pode.build.ps1 b/pode.build.ps1 index ae8862739..658146921 100644 --- a/pode.build.ps1 +++ b/pode.build.ps1 @@ -9,6 +9,10 @@ .PARAMETER Version Specifies the project version for stamping, packaging, and documentation. Defaults to '0.0.0'. +.PARAMETER Prerelease + Specifies the prerelease label to append to the module version, following semantic versioning conventions. + Examples include 'alpha.1', 'alpha.2', 'beta.1', etc. This label indicates the stability and iteration of the prerelease version. + .PARAMETER PesterVerbosity Sets the verbosity level for Pester tests. Options: None, Normal, Detailed, Diagnostic. @@ -90,6 +94,9 @@ param( [string] $Version = '0.0.0', + [string] + $Prerelease, + [string] [ValidateSet('None', 'Normal' , 'Detailed', 'Diagnostic')] $PesterVerbosity = 'Normal', @@ -487,7 +494,15 @@ function Invoke-PodeBuildDotnetBuild { # Optionally set assembly version if ($Version) { Write-Output "Assembly Version: $Version" - $AssemblyVersion = "-p:Version=$Version" + + if ($Prerelease) { + $AssemblyVersion = "-p:VersionPrefix=$Version" + $AssemblyPrerelease = "-p:VersionSuffix=$Prerelease" + } + else { + $AssemblyVersion = "-p:Version=$Version" + $AssemblyPrerelease = '' + } } else { $AssemblyVersion = '' @@ -497,7 +512,7 @@ function Invoke-PodeBuildDotnetBuild { dotnet restore # Use dotnet publish for .NET Core and .NET 5+ - dotnet publish --configuration Release --self-contained --framework $target $AssemblyVersion --output ../Libs/$target + dotnet publish --configuration Release --self-contained --framework $target $AssemblyVersion $AssemblyPrerelease --output ../Libs/$target if (!$?) { throw "Build failed for target framework '$target'." @@ -889,6 +904,38 @@ if (($null -eq $PSCmdlet.MyInvocation) -or ($PSCmdlet.MyInvocation.BoundParamete return } +# Import Version File if needed +if ($Version -eq '0.0.0' -and (Test-Path './Version.json' -PathType Leaf)) { + $importedVersion = Get-Content -Path './Version.json' | ConvertFrom-Json + if ($importedVersion.Version) { + $Version = $importedVersion.Version + } + if ($importedVersion.Prerelease) { + $Prerelease = $importedVersion.Prerelease + } +} + +Write-Host '---------------------------------------------------' -ForegroundColor DarkCyan + +# Display the Pode build version +if ($Prerelease) { + Write-Host "Pode Build: v$Version-$Prerelease (Pre-release)" -ForegroundColor DarkCyan +} +else { + if ($Version -eq '0.0.0') { + Write-Host 'Pode Build: [Development Version]' -ForegroundColor DarkCyan + } + else { + Write-Host "Pode Build: v$Version" -ForegroundColor DarkCyan + } +} + +# Display the current UTC time in a readable format +$utcTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") +Write-Host "Start Time: $utcTime" -ForegroundColor DarkCyan + +Write-Host '---------------------------------------------------' -ForegroundColor DarkCyan + Add-BuildTask Default { Write-Host 'Tasks in the Build Script:' -ForegroundColor DarkMagenta @@ -940,7 +987,10 @@ Add-BuildTask Default { # Synopsis: Stamps the version onto the Module Add-BuildTask StampVersion { $pwshVersions = Get-PodeBuildPwshEOL - (Get-Content ./pkg/Pode.psd1) | ForEach-Object { $_ -replace '\$version\$', $Version -replace '\$versionsUntested\$', $pwshVersions.eol -replace '\$versionsSupported\$', $pwshVersions.supported -replace '\$buildyear\$', ((get-date).Year) } | Set-Content ./pkg/Pode.psd1 + if ($Prerelease) { + $prereleaseValue = "Prerelease = '$Prerelease'" + } + (Get-Content ./pkg/Pode.psd1) | ForEach-Object { $_ -replace '\$version\$', $Version -replace '\$versionsUntested\$', $pwshVersions.eol -replace '\$versionsSupported\$', $pwshVersions.supported -replace '\$buildyear\$', ((get-date).Year) -replace '#\$Prerelease-Here\$', $prereleaseValue } | Set-Content ./pkg/Pode.psd1 (Get-Content ./pkg/Pode.Internal.psd1) | ForEach-Object { $_ -replace '\$version\$', $Version } | Set-Content ./pkg/Pode.Internal.psd1 (Get-Content ./packers/choco/pode_template.nuspec) | ForEach-Object { $_ -replace '\$version\$', $Version } | Set-Content ./packers/choco/pode.nuspec (Get-Content ./packers/choco/tools/ChocolateyInstall_template.ps1) | ForEach-Object { $_ -replace '\$version\$', $Version } | Set-Content ./packers/choco/tools/ChocolateyInstall.ps1 @@ -1712,4 +1762,4 @@ task ReleaseNotes { $categories[$category] | Sort-Object | ForEach-Object { Write-Host $_ } Write-Host '' } -} +} \ No newline at end of file diff --git a/src/Pode.Internal.psm1 b/src/Pode.Internal.psm1 index dcb516be0..96176933a 100644 --- a/src/Pode.Internal.psm1 +++ b/src/Pode.Internal.psm1 @@ -14,4 +14,7 @@ Get-ChildItem "$($root)/Public/*.ps1" | ForEach-Object { . ([System.IO.Path]::Ge $funcs = Get-ChildItem Function: | Where-Object { $sysfuncs -notcontains $_ } # export the module's public functions -Export-ModuleMember -Function ($funcs.Name) \ No newline at end of file +Export-ModuleMember -Function ($funcs.Name) + +# Ensure backward compatibility by creating aliases for legacy Pode OpenAPI function names. +New-PodeFunctionAlias \ No newline at end of file diff --git a/src/Pode.psd1 b/src/Pode.psd1 index b520f389c..fa158a2ec 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -541,6 +541,9 @@ # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. PrivateData = @{ PSData = @{ + + #$Prerelease-Here$ + # Tags applied to this module. These help with module discovery in online galleries. Tags = @( 'powershell', 'web', 'server', 'http', 'https', 'listener', 'rest', 'api', 'tcp', diff --git a/src/Pode.psm1 b/src/Pode.psm1 index 34a812c3a..8e117f136 100644 --- a/src/Pode.psm1 +++ b/src/Pode.psm1 @@ -75,18 +75,26 @@ try { $moduleManifestPath = Join-Path -Path $root -ChildPath 'Pode.psd1' # Import the module manifest to access its properties - $moduleManifest = Import-PowerShellDataFile -Path $moduleManifestPath -ErrorAction Stop + $PodeManifest = Import-PowerShellDataFile -Path $moduleManifestPath -ErrorAction Stop $podeDll = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Pode' } if ($podeDll) { - if ( $moduleManifest.ModuleVersion -ne '$version$') { - $moduleVersion = ([version]::new($moduleManifest.ModuleVersion + '.0')) + if ( $PodeManifest.ModuleVersion -ne '$version$') { + $moduleVersion = ([version]::new($PodeManifest.ModuleVersion + '.0')) if ($podeDll.GetName().Version -ne $moduleVersion) { # An existing incompatible Pode.DLL version {0} is loaded. Version {1} is required. Open a new Powershell/pwsh session and retry. throw ($PodeLocale.incompatiblePodeDllExceptionMessage -f $podeDll.GetName().Version, $moduleVersion) } + $assemblyInformationalVersion = $podeDll.CustomAttributes.Where({ $_.AttributeType -eq [System.Reflection.AssemblyInformationalVersionAttribute] }) + if ($null -ne $PodeManifest.PrivateData.PSData.Prerelease) { + if (! $assemblyInformationalVersion.ConstructorArguments.Value.Contains($PodeManifest.PrivateData.PSData.Prerelease)) { + throw ($PodeLocale.incompatiblePodeDllExceptionMessage -f $assemblyInformationalVersion.ConstructorArguments.Value, "$moduleVersion-$($PodeManifest.PrivateData.PSData.Prerelease)") + } + }elseif($assemblyInformationalVersion.ConstructorArguments.Value.Contains('-')){ + throw ($PodeLocale.incompatiblePodeDllExceptionMessage -f $assemblyInformationalVersion.ConstructorArguments.Value, $moduleVersion) + } } } else { @@ -123,6 +131,9 @@ try { # load public functions Get-ChildItem "$($root)/Public/*.ps1" | ForEach-Object { . ([System.IO.Path]::GetFullPath($_)) } + # Ensure backward compatibility by creating aliases for legacy Pode OpenAPI function names. + New-PodeFunctionAlias + # get functions from memory and compare to existing to find new functions added $funcs = Get-ChildItem Function: | Where-Object { $sysfuncs -notcontains $_ } $aliases = Get-ChildItem Alias: | Where-Object { $sysaliases -notcontains $_ } @@ -141,6 +152,5 @@ catch { } finally { # Cleanup temporary variables - Remove-Variable -Name 'tmpPodeLocale', 'localesPath', 'moduleManifest', 'root', 'version', 'libsPath', 'netFolder', 'podeDll', 'sysfuncs', 'sysaliases', 'funcs', 'aliases', 'moduleManifestPath', 'moduleVersion' -ErrorAction SilentlyContinue + Remove-Variable -Name 'tmpPodeLocale', 'localesPath', 'root', 'version', 'libsPath', 'netFolder', 'podeDll', 'sysfuncs', 'sysaliases', 'funcs', 'aliases', 'moduleManifestPath', 'moduleVersion' -ErrorAction SilentlyContinue } - diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 804e5e7d2..b2a32e66c 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -3278,30 +3278,6 @@ function Test-PodePlaceholder { } -<# -.SYNOPSIS -Retrieves the PowerShell module manifest object for the specified module. - -.DESCRIPTION -This function constructs the path to a PowerShell module manifest file (.psd1) located in the parent directory of the script root. It then imports the module manifest file to access its properties and returns the manifest object. This can be useful for scripts that need to dynamically discover and utilize module metadata, such as version, dependencies, and exported functions. - -.PARAMETERS -This function does not accept any parameters. - -.EXAMPLE -$manifest = Get-PodeModuleManifest -This example calls the `Get-PodeModuleManifest` function to retrieve the module manifest object and stores it in the variable `$manifest`. - -#> -function Get-PodeModuleManifest { - # Construct the path to the module manifest (.psd1 file) - $moduleManifestPath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'Pode.psd1' - - # Import the module manifest to access its properties - $moduleManifest = Import-PowerShellDataFile -Path $moduleManifestPath - return $moduleManifest -} - <# .SYNOPSIS Tests the running PowerShell version for compatibility with Pode, identifying end-of-life (EOL) and untested versions. @@ -3335,8 +3311,7 @@ function Test-PodeVersionPwshEOL { param( [switch] $ReportUntested ) - $moduleManifest = Get-PodeModuleManifest - if ($moduleManifest.ModuleVersion -eq '$version$') { + if ($PodeManifest.ModuleVersion -eq '$version$') { return @{ eol = $false supported = $true @@ -3344,7 +3319,7 @@ function Test-PodeVersionPwshEOL { } $psVersion = $PSVersionTable.PSVersion - $eolVersions = $moduleManifest.PrivateData.PwshVersions.Untested -split ',' + $eolVersions = $PodeManifest.PrivateData.PwshVersions.Untested -split ',' $isEol = "$($psVersion.Major).$($psVersion.Minor)" -in $eolVersions if ($isEol) { @@ -3352,7 +3327,7 @@ function Test-PodeVersionPwshEOL { Write-PodeHost ($PodeLocale.eolPowerShellWarningMessage -f $PodeVersion, $PSVersion) -ForegroundColor Yellow } - $SupportedVersions = $moduleManifest.PrivateData.PwshVersions.Supported -split ',' + $SupportedVersions = $PodeManifest.PrivateData.PwshVersions.Supported -split ',' $isSupported = "$($psVersion.Major).$($psVersion.Minor)" -in $SupportedVersions if ((! $isSupported) -and (! $isEol) -and $ReportUntested) { @@ -3966,3 +3941,44 @@ function ConvertTo-PodeSleep { function Test-PodeIsISEHost { return ((Test-PodeIsWindows) -and ('Windows PowerShell ISE Host' -eq $Host.Name)) } + +<# +.SYNOPSIS + Creates aliases for Pode OpenAPI functions to support legacy naming conventions. +.DESCRIPTION + This function sets up the following aliases in the current script scope: + - New-PodeOASchemaProperty as an alias for New-PodeOAComponentSchemaProperty. + - Enable-PodeOpenApiViewer as an alias for Enable-PodeOAViewer. + - Enable-PodeOA as an alias for Enable-PodeOpenApi. + - Get-PodeOpenApiDefinition as an alias for Get-PodeOADefinition. + The function helps maintain backward compatibility and simplifies calling Pode OpenAPI functions. + +.PARAMETER None + This function does not accept any parameters. +.OUTPUTS + None. The function creates aliases and does not output any objects. +.EXAMPLE + PS C:\> New-PodeFunctionAlias + The function creates the necessary aliases for Pode OpenAPI functions in the current session. +.NOTES + This function is part of the Pode project and adheres to the coding standards defined in the Pode GitHub Repository. + Internal function subject to change. +#> +function New-PodeFunctionAlias { + # Alias + if (!(Test-Path Alias:New-PodeOASchemaProperty)) { + New-Alias New-PodeOASchemaProperty -Value New-PodeOAComponentSchemaProperty -Scope Script + } + + if (!(Test-Path Alias:Enable-PodeOpenApiViewer)) { + New-Alias Enable-PodeOpenApiViewer -Value Enable-PodeOAViewer -Scope Script + } + + if (!(Test-Path Alias:Enable-PodeOA)) { + New-Alias Enable-PodeOA -Value Enable-PodeOpenApi -Scope Script + } + + if (!(Test-Path Alias:Get-PodeOpenApiDefinition)) { + New-Alias Get-PodeOpenApiDefinition -Value Get-PodeOADefinition -Scope Script + } +} \ No newline at end of file diff --git a/src/Public/OAComponents.ps1 b/src/Public/OAComponents.ps1 index a829f5274..7a49d94f6 100644 --- a/src/Public/OAComponents.ps1 +++ b/src/Public/OAComponents.ps1 @@ -950,16 +950,4 @@ function Remove-PodeOAComponent { $PodeContext.Server.OpenAPI.Definitions[$tag].components[$field ].remove($Name) } } -} - -if (!(Test-Path Alias:Enable-PodeOpenApiViewer)) { - New-Alias Enable-PodeOpenApiViewer -Value Enable-PodeOAViewer -} - -if (!(Test-Path Alias:Enable-PodeOA)) { - New-Alias Enable-PodeOA -Value Enable-PodeOpenApi -} - -if (!(Test-Path Alias:Get-PodeOpenApiDefinition)) { - New-Alias Get-PodeOpenApiDefinition -Value Get-PodeOADefinition -} +} \ No newline at end of file diff --git a/src/Public/OAProperties.ps1 b/src/Public/OAProperties.ps1 index 322bc2f21..ae4adc834 100644 --- a/src/Public/OAProperties.ps1 +++ b/src/Public/OAProperties.ps1 @@ -2072,9 +2072,4 @@ function New-PodeOAComponentSchemaProperty { return $param } } -} - - -if (!(Test-Path Alias:New-PodeOASchemaProperty)) { - New-Alias New-PodeOASchemaProperty -Value New-PodeOAComponentSchemaProperty } \ No newline at end of file diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index e193bbbc3..9391e8845 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -1356,38 +1356,47 @@ function New-PodeCron { <# .SYNOPSIS -Gets the version of the Pode module. + Retrieves the version of the Pode module. .DESCRIPTION -The Get-PodeVersion function checks the version of the Pode module specified in the module manifest. If the module version is not a placeholder value ('$version$'), it returns the actual version prefixed with 'v.'. If the module version is the placeholder value, indicating the development branch, it returns '[develop branch]'. + The `Get-PodeVersion` function checks the version of the Pode module as specified in the module manifest. + If the module version is **not** the placeholder value (`'$version$'`), it returns the actual version prefixed with `'v'`. + If the module version **is** the placeholder value, indicating the development branch, it returns `"[dev]"`. -.PARAMETER None -This function does not accept any parameters. +.PARAMETER Raw + If specified, the function returns only the raw module version without the `'v'` prefix. + By default, the function formats the version as `'vX.Y.Z'` unless the module is in development mode. .OUTPUTS -System.String -Returns a string indicating the version of the Pode module or '[dev]' if on a development version. + System.String + Returns a string representing the Pode module version in one of the following formats: + - `"vX.Y.Z"` for a release version (e.g., `"v1.2.3"`). + - `"[dev]"` for development versions. .EXAMPLE -PS> $moduleManifest = @{ ModuleVersion = '1.2.3' } -PS> Get-PodeVersion - -Returns 'v1.2.3'. + PS> Get-PodeVersion + Returns the Pode module version, e.g., `'v1.2.3'` for release versions or `"[dev]"` if in development. .EXAMPLE -PS> $moduleManifest = @{ ModuleVersion = '$version$' } -PS> Get-PodeVersion - -Returns '[dev]'. + PS> Get-PodeVersion -Raw + Returns the raw version number, e.g., `'1.2.3'`, without the `'v'` prefix. .NOTES -This function assumes that $moduleManifest is a hashtable representing the loaded module manifest, with a key of ModuleVersion. - + - If the module version is a placeholder (`'$version$'`), the function assumes it's running from the development branch. #> function Get-PodeVersion { - $moduleManifest = Get-PodeModuleManifest - if ($moduleManifest.ModuleVersion -ne '$version$') { - return "v$($moduleManifest.ModuleVersion)" + param ( + [switch] + $Raw + ) + + if ($PodeManifest.ModuleVersion -ne '$version$') { + $prefix = if ($Raw) { '' } else { 'v' } + if ($PodeManifest.PrivateData.PSData.Prerelease) { + return "$prefix$($PodeManifest.ModuleVersion)-$($PodeManifest.PrivateData.PSData.Prerelease)" + } + return "$prefix$($PodeManifest.ModuleVersion)" + } else { return '[dev]' diff --git a/tests/unit/OpenApi.Tests.ps1 b/tests/unit/OpenApi.Tests.ps1 index b62b29b89..f72474521 100644 --- a/tests/unit/OpenApi.Tests.ps1 +++ b/tests/unit/OpenApi.Tests.ps1 @@ -1797,7 +1797,7 @@ Describe 'OpenApi' { } - Context 'New-PodeOASchemaProperty' { + Context 'New-PodeOAComponentSchemaProperty' { BeforeEach { Add-PodeOAComponentSchema -Name 'Cat' -Schema ( New-PodeOAObjectProperty -Properties @( @@ -1807,12 +1807,12 @@ Describe 'OpenApi' { } # Check if the function exists - It 'New-PodeOASchemaProperty function exists' { - Get-Command New-PodeOASchemaProperty | Should -Not -Be $null + It 'New-PodeOAComponentSchemaProperty function exists' { + Get-Command New-PodeOAComponentSchemaProperty | Should -Not -Be $null } It 'Standard' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Reference 'Cat' + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Reference 'Cat' $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 0 $result.type | Should -Be 'schema' @@ -1821,15 +1821,15 @@ Describe 'OpenApi' { } It 'ArrayNoSwitchesUniqueItems' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -Array -MinItems 2 -MaxItems 4 -UniqueItems + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -Array -MinItems 2 -MaxItems 4 -UniqueItems $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 1 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result.array | Should -BeTrue $result.uniqueItems | Should -BeTrue $result.minItems | Should -BeTrue @@ -1837,15 +1837,15 @@ Describe 'OpenApi' { } It 'ArrayDeprecatedUniqueItems' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -Deprecated -Array -MinItems 2 -MaxItems 4 -UniqueItems + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -Deprecated -Array -MinItems 2 -MaxItems 4 -UniqueItems $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 1 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result.deprecated | Should -Be $true $result.array | Should -BeTrue $result.uniqueItems | Should -BeTrue @@ -1853,15 +1853,15 @@ Describe 'OpenApi' { $result.maxItems | Should -BeTrue } It 'ArrayNullableUniqueItems' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -Nullable -Array -MinItems 2 -MaxItems 4 -UniqueItems + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -Nullable -Array -MinItems 2 -MaxItems 4 -UniqueItems $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 2 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result['nullable'] | Should -Be $true $result.array | Should -BeTrue $result.uniqueItems | Should -BeTrue @@ -1869,15 +1869,15 @@ Describe 'OpenApi' { $result.maxItems | Should -BeTrue } It 'ArrayWriteOnlyUniqueItems' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -WriteOnly -Array -MinItems 2 -MaxItems 4 -UniqueItems + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -WriteOnly -Array -MinItems 2 -MaxItems 4 -UniqueItems $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 2 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result['writeOnly'] | Should -Be $true $result.array | Should -BeTrue $result.uniqueItems | Should -BeTrue @@ -1885,15 +1885,15 @@ Describe 'OpenApi' { $result.maxItems | Should -BeTrue } It 'ArrayReadOnlyUniqueItems' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -ReadOnly -Array -MinItems 2 -MaxItems 4 -UniqueItems + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -ReadOnly -Array -MinItems 2 -MaxItems 4 -UniqueItems $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 2 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result['readOnly'] | Should -Be $true $result.array | Should -BeTrue $result.uniqueItems | Should -BeTrue @@ -1902,75 +1902,75 @@ Describe 'OpenApi' { } It 'ArrayNoSwitches' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -Array -MinItems 2 -MaxItems 4 + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -Array -MinItems 2 -MaxItems 4 $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 1 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result.array | Should -BeTrue $result.minItems | Should -BeTrue $result.maxItems | Should -BeTrue } It 'ArrayDeprecated' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -Deprecated -Array -MinItems 2 -MaxItems 4 + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -Deprecated -Array -MinItems 2 -MaxItems 4 $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 1 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result.deprecated | Should -Be $true $result.array | Should -BeTrue $result.minItems | Should -BeTrue $result.maxItems | Should -BeTrue } It 'ArrayNullable' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -Nullable -Array -MinItems 2 -MaxItems 4 + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -Nullable -Array -MinItems 2 -MaxItems 4 $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 2 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result['nullable'] | Should -Be $true $result.array | Should -BeTrue $result.minItems | Should -BeTrue $result.maxItems | Should -BeTrue } It 'ArrayWriteOnly' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -WriteOnly -Array -MinItems 2 -MaxItems 4 + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -WriteOnly -Array -MinItems 2 -MaxItems 4 $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 2 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result['writeOnly'] | Should -Be $true $result.array | Should -BeTrue $result.minItems | Should -BeTrue $result.maxItems | Should -BeTrue } It 'ArrayReadOnly' { - $result = New-PodeOASchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOASchemaProperty' -Reference 'Cat' ` - -Example 'Example for New-PodeOASchemaProperty' -ReadOnly -Array -MinItems 2 -MaxItems 4 + $result = New-PodeOAComponentSchemaProperty -Name 'testSchema' -Description 'Test for New-PodeOAComponentSchemaProperty' -Reference 'Cat' ` + -Example 'Example for New-PodeOAComponentSchemaProperty' -ReadOnly -Array -MinItems 2 -MaxItems 4 $result | Should -Not -BeNullOrEmpty #$result.Count | Should -Be 2 $result.type | Should -Be 'schema' $result.name | Should -Be 'testSchema' $result.schema | Should -Be 'Cat' - $result.description | Should -Be 'Test for New-PodeOASchemaProperty' - $result['example'] | Should -Be 'Example for New-PodeOASchemaProperty' + $result.description | Should -Be 'Test for New-PodeOAComponentSchemaProperty' + $result['example'] | Should -Be 'Example for New-PodeOAComponentSchemaProperty' $result['readOnly'] | Should -Be $true $result.array | Should -BeTrue $result.minItems | Should -BeTrue @@ -3352,10 +3352,10 @@ Describe 'OpenApi' { $Pet = New-PodeOAObjectProperty -Name 'Pet' -XmlName 'pet' -Properties ( (New-PodeOAIntProperty -Name 'id'-Format Int64 -Example 10 -ReadOnly ), (New-PodeOAStringProperty -Name 'name' -Example 'doggie' -Required) , - (New-PodeOASchemaProperty -Name 'category' -Reference 'Category' ), + (New-PodeOAComponentSchemaProperty -Name 'category' -Reference 'Category' ), (New-PodeOAStringProperty -Name 'petType' -Example 'dog' -Required) , (New-PodeOAStringProperty -Name 'photoUrls' -Array) , - (New-PodeOASchemaProperty -Name 'tags' -Reference 'Tag') , + (New-PodeOAComponentSchemaProperty -Name 'tags' -Reference 'Tag') , (New-PodeOAStringProperty -Name 'status' -Description 'pet status in the store' -Enum @('available', 'pending', 'sold')) ) $Pet.type | Should -be 'object' @@ -3407,10 +3407,10 @@ Describe 'OpenApi' { It 'By Pipeline' { $Pet = New-PodeOAIntProperty -Name 'id'-Format Int64 -Example 10 -ReadOnly | New-PodeOAStringProperty -Name 'name' -Example 'doggie' -Required | - New-PodeOASchemaProperty -Name 'category' -Reference 'Category' | + New-PodeOAComponentSchemaProperty -Name 'category' -Reference 'Category' | New-PodeOAStringProperty -Name 'petType' -Example 'dog' -Required | New-PodeOAStringProperty -Name 'photoUrls' -Array | - New-PodeOASchemaProperty -Name 'tags' -Reference 'Tag' | + New-PodeOAComponentSchemaProperty -Name 'tags' -Reference 'Tag' | New-PodeOAStringProperty -Name 'status' -Description 'pet status in the store' -Enum @('available', 'pending', 'sold') | New-PodeOAObjectProperty -Name 'Pet' -XmlName 'pet' $Pet.type | Should -be 'object' From 71eee920de2d3929e2707c56a6d3f282fa3543bd Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:23:49 -0800 Subject: [PATCH 03/12] Squashed changes from PR #1509 --- docs/Tutorials/Endpoints/Basics.md | 36 ++++++++++++ src/Misc/favicon.ico | Bin 0 -> 1150 bytes src/Private/PodeServer.ps1 | 87 ++++++++++++++++------------- src/Public/Endpoint.ps1 | 49 ++++++++++++---- 4 files changed, 121 insertions(+), 51 deletions(-) create mode 100644 src/Misc/favicon.ico diff --git a/docs/Tutorials/Endpoints/Basics.md b/docs/Tutorials/Endpoints/Basics.md index 888a529ba..1060a5bb8 100644 --- a/docs/Tutorials/Endpoints/Basics.md +++ b/docs/Tutorials/Endpoints/Basics.md @@ -188,3 +188,39 @@ To set this property, include it in `server.psd1` configuration file as shown be } } ``` + +## Favicons + +Pode allows you to customize or disable the favicon for HTTP/HTTPS endpoints. By default, Pode serves a built-in `favicon.ico`, but you can override this behavior using the `-Favicon` and `-NoFavicon` parameters. + +- **`-Favicon` (byte[])**: Allows you to specify a custom favicon as a byte array. +- **`-NoFavicon` (switch)**: Disables the favicon, preventing browsers from requesting it. + +### **Favicon Format and Specifications** + +Favicons are typically stored in the `.ico` format, which is a container that can hold multiple image sizes and color depths. This ensures compatibility with different browsers and devices. Some modern browsers also support `.png` and `.svg` favicons. + +For more details on favicon formats and specifications, refer to the [Favicon specification](https://en.wikipedia.org/wiki/Favicon) and [RFC 5988](https://datatracker.ietf.org/doc/html/rfc5988). + +### **Favicon Size Recommendations** + +Favicons should include multiple resolutions for optimal display across different devices. Recommended sizes include: + +- **16x16** → Used in browser tabs, bookmarks, and address bars. +- **32x32** → Used in browser tabs on higher-resolution displays. +- **48x48** → Used by some older browsers and web applications. +- **64x64+** → Generally not used by browsers but can be helpful for scalability in web apps. +- **256x256** → Mainly for **Windows app icons** (not typically used as a favicon in browsers). + +### **Usage Example** + +```powershell +# Load a custom favicon from file +$iconBytes = [System.IO.File]::ReadAllBytes("C:\path\to\custom.ico") +Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http -Favicon $iconBytes + +# Disable favicon for an endpoint +Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http -NoFavicon +``` + +Using a favicon enhances the user experience by providing a recognizable site icon in browser tabs and bookmarks. diff --git a/src/Misc/favicon.ico b/src/Misc/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0b937da25e5f7176dcab9c4220fe1955959f1a6e GIT binary patch literal 1150 zcmbVMJ8V*66#jYH!I-{`ZbqG~OMQ$cnz+@)#lgj-lQD62ad0&5J`!DZ@d*?tT0*&S zxfVkU4NXPSLTwvfX#o|eJU`EWE)NysAmse_{^y+Uyw4TFK>v{uLFZv{%`C(LA;d8v zVi8B@(?bZ7jqTNm?{q|zNd#;H!5K4=u|pq}mQjfD68{>VG zP{~%nYJ_SZ;+SK>vy9tG+mPRQ%?2W?vl;X1) z@F_aYI)$7IzUPGZpCanullOsE10nYq=OBGlN>ThMuOS}3L~#p_r&ifSG3Oq{ryc<9 zHv5yh)$bqJmpmHv0@)pB8};1`O1Zf~d{W}k)2o&fI=gVU>t7z1=iB-Xu_cyukvSxbxZ*;uU&p(~I$Nu8)E^?l-eqqlM?uD$cLh}?; z&v1;z{4ep-e90cIhAyDKn`Rq@tP2s}arPmx;kiS|Z$4pJvtFd$PBTW%W~4Vi0&iWz z*h<`{xvR78R^kp)t2ZczD&Mo~52+3Zx%b`q;rZ{(uWC1OoMF#V?4+%Xk;k`fm1aes zGu1|W;M8}kH~+kUaxdiGS*U&nJ*R~!d|bTD{(j#>(?9o5qdk%&>)qcOxx=0PCRum? Ky5E8Sef|Vu3g27+ literal 0 HcmV?d00001 diff --git a/src/Private/PodeServer.ps1 b/src/Private/PodeServer.ps1 index bb3356d8a..b8e6453c6 100644 --- a/src/Private/PodeServer.ps1 +++ b/src/Private/PodeServer.ps1 @@ -210,59 +210,66 @@ function Start-PodeWebServer { if ($Request.IsAborted) { throw $Request.Error } - - # if we have an sse clientId, verify it and then set details in WebEvent - if ($WebEvent.Request.HasSseClientId) { - if (!(Test-PodeSseClientIdValid)) { - throw [Pode.PodeRequestException]::new("The X-PODE-SSE-CLIENT-ID value is not valid: $($WebEvent.Request.SseClientId)") - } - - if (![string]::IsNullOrEmpty($WebEvent.Request.SseClientName) -and !(Test-PodeSseClientId -Name $WebEvent.Request.SseClientName -ClientId $WebEvent.Request.SseClientId)) { - throw [Pode.PodeRequestException]::new("The SSE Connection being referenced via the X-PODE-SSE-NAME and X-PODE-SSE-CLIENT-ID headers does not exist: [$($WebEvent.Request.SseClientName)] $($WebEvent.Request.SseClientId)", 404) - } - - $WebEvent.Sse = @{ - Name = $WebEvent.Request.SseClientName - Group = $WebEvent.Request.SseClientGroup - ClientId = $WebEvent.Request.SseClientId - LastEventId = $null - IsLocal = $false - } + + # deal with favicon if available + if ($WebEvent.Path -eq '/favicon.ico' -and ($null -ne $PodeContext.Server.Endpoints[$context.EndpointName].Favicon)) { + # Write the file content as the HTTP response + Write-PodeTextResponse -Bytes $PodeContext.Server.Endpoints[$context.EndpointName].Favicon -ContentType 'image/png' -StatusCode 200 } + else { + # if we have an sse clientId, verify it and then set details in WebEvent + if ($WebEvent.Request.HasSseClientId) { + if (!(Test-PodeSseClientIdValid)) { + throw [Pode.PodeRequestException]::new("The X-PODE-SSE-CLIENT-ID value is not valid: $($WebEvent.Request.SseClientId)") + } + + if (![string]::IsNullOrEmpty($WebEvent.Request.SseClientName) -and !(Test-PodeSseClientId -Name $WebEvent.Request.SseClientName -ClientId $WebEvent.Request.SseClientId)) { + throw [Pode.PodeRequestException]::new("The SSE Connection being referenced via the X-PODE-SSE-NAME and X-PODE-SSE-CLIENT-ID headers does not exist: [$($WebEvent.Request.SseClientName)] $($WebEvent.Request.SseClientId)", 404) + } - # invoke global and route middleware - if ((Invoke-PodeMiddleware -Middleware $PodeContext.Server.Middleware -Route $WebEvent.Path)) { - # has the request been aborted - if ($Request.IsAborted) { - throw $Request.Error + $WebEvent.Sse = @{ + Name = $WebEvent.Request.SseClientName + Group = $WebEvent.Request.SseClientGroup + ClientId = $WebEvent.Request.SseClientId + LastEventId = $null + IsLocal = $false + } } - if ((Invoke-PodeMiddleware -Middleware $WebEvent.Route.Middleware)) { + # invoke global and route middleware + if ((Invoke-PodeMiddleware -Middleware $PodeContext.Server.Middleware -Route $WebEvent.Path)) { # has the request been aborted if ($Request.IsAborted) { throw $Request.Error } - # invoke the route - if ($null -ne $WebEvent.StaticContent) { - $fileBrowser = $WebEvent.Route.FileBrowser - if ($WebEvent.StaticContent.IsDownload) { - Write-PodeAttachmentResponseInternal -Path $WebEvent.StaticContent.Source -FileBrowser:$fileBrowser + if ((Invoke-PodeMiddleware -Middleware $WebEvent.Route.Middleware)) { + # has the request been aborted + if ($Request.IsAborted) { + throw $Request.Error } - elseif ($WebEvent.StaticContent.RedirectToDefault) { - $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) - Move-PodeResponseUrl -Url "$($WebEvent.Path)/$($file)" + + # invoke the route + if ($null -ne $WebEvent.StaticContent) { + $fileBrowser = $WebEvent.Route.FileBrowser + if ($WebEvent.StaticContent.IsDownload) { + Write-PodeAttachmentResponseInternal -Path $WebEvent.StaticContent.Source -FileBrowser:$fileBrowser + } + elseif ($WebEvent.StaticContent.RedirectToDefault) { + $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) + Move-PodeResponseUrl -Url "$($WebEvent.Path)/$($file)" + } + else { + $cachable = $WebEvent.StaticContent.IsCachable + Write-PodeFileResponseInternal -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge ` + -Cache:$cachable -FileBrowser:$fileBrowser + } } - else { - $cachable = $WebEvent.StaticContent.IsCachable - Write-PodeFileResponseInternal -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge ` - -Cache:$cachable -FileBrowser:$fileBrowser + elseif ($null -ne $WebEvent.Route.Logic) { + $null = Invoke-PodeScriptBlock -ScriptBlock $WebEvent.Route.Logic -Arguments $WebEvent.Route.Arguments ` + -UsingVariables $WebEvent.Route.UsingVariables -Scoped -Splat } } - elseif ($null -ne $WebEvent.Route.Logic) { - $null = Invoke-PodeScriptBlock -ScriptBlock $WebEvent.Route.Logic -Arguments $WebEvent.Route.Arguments ` - -UsingVariables $WebEvent.Route.UsingVariables -Scoped -Splat - } } } } diff --git a/src/Public/Endpoint.ps1 b/src/Public/Endpoint.ps1 index d6b1c97c1..d2d9fa523 100644 --- a/src/Public/Endpoint.ps1 +++ b/src/Public/Endpoint.ps1 @@ -1,4 +1,3 @@ - <# .SYNOPSIS Bind an endpoint to listen for incoming Requests. @@ -19,13 +18,13 @@ An optional hostname for the endpoint, specifying a hostname restricts access to The protocol of the supplied endpoint. .PARAMETER Certificate -The path to a certificate that can be use to enable HTTPS +The path to a certificate that can be used to enable HTTPS. .PARAMETER CertificatePassword -The password for the certificate file referenced in Certificate +The password for the certificate file referenced in Certificate. .PARAMETER CertificateKey -A key file to be paired with a PEM certificate file referenced in Certificate +A key file to be paired with a PEM certificate file referenced in Certificate. .PARAMETER CertificateThumbprint A certificate thumbprint to bind onto HTTPS endpoints (Windows). @@ -34,13 +33,13 @@ A certificate thumbprint to bind onto HTTPS endpoints (Windows). A certificate subject name to bind onto HTTPS endpoints (Windows). .PARAMETER CertificateStoreName -The name of a certifcate store where a certificate can be found (Default: My) (Windows). +The name of a certificate store where a certificate can be found (Default: My) (Windows). .PARAMETER CertificateStoreLocation -The location of a certifcate store where a certificate can be found (Default: CurrentUser) (Windows). +The location of a certificate store where a certificate can be found (Default: CurrentUser) (Windows). .PARAMETER X509Certificate -The raw X509 certificate that can be use to enable HTTPS +The raw X509 certificate that can be used to enable HTTPS. .PARAMETER TlsMode The TLS mode to use on secure connections, options are Implicit or Explicit (SMTP only) (Default: Implicit). @@ -58,16 +57,16 @@ A quick description of the Endpoint - normally used in OpenAPI. An optional Acknowledge message to send to clients when they first connect, for TCP and SMTP endpoints only. .PARAMETER SslProtocol -One or more optional SSL Protocols this endpoints supports. (Default: SSL3/TLS12 - Just TLS12 on MacOS). +One or more optional SSL Protocols this endpoint supports. (Default: SSL3/TLS12 - Just TLS12 on MacOS). .PARAMETER CRLFMessageEnd If supplied, TCP endpoints will expect incoming data to end with CRLF. .PARAMETER Force -Ignore Adminstrator checks for non-localhost endpoints. +Ignore Administrator checks for non-localhost endpoints. .PARAMETER SelfSigned -Create and bind a self-signed certifcate for HTTPS endpoints. +Create and bind a self-signed certificate for HTTPS endpoints. .PARAMETER AllowClientCertificate Allow for client certificates to be sent on requests. @@ -85,6 +84,12 @@ For IPv6, this will only work if the IPv6 address can convert to a valid IPv4 ad .PARAMETER Default If supplied, this endpoint will be the default one used for internally generating URLs. +.PARAMETER Favicon +A byte array representing a custom favicon for HTTP/HTTPS endpoints. + +.PARAMETER NoFavicon +If supplied, disables the default favicon for HTTP/HTTPS endpoints. + .EXAMPLE Add-PodeEndpoint -Address localhost -Port 8090 -Protocol Http @@ -208,7 +213,13 @@ function Add-PodeEndpoint { $DualMode, [switch] - $Default + $Default, + + [byte[]] + $Favicon, + + [switch] + $NoFavicon ) # error if serverless @@ -292,6 +303,20 @@ function Add-PodeEndpoint { throw ($PodeLocale.crlfMessageEndCheckOnlySupportedOnTcpEndpointsExceptionMessage) } + if (! $NoFavicon ) { + $Favicon = $null + } + # Load the default favicon + elseif ( ($null -eq $Favicon) -and (@('Http', 'Https') -icontains $Protocol)) { + $podeRoot = Get-PodeModuleMiscPath + if (Test-PodeIsPSCore) { + $Favicon = (Get-Content -Path ([System.IO.Path]::Combine($podeRoot, 'favicon.ico')) -Raw -AsByteStream) + } + else { + $Favicon = (Get-Content -Path ([System.IO.Path]::Combine($podeRoot, 'favicon.ico')) -Raw -Encoding byte) + } + } + # new endpoint object $obj = @{ Name = $Name @@ -324,8 +349,10 @@ function Add-PodeEndpoint { Acknowledge = $Acknowledge CRLFMessageEnd = $CRLFMessageEnd } + Favicon = $Favicon } + # set ssl protocols if (!(Test-PodeIsEmpty $SslProtocol)) { $obj.Ssl.Protocols = (ConvertTo-PodeSslProtocol -Protocol $SslProtocol) From 86885c757ee078cbd057fde5abefc25d620b0a26 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:24:49 -0800 Subject: [PATCH 04/12] Squashed changes from PR #1386 --- docs/Tutorials/Routes/Overview.md | 91 +- docs/Tutorials/Routes/Parameters/Body.md | 100 ++ docs/Tutorials/Routes/Parameters/Cookies.md | 107 ++ docs/Tutorials/Routes/Parameters/Headers.md | 102 ++ docs/Tutorials/Routes/Parameters/Paths.md | 108 ++ docs/Tutorials/Routes/Parameters/Queries.md | 107 ++ src/Pode.psd1 | 5 + src/Public/Cookies.ps1 | 99 +- src/Public/Headers.ps1 | 76 +- src/Public/Utilities.ps1 | 1329 ++++++++++++++++++- tests/unit/Utility.Tests.ps1 | 956 +++++++++++++ 11 files changed, 2948 insertions(+), 132 deletions(-) create mode 100644 docs/Tutorials/Routes/Parameters/Body.md create mode 100644 docs/Tutorials/Routes/Parameters/Cookies.md create mode 100644 docs/Tutorials/Routes/Parameters/Headers.md create mode 100644 docs/Tutorials/Routes/Parameters/Paths.md create mode 100644 docs/Tutorials/Routes/Parameters/Queries.md create mode 100644 tests/unit/Utility.Tests.ps1 diff --git a/docs/Tutorials/Routes/Overview.md b/docs/Tutorials/Routes/Overview.md index a9acd7efe..aba0f2b6d 100644 --- a/docs/Tutorials/Routes/Overview.md +++ b/docs/Tutorials/Routes/Overview.md @@ -39,96 +39,21 @@ The scriptblock for the route will have access to the `$WebEvent` variable which You can add your routes straight into the [`Start-PodeServer`](../../../Functions/Core/Start-PodeServer) scriptblock, or separate them into different files. These files can then be dot-sourced, or you can use [`Use-PodeRoutes`](../../../Functions/Routes/Use-PodeRoutes) to automatically load all ps1 files within a `/routes` directory at the root of your server. -## Payloads +## Retrieving Client Parameters -The following is an example of using data from a request's payload - ie, the data in the body of POST request. To retrieve values from the payload you can use the `.Data` property on the `$WebEvent` variable to a route's logic. +When working with REST calls, data can be passed by the client using various methods, including Cookies, Headers, Paths, Queries, and Body. Each of these methods has specific ways to retrieve the data: -Depending the the Content-Type supplied, Pode has inbuilt body-parsing logic for JSON, XML, CSV, and Form data. +- **Cookies**: Cookies sent by the client can be accessed using the `$WebEvent.Cookies` property or the `Get-PodeCookie` function for more advanced handling. For more details, refer to the [Cookies Documentation](./Parameters/Cookies.md). -This example will get the `userId` and "find" user, returning the users data: +- **Headers**: Headers can be retrieved using the `$WebEvent.Request.Headers` property or the `Get-PodeHeader` function, which provides additional deserialization options. Learn more in the [Headers Documentation](./Parameters/Headers.md). -```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8080 -Protocol Http +- **Paths**: Parameters passed through the URL path can be accessed using the `$WebEvent.Parameters` property or the `Get-PodePathParameter` function. Detailed information can be found in the [Path Parameters Documentation](./Parameters/Paths.md). - Add-PodeRoute -Method Post -Path '/users' -ScriptBlock { - # get the user - $user = Get-DummyUser -UserId $WebEvent.Data.userId - - # return the user - Write-PodeJsonResponse -Value @{ - Username = $user.username - Age = $user.age - } - } -} -``` - -The following request will invoke the above route: - -```powershell -Invoke-WebRequest -Uri 'http://localhost:8080/users' -Method Post -Body '{ "userId": 12345 }' -ContentType 'application/json' -``` +- **Queries**: Query parameters from the URL can be accessed via `$WebEvent.Query` or retrieved using the `Get-PodeQueryParameter` function for deserialization support. Check the [Query Parameters Documentation](./Parameters/Queries.md). -!!! important - The `ContentType` is required as it informs Pode on how to parse the requests payload. For example, if the content type were `application/json`, then Pode will attempt to parse the body of the request as JSON - converting it to a hashtable. +- **Body**: Data sent in the request body, such as in POST requests, can be retrieved using the `$WebEvent.Data` property or the `Get-PodeBodyData` function for enhanced deserialization capabilities. See the [Body Data Documentation](./Parameters/Body.md) for more information. -!!! important - On PowerShell 5 referencing JSON data on `$WebEvent.Data` must be done as `$WebEvent.Data.userId`. This also works in PowerShell 6+, but you can also use `$WebEvent.Data['userId']` on PowerShell 6+. - -## Query Strings - -The following is an example of using data from a request's query string. To retrieve values from the query string you can use the `.Query` property from the `$WebEvent` variable. This example will return a user based on the `userId` supplied: - -```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8080 -Protocol Http - - Add-PodeRoute -Method Get -Path '/users' -ScriptBlock { - # get the user - $user = Get-DummyUser -UserId $WebEvent.Query['userId'] - - # return the user - Write-PodeJsonResponse -Value @{ - Username = $user.username - Age = $user.age - } - } -} -``` - -The following request will invoke the above route: - -```powershell -Invoke-WebRequest -Uri 'http://localhost:8080/users?userId=12345' -Method Get -``` - -## Parameters - -The following is an example of using values supplied on a request's URL using parameters. To retrieve values that match a request's URL parameters you can use the `.Parameters` property from the `$WebEvent` variable. This example will get the `:userId` and "find" user, returning the users data: - -```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8080 -Protocol Http - - Add-PodeRoute -Method Get -Path '/users/:userId' -ScriptBlock { - # get the user - $user = Get-DummyUser -UserId $WebEvent.Parameters['userId'] - - # return the user - Write-PodeJsonResponse -Value @{ - Username = $user.username - Age = $user.age - } - } -} -``` - -The following request will invoke the above route: - -```powershell -Invoke-WebRequest -Uri 'http://localhost:8080/users/12345' -Method Get -``` +Each link provides detailed usage and examples to help you retrieve and manipulate the parameters passed by the client effectively. ## Script from File diff --git a/docs/Tutorials/Routes/Parameters/Body.md b/docs/Tutorials/Routes/Parameters/Body.md new file mode 100644 index 000000000..b620d3a54 --- /dev/null +++ b/docs/Tutorials/Routes/Parameters/Body.md @@ -0,0 +1,100 @@ +# Body Payloads + +The following is an example of using data from a request's payload—i.e., the data in the body of a POST request. To retrieve values from the payload, you can use the `.Data` property on the `$WebEvent` variable in a route's logic. + +Alternatively, you can use the `Get-PodeBodyData` function to retrieve the body data, with additional support for deserialization. + +Depending on the Content-Type supplied, Pode has built-in body-parsing logic for JSON, XML, CSV, and Form data. + +This example will get the `userId` and "find" the user, returning the user's data: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Post -Path '/users' -ScriptBlock { + # get the user + $user = Get-DummyUser -UserId $WebEvent.Data.userId + + # return the user + Write-PodeJsonResponse -Value @{ + Username = $user.username + Age = $user.age + } + } +} +``` + +The following request will invoke the above route: + +```powershell +Invoke-WebRequest -Uri 'http://localhost:8080/users' -Method Post -Body '{ "userId": 12345 }' -ContentType 'application/json' +``` + +!!! important + The `ContentType` is required as it informs Pode on how to parse the request's payload. For example, if the content type is `application/json`, Pode will attempt to parse the body of the request as JSON—converting it to a hashtable. + +!!! important + On PowerShell 5, referencing JSON data on `$WebEvent.Data` must be done as `$WebEvent.Data.userId`. This also works in PowerShell 6+, but you can also use `$WebEvent.Data['userId']` on PowerShell 6+. + +### Using Get-PodeBodyData + +Alternatively, you can use the `Get-PodeBodyData` function to retrieve the body data. This function works similarly to the `.Data` property on `$WebEvent` and supports the same content types. + +Here is the same example using `Get-PodeBodyData`: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Post -Path '/users' -ScriptBlock { + # get the body data + $body = Get-PodeBodyData + + # get the user + $user = Get-DummyUser -UserId $body.userId + + # return the user + Write-PodeJsonResponse -Value @{ + Username = $user.username + Age = $user.age + } + } +} +``` + +### Deserialization with Get-PodeBodyData + +Typically the request body is encoded in Json,Xml or Yaml but if it's required the `Get-PodeBodyData` function can also deserialize body data from requests, allowing for more complex data handling scenarios where the only allowed ContentTypes are `application/x-www-form-urlencoded` or `multipart/form-data`. This feature can be especially useful when dealing with serialized data structures that require specific interpretation styles. + +To enable deserialization, use the `-Deserialize` switch along with the following options: + +- **`-NoExplode`**: Prevents deserialization from exploding arrays in the body data. This is useful when dealing with comma-separated values where array expansion is not desired. +- **`-Style`**: Defines the deserialization style (`'Simple'`, `'Label'`, `'Matrix'`, `'Form'`, `'SpaceDelimited'`, `'PipeDelimited'`, `'DeepObject'`) to interpret the body data correctly. The default style is `'Form'`. +- **`-KeyName`**: Specifies the key name to use when deserializing, allowing accurate mapping of the body data. The default value for `KeyName` is `'id'`. + +### Example with Deserialization + +This example demonstrates deserialization of body data using specific styles and options: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Post -Path '/items' -ScriptBlock { + # retrieve and deserialize the body data + $body = Get-PodeBodyData -Deserialize -Style 'Matrix' -NoExplode + + # get the item based on the deserialized data + $item = Get-DummyItem -ItemId $body.id + + # return the item details + Write-PodeJsonResponse -Value @{ + Name = $item.name + Quantity = $item.quantity + } + } +} +``` + +In this example, `Get-PodeBodyData` is used to deserialize the body data with the `'Matrix'` style and prevent array explosion (`-NoExplode`). This approach provides flexible and precise handling of incoming body data, enhancing the capability of your Pode routes to manage complex payloads. \ No newline at end of file diff --git a/docs/Tutorials/Routes/Parameters/Cookies.md b/docs/Tutorials/Routes/Parameters/Cookies.md new file mode 100644 index 000000000..f1222aa2e --- /dev/null +++ b/docs/Tutorials/Routes/Parameters/Cookies.md @@ -0,0 +1,107 @@ + +# Cookies + +The following is an example of using values supplied in a request's cookies. To retrieve values from the cookies, you can use the `Cookies` property from the `$WebEvent` variable. + +Alternatively, you can use the `Get-PodeCookie` function to retrieve the cookie data, with additional support for deserialization and secure handling. + +This example will get the `SessionId` cookie and use it to authenticate the user, returning a success message: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/authenticate' -ScriptBlock { + # get the session ID from the cookie + $sessionId = $WebEvent.Cookies['SessionId'] + + # authenticate the session + $isAuthenticated = Authenticate-Session -SessionId $sessionId + + # return the result + Write-PodeJsonResponse -Value @{ + Authenticated = $isAuthenticated + } + } +} +``` + +The following request will invoke the above route: + +```powershell +Invoke-WebRequest -Uri 'http://localhost:8080/authenticate' -Method Get -Headers @{ Cookie = 'SessionId=abc123' } +``` + +## Using Get-PodeCookie + +Alternatively, you can use the `Get-PodeCookie` function to retrieve the cookie data. This function works similarly to the `Cookies` property on `$WebEvent`, but it provides additional options for deserialization and secure cookie handling. + +Here is the same example using `Get-PodeCookie`: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/authenticate' -ScriptBlock { + # get the session ID from the cookie + $sessionId = Get-PodeCookie -Name 'SessionId' + + # authenticate the session + $isAuthenticated = Authenticate-Session -SessionId $sessionId + + # return the result + Write-PodeJsonResponse -Value @{ + Authenticated = $isAuthenticated + } + } +} +``` + +### Deserialization with Get-PodeCookie + +The `Get-PodeCookie` function can also deserialize cookie values, allowing for more complex handling of serialized data sent in cookies. This feature is particularly useful when cookies contain encoded or structured content that needs specific parsing. + +To enable deserialization, use the `-Deserialize` switch along with the following options: + +- **`-NoExplode`**: Prevents deserialization from exploding arrays in the cookie value. This is useful when handling comma-separated values where array expansion is not desired. +- **`-Deserialize`**: Indicates that the retrieved cookie value should be deserialized, interpreting the content based on the provided deserialization style and options. + + + +#### Supported Deserialization Styles + +| Style | Explode | URI Template | Primitive Value (id = 5) | Array (id = [3, 4, 5]) | Object (id = {"role": "admin", "firstName": "Alex"}) | +|-------|---------|--------------|--------------------------|------------------------|------------------------------------------------------| +| form* | true* | | Cookie: id=5 | | | +| form | false | id={id} | Cookie: id=5 | Cookie: id=3,4,5 | Cookie: id=role,admin,firstName,Alex | + +\* Default serialization method + +### Example with Deserialization + +This example demonstrates deserialization of a cookie value: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/deserialize-cookie' -ScriptBlock { + # retrieve and deserialize the 'Session' cookie + $sessionData = Get-PodeCookie -Name 'Session' -Deserialize -NoExplode + + # process the deserialized cookie data + # (example processing logic here) + + # return the processed cookie data + Write-PodeJsonResponse -Value @{ + SessionData = $sessionData + } + } +} +``` + +In this example, `Get-PodeCookie` is used to deserialize the `Session` cookie, interpreting it according to the provided deserialization options. The `-NoExplode` switch ensures that any arrays within the cookie value are not expanded during deserialization. + +For further information regarding serialization, please refer to the [RFC6570](https://tools.ietf.org/html/rfc6570). + +For further information on general usage and retrieving cookies, please refer to the [Headers Documentation](Cookies.md). \ No newline at end of file diff --git a/docs/Tutorials/Routes/Parameters/Headers.md b/docs/Tutorials/Routes/Parameters/Headers.md new file mode 100644 index 000000000..913b860bf --- /dev/null +++ b/docs/Tutorials/Routes/Parameters/Headers.md @@ -0,0 +1,102 @@ +# Headers + +The following is an example of using values supplied in a request's headers. To retrieve values from the headers, you can use the `Headers` property from the `$WebEvent.Request` variable. Alternatively, you can use the `Get-PodeHeader` function to retrieve the header data. + +This example will get the Authorization header and validate the token, returning a success message: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/validate' -ScriptBlock { + # get the token + $token = $WebEvent.Request.Headers['Authorization'] + + # validate the token + $isValid = Test-PodeJwt -payload $token + + # return the result + Write-PodeJsonResponse -Value @{ + Success = $isValid + } + } +} +``` + +The following request will invoke the above route: + +```powershell +Invoke-WebRequest -Uri 'http://localhost:8080/validate' -Method Get -Headers @{ Authorization = 'Bearer some_token' } +``` + +## Using Get-PodeHeader + +Alternatively, you can use the `Get-PodeHeader` function to retrieve the header data. This function works similarly to the `Headers` property on `$WebEvent.Request`. + +Here is the same example using `Get-PodeHeader`: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/validate' -ScriptBlock { + # get the token + $token = Get-PodeHeader -Name 'Authorization' + + # validate the token + $isValid = Test-PodeJwt -payload $token + + # return the result + Write-PodeJsonResponse -Value @{ + Success = $isValid + } + } +} +``` + +### Deserialization with Get-PodeHeader + +The `Get-PodeHeader` function can also deserialize header values, enabling more advanced handling of serialized data sent in headers. This feature is useful when dealing with complex data structures or when headers contain encoded or serialized content. + +To enable deserialization, use the `-Deserialize` switch along with the following options: + +- **`-Explode`**: Specifies whether the deserialization process should explode arrays in the header value. This is useful when handling comma-separated values within the header. +- **`-Deserialize`**: Indicates that the retrieved header value should be deserialized, interpreting the content based on the deserialization style and options. + +#### Supported Deserialization Styles + +| Style | Explode | URI Template | Primitive Value (X-MyHeader = 5) | Array (X-MyHeader = [3, 4, 5]) | Object (X-MyHeader = {"role": "admin", "firstName": "Alex"}) | +|---------|---------|--------------|----------------------------------|--------------------------------|--------------------------------------------------------------| +| simple* | false* | {id} | X-MyHeader: 5 | X-MyHeader: 3,4,5 | X-MyHeader: role,admin,firstName,Alex | +| simple | true | {id*} | X-MyHeader: 5 | X-MyHeader: 3,4,5 | X-MyHeader: role=admin,firstName=Alex | + +\* Default serialization method + +### Example with Deserialization + +This example demonstrates deserialization of a header value: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/deserialize' -ScriptBlock { + # retrieve and deserialize the 'X-SerializedHeader' header + $headerData = Get-PodeHeader -Name 'X-SerializedHeader' -Deserialize -Explode + + # process the deserialized header data + # (example processing logic here) + + # return the processed header data + Write-PodeJsonResponse -Value @{ + HeaderData = $headerData + } + } +} +``` + +In this example, `Get-PodeHeader` is used to deserialize the `X-SerializedHeader` header, interpreting it according to the provided deserialization options. The `-Explode` switch ensures that any arrays within the header value are properly expanded during deserialization. + +For further information regarding serialization, please refer to the [RFC6570](https://tools.ietf.org/html/rfc6570). + +For further information on general usage and retrieving headers, please refer to the [Headers Documentation](Headers.md). diff --git a/docs/Tutorials/Routes/Parameters/Paths.md b/docs/Tutorials/Routes/Parameters/Paths.md new file mode 100644 index 000000000..e1a3d3e5a --- /dev/null +++ b/docs/Tutorials/Routes/Parameters/Paths.md @@ -0,0 +1,108 @@ + +# Paths + +The following is an example of using values supplied on a request's URL using parameters. To retrieve values that match a request's URL parameters, you can use the `Parameters` property from the `$WebEvent` variable. + +Alternatively, you can use the `Get-PodePathParameter` function to retrieve the parameter data. + +This example will get the `:userId` and "find" user, returning the user's data: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/users/:userId' -ScriptBlock { + # get the user + $user = Get-DummyUser -UserId $WebEvent.Parameters['userId'] + + # return the user + Write-PodeJsonResponse -Value @{ + Username = $user.username + Age = $user.age + } + } +} +``` + +The following request will invoke the above route: + +```powershell +Invoke-WebRequest -Uri 'http://localhost:8080/users/12345' -Method Get +``` + +### Using Get-PodePathParameter + +Alternatively, you can use the `Get-PodePathParameter` function to retrieve the parameter data. This function works similarly to the `Parameters` property on `$WebEvent` but provides additional options for deserialization when needed. + +Here is the same example using `Get-PodePathParameter`: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/users/:userId' -ScriptBlock { + # get the parameter data + $userId = Get-PodePathParameter -Name 'userId' + + # get the user + $user = Get-DummyUser -UserId $userId + + # return the user + Write-PodeJsonResponse -Value @{ + Username = $user.username + Age = $user.age + } + } +} +``` + +#### Deserialization with Get-PodePathParameter + +The `Get-PodePathParameter` function can handle deserialization of parameters passed in the URL path, query string, or body, using specific styles to interpret the data correctly. This is useful when dealing with more complex data structures or encoded parameter values. + +To enable deserialization, use the `-Deserialize` switch along with the following options: + +- **`-Explode`**: Specifies whether to explode arrays when deserializing, useful when parameters contain comma-separated values. +- **`-Style`**: Defines the deserialization style (`'Simple'`, `'Label'`, or `'Matrix'`) to interpret the parameter value correctly. The default style is `'Simple'`. +- **`-KeyName`**: Specifies the key name to use when deserializing, allowing you to map the parameter data accurately. The default value for `KeyName` is `'id'`. + +#### Supported Deserialization Styles + +| Style | Explode | URI Template | Primitive Value (id = 5) | Array (id = [3, 4, 5]) | Object (id = {"role": "admin", "firstName": "Alex"}) | +|---------|---------|---------------|--------------------------|------------------------|------------------------------------------------------| +| simple* | false* | /users/{id} | /users/5 | /users/3,4,5 | /users/role,admin,firstName,Alex | +| simple | true | /users/{id*} | /users/5 | /users/3,4,5 | /users/role=admin,firstName=Alex | +| label | false | /users/{.id} | /users/.5 | /users/.3,4,5 | /users/.role,admin,firstName,Alex | +| label | true | /users/{.id*} | /users/.5 | /users/.3.4.5 | /users/.role=admin.firstName=Alex | +| matrix | false | /users/{;id} | /users/;id=5 | /users/;id=3,4,5 | /users/;id=role,admin,firstName,Alex | +| matrix | true | /users/{;id*} | /users/;id=5 | /users/;id=3;id=4;id=5 | /users/;role=admin;firstName=Alex | + +\* Default serialization method + +#### Example with Deserialization + +This example demonstrates deserialization of a parameter that is styled and exploded as part of the request: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/items/:itemId' -ScriptBlock { + # retrieve and deserialize the 'itemId' parameter + $itemId = Get-PodePathParameter -Name 'itemId' -Deserialize -Style 'Label' -Explode + + # get the item based on the deserialized data + $item = Get-DummyItem -ItemId $itemId + + # return the item details + Write-PodeJsonResponse -Value @{ + Name = $item.name + Quantity = $item.quantity + } + } +} +``` + +In this example, the `Get-PodePathParameter` function is used to deserialize the `itemId` parameter, interpreting it according to the specified style (`Label`) and handling arrays if present (`-Explode`). The default `KeyName` is `'id'`, but it can be customized as needed. This approach allows for dynamic and precise handling of incoming request data, making your Pode routes more versatile and resilient. + +For further information regarding serialization, please refer to the [RFC6570](https://tools.ietf.org/html/rfc6570). \ No newline at end of file diff --git a/docs/Tutorials/Routes/Parameters/Queries.md b/docs/Tutorials/Routes/Parameters/Queries.md new file mode 100644 index 000000000..41b839b98 --- /dev/null +++ b/docs/Tutorials/Routes/Parameters/Queries.md @@ -0,0 +1,107 @@ +# Queries + +The following is an example of using data from a request's query string. To retrieve values from the query parameters, you can use the `Query` property on the `$WebEvent` variable in a route's logic. + +Alternatively, you can use the `Get-PodeQueryParameter` function to retrieve the query parameter data, with additional support for deserialization. + +This example will return a user based on the `userId` supplied: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/users' -ScriptBlock { + # get the user + $user = Get-DummyUser -UserId $WebEvent.Query['userId'] + + # return the user + Write-PodeJsonResponse -Value @{ + Username = $user.username + Age = $user.age + } + } +} +``` + +The following request will invoke the above route: + +```powershell +Invoke-WebRequest -Uri 'http://localhost:8080/users?userId=12345' -Method Get +``` + +### Using Get-PodeQueryParameter + +Alternatively, you can use the `Get-PodeQueryParameter` function to retrieve the query data. This function works similarly to the `Query` property on `$WebEvent` but provides additional options for deserialization when needed. + +Here is the same example using `Get-PodeQueryParameter`: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/users' -ScriptBlock { + # get the query data + $userId = Get-PodeQueryParameter -Name 'userId' + + # get the user + $user = Get-DummyUser -UserId $userId + + # return the user + Write-PodeJsonResponse -Value @{ + Username = $user.username + Age = $user.age + } + } +} +``` + +#### Deserialization with Get-PodeQueryParameter + +The `Get-PodeQueryParameter` function can also deserialize query parameters passed in the URL, using specific styles to interpret the data correctly. This feature is particularly useful when handling complex data structures or encoded parameter values. + +To enable deserialization, use the `-Deserialize` switch along with the following options: + +- **`-NoExplode`**: Prevents deserialization from exploding arrays when handling comma-separated values. This is useful when array expansion is not desired. +- **`-Style`**: Defines the deserialization style (`'Simple'`, `'Label'`, `'Matrix'`, `'Form'`, `'SpaceDelimited'`, `'PipeDelimited'`, `'DeepObject'`) to interpret the query parameter value correctly. The default style is `'Form'`. +- **`-KeyName`**: Specifies the key name to use when deserializing, allowing you to map the query parameter data accurately. The default value for `KeyName` is `'id'`. + +#### Supported Deserialization Styles + + +| Style | Explode | URI Template | Primitive Value (id = 5) | Array (id = [3, 4, 5]) | Object (id = {"role": "admin", "firstName": "Alex"}) | +|----------------|---------|--------------|--------------------------|------------------------|------------------------------------------------------| +| form* | true* | /users{?id*} | /users?id=5 | /users?id=3&id=4&id=5 | /users?role=admin&firstName=Alex | +| form | false | /users{?id} | /users?id=5 | /users?id=3,4,5 | /users?id=role,admin,firstName,Alex | +| spaceDelimited | true | /users{?id*} | n/a | /users?id=3&id=4&id=5 | n/a | +| spaceDelimited | false | n/a | n/a | /users?id=3%204%205 | n/a | +| pipeDelimited | true | /users{?id*} | n/a | /users?id=3&id=4&id=5 | n/a | +| pipeDelimited | false | n/a | n/a | /users?id=3\|4\|5 | n/a | +| deepObject | true | n/a | n/a | n/a | /users?id[role]=admin&id[firstName]=Alex | + + +\* Default serialization method + +#### Example with Deserialization + +This example demonstrates deserialization of a query parameter with specific styles and options: + +```powershell +Start-PodeServer { + Add-PodeEndpoint -Address * -Port 8080 -Protocol Http + + Add-PodeRoute -Method Get -Path '/items' -ScriptBlock { + # retrieve and deserialize the 'filter' query parameter + $filter = Get-PodeQueryParameter -Name 'filter' -Deserialize -Style 'SpaceDelimited' -NoExplode + + # get items based on the deserialized filter data + $items = Get-DummyItems -Filter $filter + + # return the item details + Write-PodeJsonResponse -Value $items + } +} +``` + +In this example, the `Get-PodeQueryParameter` function is used to deserialize the `filter` query parameter, interpreting it according to the specified style (`SpaceDelimited`) and preventing array explosion (`-NoExplode`). This approach allows for dynamic and precise handling of complex query data, enhancing the flexibility of your Pode routes. + +For further information regarding serialization, please refer to the [RFC6570](https://tools.ietf.org/html/rfc6570). \ No newline at end of file diff --git a/src/Pode.psd1 b/src/Pode.psd1 index fa158a2ec..3d9cb9286 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -141,6 +141,11 @@ 'ConvertFrom-PodeXml', 'Set-PodeDefaultFolder', 'Get-PodeDefaultFolder', + 'Get-PodeBodyData', + 'Get-PodeQueryParameter', + 'Get-PodePathParameter', + 'ConvertFrom-PodeSerializedString', + 'ConvertTo-PodeSerializedString', 'Get-PodeCurrentRunspaceName', 'Set-PodeCurrentRunspaceName', 'Invoke-PodeGC', diff --git a/src/Public/Cookies.ps1 b/src/Public/Cookies.ps1 index 6bff3e2a0..65245f41d 100644 --- a/src/Public/Cookies.ps1 +++ b/src/Public/Cookies.ps1 @@ -109,69 +109,112 @@ function Set-PodeCookie { <# .SYNOPSIS -Retrieves a cookie from the Request. + Retrieves a specified cookie from the incoming request. .DESCRIPTION -Retrieves a cookie from the Request, with the option to supply a secret to unsign the cookie's value. + The `Get-PodeCookie` function retrieves a cookie from the incoming request. + It can unsign the cookie's value using a specified secret, which can be extended with the client request's UserAgent and RemoteIPAddress if `-Strict` is specified. The function also allows for returning the raw .NET Cookie object for direct manipulation or deserializing serialized cookie values for more complex handling. .PARAMETER Name -The name of the cookie to retrieve. + The name of the cookie to retrieve. This parameter is mandatory. .PARAMETER Secret -The secret used to unsign the cookie's value. + The secret used to unsign the cookie's value, ensuring the integrity and authenticity of the cookie data. + Applicable only in the 'BuiltIn' parameter set. .PARAMETER Strict -If supplied, the Secret will be extended using the client request's UserAgent and RemoteIPAddress. + If specified, the secret is extended using the client's UserAgent and RemoteIPAddress, adding an extra layer of + security when unsigning the cookie. Applicable only in the 'BuiltIn' parameter set. .PARAMETER Raw -If supplied, the cookie returned will be the raw .NET Cookie object for manipulation. + If specified, the cookie returned will be the raw .NET Cookie object, allowing for direct manipulation of + the cookie. This is useful for scenarios where the full cookie object is needed. Applicable only in the 'BuiltIn' parameter set. + +.PARAMETER Deserialize + Indicates that the retrieved cookie value should be deserialized. When this switch is used, the value will be + interpreted based on the deserialization options provided. This parameter is mandatory in the 'Deserialize' parameter set. + +.PARAMETER NoExplode + Prevents deserialization from exploding arrays in the cookie value, which is useful when handling comma-separated + values without expanding them into arrays. Applicable only when the `-Deserialize` switch is used. + +.EXAMPLE + Get-PodeCookie -Name 'Views' + Retrieves the value of the 'Views' cookie from the request. .EXAMPLE -Get-PodeCookie -Name 'Views' + Get-PodeCookie -Name 'Views' -Secret 'hunter2' + Retrieves and unsigns the 'Views' cookie using the specified secret. .EXAMPLE -Get-PodeCookie -Name 'Views' -Secret 'hunter2' + Get-PodeCookie -Name 'Session' -Deserialize -NoExplode + Retrieves and deserializes the 'Session' cookie value without exploding arrays. + +.EXAMPLE + Get-PodeCookie -Name 'AuthToken' -Raw + Retrieves the raw .NET Cookie object for the 'AuthToken' cookie, allowing for direct manipulation. + +.NOTES + This function should be used within a route's script block in a Pode server. The `-Deserialize` switch provides + advanced handling of serialized cookie values, while the `-Secret` and `-Strict` options offer secure methods for + unsigning cookies. #> function Get-PodeCookie { - [CmdletBinding()] - [OutputType([hashtable])] + [CmdletBinding(DefaultParameterSetName = 'BuiltIn' )] param( - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = 'Deserialize')] + [Parameter(Mandatory = $true, ParameterSetName = 'BuiltIn')] [string] $Name, - [Parameter()] + [Parameter(ParameterSetName = 'BuiltIn')] [string] $Secret, + [Parameter(ParameterSetName = 'BuiltIn')] [switch] $Strict, + [Parameter(ParameterSetName = 'BuiltIn')] [switch] - $Raw + $Raw, + + [Parameter(ParameterSetName = 'Deserialize')] + [switch] + $NoExplode, + + [Parameter(Mandatory = $true, ParameterSetName = 'Deserialize')] + [switch] + $Deserialize ) + if ($WebEvent) { + # get the cookie from the request + $cookie = $WebEvent.Cookies[$Name] + if (!$Raw) { + $cookie = (ConvertTo-PodeCookie -Cookie $cookie) + } - # get the cookie from the request - $cookie = $WebEvent.Cookies[$Name] - if (!$Raw) { - $cookie = (ConvertTo-PodeCookie -Cookie $cookie) - } + if (($null -eq $cookie) -or [string]::IsNullOrWhiteSpace($cookie.Value)) { + return $null + } - if (($null -eq $cookie) -or [string]::IsNullOrWhiteSpace($cookie.Value)) { - return $null - } + if ($Deserialize.IsPresent) { + $cookie.Value = ConvertFrom-PodeSerializedString -SerializedString $cookie.Value -Style 'Form' -Explode:(!$NoExplode) + } - # if a secret was supplied, attempt to unsign the cookie - if (![string]::IsNullOrWhiteSpace($Secret)) { - $value = (Invoke-PodeValueUnsign -Value $cookie.Value -Secret $Secret -Strict:$Strict) - if (![string]::IsNullOrWhiteSpace($value)) { - $cookie.Value = $value + # if a secret was supplied, attempt to unsign the cookie + if (![string]::IsNullOrWhiteSpace($Secret)) { + $value = (Invoke-PodeValueUnsign -Value $cookie.Value -Secret $Secret -Strict:$Strict) + if (![string]::IsNullOrWhiteSpace($value)) { + $cookie.Value = $value + } } - } - return $cookie + return $cookie + } } + <# .SYNOPSIS Retrieves the value of a cookie from the Request. diff --git a/src/Public/Headers.ps1 b/src/Public/Headers.ps1 index 84a907b2f..9b8db7173 100644 --- a/src/Public/Headers.ps1 +++ b/src/Public/Headers.ps1 @@ -133,48 +133,88 @@ function Test-PodeHeader { <# .SYNOPSIS -Retrieves the value of a header from the Request. + Retrieves the value of a specified header from the incoming request. .DESCRIPTION -Retrieves the value of a header from the Request. + The `Get-PodeHeader` function retrieves the value of a specified header from the incoming request. + It supports deserialization of header values and can optionally unsign the header using a specified secret. + The unsigning process can be further secured with the client's UserAgent and RemoteIPAddress if `-Strict` is specified. .PARAMETER Name -The name of the header to retrieve. + The name of the header to retrieve. This parameter is mandatory. .PARAMETER Secret -The secret used to unsign the header's value. + The secret used to unsign the header's value. This option is useful when working with signed headers to ensure + the integrity and authenticity of the value. Applicable only in the 'BuiltIn' parameter set. .PARAMETER Strict -If supplied, the Secret will be extended using the client request's UserAgent and RemoteIPAddress. + If specified, the secret is extended using the client's UserAgent and RemoteIPAddress, providing an additional + layer of security during the unsigning process. Applicable only in the 'BuiltIn' parameter set. + +.PARAMETER Deserialize + Indicates that the retrieved header value should be deserialized. When this switch is used, the value will be + interpreted based on the provided deserialization options. This parameter is mandatory in the 'Deserialize' parameter set. + +.PARAMETER Explode + Specifies whether the deserialization process should explode arrays in the header value. This is useful when + handling comma-separated values within the header. Applicable only when the `-Deserialize` switch is used. + +.EXAMPLE + Get-PodeHeader -Name 'X-AuthToken' + Retrieves the value of the 'X-AuthToken' header from the request. .EXAMPLE -Get-PodeHeader -Name 'X-AuthToken' + Get-PodeHeader -Name 'X-SerializedHeader' -Deserialize -Explode + Retrieves and deserializes the value of the 'X-SerializedHeader' header, exploding arrays if present. + +.EXAMPLE + Get-PodeHeader -Name 'X-AuthToken' -Secret 'MySecret' -Strict + Retrieves and unsigns the 'X-AuthToken' header using the specified secret, extending it with UserAgent and + RemoteIPAddress information for added security. + +.NOTES + This function should be used within a route's script block in a Pode server. The `-Deserialize` switch enables + advanced handling of serialized header values, while the `-Secret` and `-Strict` options provide secure unsigning + capabilities for signed headers. #> function Get-PodeHeader { - [CmdletBinding()] - [OutputType([string])] + [CmdletBinding(DefaultParameterSetName = 'BuiltIn' )] param( - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = 'Deserialize')] + [Parameter(Mandatory = $true, ParameterSetName = 'BuiltIn')] [string] $Name, - [Parameter()] + [Parameter(ParameterSetName = 'BuiltIn')] [string] $Secret, + [Parameter(ParameterSetName = 'BuiltIn')] [switch] - $Strict + $Strict, + + [Parameter(ParameterSetName = 'Deserialize')] + [switch] + $Explode, + + [Parameter(Mandatory = $true, ParameterSetName = 'Deserialize')] + [switch] + $Deserialize ) + if ($WebEvent) { + # get the value for the header from the request + $header = $WebEvent.Request.Headers.$Name - # get the value for the header from the request - $header = $WebEvent.Request.Headers.$Name + if ($Deserialize.IsPresent) { + return ConvertFrom-PodeSerializedString -SerializedString $header -Style 'Simple' -Explode:$Explode + } + # if a secret was supplied, attempt to unsign the header's value + if (![string]::IsNullOrWhiteSpace($Secret)) { + $header = (Invoke-PodeValueUnsign -Value $header -Secret $Secret -Strict:$Strict) + } - # if a secret was supplied, attempt to unsign the header's value - if (![string]::IsNullOrWhiteSpace($Secret)) { - $header = (Invoke-PodeValueUnsign -Value $header -Secret $Secret -Strict:$Strict) + return $header } - - return $header } <# diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index 9391e8845..64d21b677 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -1529,13 +1529,13 @@ function ConvertFrom-PodeXml { <# .SYNOPSIS -Invokes the garbage collector. + Invokes the garbage collector. .DESCRIPTION -Invokes the garbage collector. + Invokes the garbage collector. .EXAMPLE -Invoke-PodeGC + Invoke-PodeGC #> function Invoke-PodeGC { [CmdletBinding()] @@ -1620,3 +1620,1326 @@ function Start-PodeSleep { + +<# +.SYNOPSIS + Converts an object (hashtable or array) to a serialized string using a specified serialization style. + +.DESCRIPTION + The `ConvertTo-PodeSerializedString` function takes a hashtable or array and converts it into a serialized string + according to the specified serialization style. It supports various styles such as 'Simple', 'Label', 'Matrix', + 'Form', 'SpaceDelimited', 'PipeDelimited', and 'DeepObject'. + + By default, parameter names and values are URL-encoded to ensure safe inclusion in URLs. You can disable URL encoding + by using the `-NoUrlEncode` switch. + + An optional `-Explode` switch can be used to modify the serialization format for certain styles, altering how arrays + and objects are represented in the serialized string. + +.PARAMETER InputObject + The object to be serialized. This can be a hashtable (or ordered dictionary) or an array. Supports pipeline input. + +.PARAMETER Style + The serialization style to use. Valid values are 'Simple', 'Label', 'Matrix', 'Form', 'SpaceDelimited', + 'PipeDelimited', and 'DeepObject'. Defaults to 'Simple'. + +.PARAMETER Explode + An optional switch to modify the serialization format for certain styles. When used, arrays and objects are + serialized in an expanded form. + +.PARAMETER NoUrlEncode + An optional switch to disable URL encoding of the serialized output. By default, parameter names and values are + URL-encoded individually. Use this switch if you require the output without URL encoding. + +.PARAMETER ParameterName + Specifies the name of the parameter to use in the serialized output. Defaults to 'id' if not specified. + +.EXAMPLE + $item = @{ + name = 'value' + anotherName = 'anotherValue' + } + $serialized = ConvertTo-PodeSerializedString -InputObject $item -Style 'Form' + Write-Output $serialized + + # Output: + # ?id=name%2Cvalue%2CanotherName%2CanotherValue + +.EXAMPLE + $item = @{ + name = 'value' + anotherName = 'anotherValue' + } + $serializedExplode = ConvertTo-PodeSerializedString -InputObject $item -Style 'DeepObject' -Explode + Write-Output $serializedExplode + + # Output: + # ?id[name]=value&id[anotherName]=anotherValue + +.EXAMPLE + $array = @('3', '4', '5') + $serialized = ConvertTo-PodeSerializedString -InputObject $array -Style 'SpaceDelimited' -Explode + Write-Output $serialized + + # Output: + # ?id=3&id=4&id=5 + +.EXAMPLE + $array = @('3', '4', '5') + $serialized = ConvertTo-PodeSerializedString -InputObject $array -Style 'SpaceDelimited' -NoUrlEncode + Write-Output $serialized + + # Output: + # ?id=3 4 5 + +.EXAMPLE + $item = @{ + 'user name' = 'Alice & Bob' + 'role' = 'Admin/User' + } + $serialized = ConvertTo-PodeSerializedString -InputObject $item -Style 'Form' -ParameterName 'account' -NoUrlEncode + Write-Output $serialized + + # Output: + # ?account=user name,Alice & Bob,role,Admin/User + +.NOTES + - 'SpaceDelimited' and 'PipeDelimited' styles for hashtables are not implemented as they are not defined by RFC 6570. + - The 'Form' style with 'Explode' for arrays is not implemented for the same reason. + - The 'Explode' option for 'SpaceDelimited' and 'PipeDelimited' styles for arrays is implemented as per the OpenAPI Specification. + + Additional information regarding serialization: + - OpenAPI Specification Serialization: https://swagger.io/docs/specification/serialization/ + - RFC 6570 - URI Template: https://tools.ietf.org/html/rfc6570 +#> +function ConvertTo-PodeSerializedString { + param ( + [Parameter(Mandatory, ValueFromPipeline = $true, Position = 0)] + [psobject[]] + $InputObject, + + [Parameter()] + [ValidateSet('Simple', 'Label', 'Matrix', 'Form', 'SpaceDelimited', 'PipeDelimited', 'DeepObject')] + [string] + $Style = 'Simple', + + [Parameter()] + [switch] + $Explode, + + [Parameter()] + [switch] + $NoUrlEncode, + + [Parameter()] + [string] + $ParameterName = 'id' # Default parameter name + ) + + begin { + # Initialize an array to collect pipeline input + $pipelineValue = @() + } + + process { + # Collect each input object from the pipeline + $pipelineValue += $_ + } + + end { + # Determine if multiple objects were provided via pipeline + if ($pipelineValue.Count -gt 1) { + $inputObjects = $pipelineValue + } + else { + $inputObjects = $InputObject + } + + # Initialize an array to store the serialized strings + $serializedArray = @() + + # return '' if the inputObjects is null + if($null -eq $inputObjects){ + return '' + } + + # Check if there are input objects to process + if ( $inputObjects.Count -gt 0) { + + # Check if the first input object is a hashtable or ordered dictionary + if ($inputObjects[0] -is [hashtable] -or $inputObjects[0] -is [System.Collections.Specialized.OrderedDictionary]) { + + # Process each hashtable item + foreach ($item in $inputObjects) { + switch ($Style) { + + 'Simple' { + # Handle 'Simple' style for hashtables + if ($Explode) { + # Serialize each key-value pair with '=' and join with ',' + $serializedArray += ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + # URL-encode unless $NoUrlEncode is specified + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$key=$value" + }) -join ',' ) + } + else { + # Serialize each key-value pair with ',' and join with ',' + $serializedArray += ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$key,$value" + }) -join ',' ) + } + break + } + + 'Label' { + # Handle 'Label' style for hashtables + if ($Explode) { + # Prepend '.' and serialize each key-value pair with '=' + $serializedArray += '.' + ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$key=$value" + }) -join ',' ) + } + else { + # Prepend '.' and serialize each key-value pair with ',' + $serializedArray += '.' + ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$key,$value" + }) -join ',' ) + } + break + } + + 'Matrix' { + # Handle 'Matrix' style for hashtables + if ($Explode) { + # Serialize each key-value pair with ';' prefix + $serializedArray += ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + ";$key=$value" + }) -join '' ) + } + else { + # Serialize key-value pairs into a single parameter + $valueString = ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$key,$value" + }) -join ',' ) + # Encode parameter name if necessary + if (-not $NoUrlEncode) { + $parameterName = [uri]::EscapeDataString($ParameterName) + } + else { + $parameterName = $ParameterName + } + $serializedArray += ";$parameterName=$valueString" + } + break + } + + 'Form' { + # Handle 'Form' style for hashtables + if ($Explode) { + # Serialize each key-value pair as query parameters + $serializedArray += '?' + ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$key=$value" + }) -join '&' ) + } + else { + # Serialize key-value pairs into a single query parameter + $valueString = ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$key,$value" + }) -join ',' ) + if (-not $NoUrlEncode) { + $parameterName = [uri]::EscapeDataString($ParameterName) + } + else { + $parameterName = $ParameterName + } + $serializedArray += "?$parameterName=$valueString" + } + break + } + + 'DeepObject' { + # Handle 'DeepObject' style for hashtables + # Encode parameter name once outside the loop + if (-not $NoUrlEncode) { + $parameterNameEncoded = [uri]::EscapeDataString($ParameterName) + } + else { + $parameterNameEncoded = $ParameterName + } + # Serialize each key-value pair using bracket notation + $serializedArray += '?' + ( ($item.Keys | ForEach-Object { + $key = $_ + $value = $item[$_] + if (-not $NoUrlEncode) { + $key = [uri]::EscapeDataString($key) + $value = [uri]::EscapeDataString($value) + } + "$parameterNameEncoded`[$key`]=$value" + }) -join '&' ) + break + } + + # Styles not defined for hashtables + 'SpaceDelimited' { + $serializedArray += '' + Write-Verbose "Serialization for objects using '$Style' style is not defined by RFC 6570." + } + + 'PipeDelimited' { + $serializedArray += '' + Write-Verbose "Serialization for objects using '$Style' style is not defined by RFC 6570." + } + } + } + } + else { + # Process input as an array + switch ($Style) { + + 'Simple' { + # Handle 'Simple' style for arrays + # Both 'Explode' and non-'Explode' result in the same output + $serializedArray += ( ($inputObjects | ForEach-Object { + $value = $_ + if (-not $NoUrlEncode) { + $value = [uri]::EscapeDataString($value) + } + $value + }) -join ',' ) + break + } + + 'Label' { + # Handle 'Label' style for arrays + $serializedArray += '.' + ( ($inputObjects | ForEach-Object { + $value = $_ + if (-not $NoUrlEncode) { + $value = [uri]::EscapeDataString($value) + } + $value + }) -join ',' ) + break + } + + 'Matrix' { + # Handle 'Matrix' style for arrays + if (-not $NoUrlEncode) { + $parameterName = [uri]::EscapeDataString($ParameterName) + } + else { + $parameterName = $ParameterName + } + if ($Explode) { + # Serialize each value with parameter name + $serializedArray += ';' + ( ($inputObjects | ForEach-Object { + $value = $_ + if (-not $NoUrlEncode) { + $value = [uri]::EscapeDataString($value) + } + "$parameterName=$value" + }) -join ';' ) + } + else { + # Serialize values into a single parameter + $valueString = ( ($inputObjects | ForEach-Object { + $value = $_ + if (-not $NoUrlEncode) { + $value = [uri]::EscapeDataString($value) + } + $value + }) -join ',' ) + $serializedArray += ";$parameterName=$valueString" + } + break + } + + 'SpaceDelimited' { + # Handle 'SpaceDelimited' style for arrays + if (-not $NoUrlEncode) { + $parameterName = [uri]::EscapeDataString($ParameterName) + } + else { + $parameterName = $ParameterName + } + if ($Explode) { + # Serialize each value as a separate parameter + $valueStrings = $inputObjects | ForEach-Object { + $value = $_ + if (-not $NoUrlEncode) { + $value = [uri]::EscapeDataString($value) + } + "$parameterName=$value" + } + $serializedArray += '?' + ($valueStrings -join '&') + } + else { + # Join values with a space + $valueString = ($inputObjects -join ' ') + if (-not $NoUrlEncode) { + $valueString = [uri]::EscapeDataString($valueString) + } + $serializedArray += "?$parameterName=$valueString" + } + break + } + + 'PipeDelimited' { + # Handle 'PipeDelimited' style for arrays + if (-not $NoUrlEncode) { + $parameterName = [uri]::EscapeDataString($ParameterName) + } + else { + $parameterName = $ParameterName + } + if ($Explode) { + # Serialize each value as a separate parameter + $valueStrings = $inputObjects | ForEach-Object { + $value = $_ + if (-not $NoUrlEncode) { + $value = [uri]::EscapeDataString($value) + } + "$parameterName=$value" + } + $serializedArray += '?' + ($valueStrings -join '&') + } + else { + # Join values with a pipe '|' + $valueString = ($inputObjects -join '|') + if (-not $NoUrlEncode) { + $valueString = [uri]::EscapeDataString($valueString) + } + $serializedArray += "?$parameterName=$valueString" + } + break + } + + 'Form' { + # Handle 'Form' style for arrays + if (-not $NoUrlEncode) { + $parameterName = [uri]::EscapeDataString($ParameterName) + } + else { + $parameterName = $ParameterName + } + if ($Explode) { + # 'Explode' is not defined for arrays in 'Form' style + $serializedArray += '' + Write-Verbose "Serialization for array using '$Style' style with 'Explode' is not defined by RFC 6570." + } + else { + # Serialize values into a single parameter + $valueString = ( ($inputObjects | ForEach-Object { + $value = $_ + if (-not $NoUrlEncode) { + $value = [uri]::EscapeDataString($value) + } + $value + }) -join ',' ) + $serializedArray += "$parameterName=$valueString" + } + break + } + + # 'DeepObject' is not defined for arrays + 'DeepObject' { + $serializedArray += '' + Write-Verbose "Serialization for arrays using '$Style' style is not defined by RFC 6570." + } + } + } + } + + # Return the serialized string(s) + return $serializedArray + } +} + + +<# +.SYNOPSIS + Converts a serialized string back into its original data structure based on the specified serialization style. + +.DESCRIPTION + The `ConvertFrom-PodeSerializedString` function takes a serialized string and converts it back into its original data structure (e.g., hashtable, array). + The function requires the serialization style to be specified via the `-Style` parameter. + Supported styles are 'Simple', 'Label', 'Matrix', 'Query', 'Form', 'SpaceDelimited', 'PipeDelimited', and 'DeepObject'. + The function also accepts an optional `-Explode` switch to indicate whether the string uses exploded serialization. + The `-ParameterName` parameter can be used to specify the key name when processing certain styles, such as 'Matrix' and 'DeepObject'. + +.PARAMETER SerializedInput + The serialized string to be converted back into its original data structure. + +.PARAMETER Style + The serialization style to use for deserialization. Options are 'Simple', 'Label', 'Matrix', 'Query', 'Form', 'SpaceDelimited', 'PipeDelimited', and 'DeepObject'. The default is 'Form'. + +.PARAMETER Explode + Indicates whether the string uses exploded serialization (`-Explode`) or not (omit `-Explode`). This affects how arrays and objects are handled. + +.PARAMETER ParameterName + Specifies the key name to match when processing certain styles, such as 'Matrix' and 'DeepObject'. The default is 'id'. + +.PARAMETER UrlDecode + If specified, the function will decode the input string using URL decoding before processing it. This is useful + for handling serialized inputs that include URL-encoded characters, such as `%20` for spaces. + +.EXAMPLE + # Simple style, explode = true + $serialized = "name=value,anotherName=anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Simple' -Explode + Write-Output $result + +.EXAMPLE + # Simple style, explode = false + $serialized = "name,value,anotherName,anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Simple' + Write-Output $result + +.EXAMPLE + # Label style, explode = true + $serialized = ".name=value.anotherName=anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Label' -Explode + Write-Output $result + +.EXAMPLE + # Label style, explode = false + $serialized = ".name,value,anotherName,anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Label' + Write-Output $result + +.EXAMPLE + # Matrix style, explode = true + $serialized = ";name=value;anotherName=anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Matrix' -Explode + Write-Output $result + +.EXAMPLE + # Matrix style, explode = false + $serialized = ";id=3,4,5" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Matrix' -ParameterName 'id' + Write-Output $result + +.EXAMPLE + # Query style, explode = true + $serialized = "?name=value&anotherName=anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Query' -Explode + Write-Output $result + +.EXAMPLE + # Query style, explode = false + $serialized = "?name,value,anotherName,anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Query' + Write-Output $result + +.EXAMPLE + # Form style, explode = true + $serialized = "?name=value&anotherName=anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Form' -Explode + Write-Output $result + +.EXAMPLE + # Form style, explode = false + $serialized = "?name,value,anotherName,anotherValue" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'Form' + Write-Output $result + +.EXAMPLE + # SpaceDelimited style, explode = true + $serialized = "?id=3&id=4&id=5" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'SpaceDelimited' -Explode -ParameterName 'id' + Write-Output $result + +.EXAMPLE + # SpaceDelimited style, explode = false + $serialized = "?id=3%204%205" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'SpaceDelimited' -ParameterName 'id' + Write-Output $result + +.EXAMPLE + # PipeDelimited style, explode = true + $serialized = "?id=3&id=4&id=5" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'PipeDelimited' -Explode -ParameterName 'id' + Write-Output $result + +.EXAMPLE + # PipeDelimited style, explode = false + $serialized = "?id=3|4|5" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'PipeDelimited' -ParameterName 'id' + Write-Output $result + +.EXAMPLE + # DeepObject style + $serialized = "myId[role]=admin&myId[firstName]=Alex" + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'DeepObject' -ParameterName 'myId' + Write-Output $result + +.NOTES + For more information on serialization styles, refer to: + - https://swagger.io/docs/specification/serialization/ + - https://tools.ietf.org/html/rfc6570 +#> + +function ConvertFrom-PodeSerializedString { + param ( + [Parameter(Mandatory, ValueFromPipeline = $true, Position = 0)] + [string] $SerializedInput, + + [Parameter()] + [ValidateSet('Simple', 'Label', 'Matrix', 'Query', 'Form', 'SpaceDelimited', 'PipeDelimited', 'DeepObject' )] + [string] + $Style = 'Form', + + [Parameter()] + [switch] + $Explode, + + [Parameter()] + [string] + $ParameterName = 'id', # Default key name if not specified + + [Parameter()] + [switch] + $UrlDecode + ) + + process { + if($UrlDecode){ + $SerializedInput = [System.Web.HttpUtility]::UrlDecode($SerializedInput) + } + # Main deserialization logic based on style + switch ($Style) { + 'Simple' { + # Check for header pattern and extract it if present + if ($SerializedInput -match '^([a-zA-Z0-9_-]+):') { + # Extract the variable name and strip it from the serialized string + $headerName = $matches[1] + $SerializedInput = ($SerializedInput -replace "^$($headerName):", '').Trim() + } + + $segments = $SerializedInput -split ',' + + # If there's only one segment, return it directly + if ($segments.Count -eq 1) { + $result = $segments[0] + } + else { + if ($Explode) { + # Handling explode=true case + + # Check if the number of '=' is equal to the count of segments + if ((($SerializedInput -split '=').Count - 1) -eq $segments.Count) { + $obj = @{} + foreach ($pair in $segments) { + if ($pair.Contains('=')) { + $key, $value = $pair -split '=', 2 # Split into exactly two parts + $obj[$key] = $value + } + } + $result = $obj + } + else { + # Return as an array if the explode conditions don't match + $result = $segments + } + } + else { + # Handling explode=false case + + # Check if it's likely an object by checking if the count of segments is even + if ($segments.Count % 2 -eq 0) { + # Try to parse as an object + $obj = @{} + for ($i = 0; $i -lt $segments.Count; $i += 2) { + $key = $segments[$i] + # Validate the key format + if ($key -match '^[a-zA-Z_][a-zA-Z0-9_]*$') { + $obj[$key] = $segments[$i + 1] + } + else { + # If the key is invalid, return the original segments as an array + $result = $segments + } + } + # Return the object if all keys are valid + $result = $obj + } + else { + # If not an object, treat it as an array + $result = $segments + } + } + } + + if ($headerName) { + return @{$headerName = $result } + } + else { + return $result + } + + } + 'Label' { + # Remove the leading dot (.) prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart('.') + + # Split the string by dot + $segments = $SerializedInput -split '\.' + + # Handle the explode=true case + if ($Explode) { + # Handling explode=true: each segment is a key=value pair + $obj = @{} + foreach ($segment in $segments) { + if ($segment.Contains('=')) { + $key, $value = $segment -split '=', 2 # Split into exactly two parts + $obj[$key] = $value + } + else { + # If a segment does not contain '=', treat it as an array element + return $segments -split ',' + } + } + return $obj + } + else { + # Handling explode=false: all segments form a combined structure + # Split the string by commas within each segment + $combinedSegments = ($SerializedInput -split ',') + + # Check if it's likely an object by checking if the count is even + if ($combinedSegments.Count % 2 -eq 0) { + # Try to parse as an object + $obj = @{} + for ($i = 0; $i -lt $combinedSegments.Count; $i += 2) { + $key = $combinedSegments[$i] + + # Validate if the key is a suitable key + if ($key -match '^[a-zA-Z_][a-zA-Z0-9_]*$') { + $value = $combinedSegments[$i + 1] + $obj[$key] = $value + } + else { + # If validation fails, return segments as array + return $combinedSegments + } + } + return $obj + } + + # If not an object, return as an array + return $combinedSegments + } + } + 'Matrix' { + # Handle the explode=true case + if ($Explode) { + # Remove the leading semicolon (;) prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart(';') + + # Split by semicolon to get segments + $segments = $SerializedInput -split ';' + + # If each segment doesn't contain '=', treat it as an array + if ($segments -notmatch '=') { + # Return as an array of individual elements split by commas + return $segments -split ',' + } + + # Initialize an empty hashtable to store key-value pairs + $obj = @{} + $values = @() + + + foreach ($segment in $segments) { + if ($segment.Contains('=')) { + $key, $value = $segment -split '=', 2 + + # If the key matches the specified key name + if ($key -eq $ParameterName) { + $values += $value + } + else { + # If a key doesn't match, treat as a normal key-value pair in the hashtable + $obj[$key] = $value + } + } + } + + # If all segments matched the specified key name, return the values as an array + if ($values.Count -eq $segments.Count) { + if ($values.Count -eq 1) { + return $values[0] + } + return $values + } + + # Merge values back into the object if any key matches the KeyName + if ($values.Count -gt 0) { + $obj[$ParameterName] = if ($values.Count -eq 1) { $values[0] } else { $values } + } + + # Return the hashtable if it contains any key-value pairs + if ($obj.Count -gt 0) { + return $obj + } + else { + return $values + } + } + else { + # Handling explode=false: + + # Remove the leading semicolon (;) prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart(";$ParameterName=") + + # Split by semicolon to get segments + $segments = $SerializedInput -split ',' + + # If there's only one segment, return it directly + if ($segments.Count -eq 1) { + return $segments[0] + } + + # Check if it's likely an object by checking if the count of segments is even + if ($segments.Count % 2 -eq 0) { + # Try to parse as an object + $obj = @{} + for ($i = 0; $i -lt $segments.Count; $i += 2) { + $key = $segments[$i] + # Validate the key format + if ($key -match '^[a-zA-Z_][a-zA-Z0-9_]*$') { + $obj[$key] = $segments[$i + 1] + } + else { + # If the key is invalid, return the original segments as an array + return $segments + } + } + # Return the object if all keys are valid + return $obj + } + + # If not an object, treat it as an array + return $segments + } + } + + 'Form' { + # Check for header pattern and extract it if present + if ($SerializedInput -match '^([a-zA-Z0-9_-]+):') { + # Extract the variable name and strip it from the serialized string + $headerName = $matches[1] + $SerializedInput = ($SerializedInput -replace "^$($headerName):", '').Trim().TrimStart("$ParameterName=") + } + else { + if ($Explode) { + # Remove the leading semicolon (;) prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart('?') + } + else { + # Remove the leading semicolon (;) prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart("?$ParameterName=") + } + } + + # Handle the explode=true case + if ($Explode) { + # Split by semicolon to get segments + $segments = $SerializedInput -split '&' + + # If each segment doesn't contain '=', treat it as an array + if ($segments -notmatch '=') { + # Return as an array of individual elements split by commas + $result = $segments -split ',' + } + else { + # Initialize an empty hashtable to store key-value pairs + $obj = @{} + $values = @() + + foreach ($segment in $segments) { + if ($segment.Contains('=')) { + $key, $value = $segment -split '=', 2 + + # If the key matches the specified key name + if ($key -eq $ParameterName) { + $values += $value + } + else { + # If a key doesn't match, treat as a normal key-value pair in the hashtable + $obj[$key] = $value + } + } + } + + # If all segments matched the specified key name, return the values as an array + if ($values.Count -eq $segments.Count) { + if ($values.Count -eq 1) { + $result = $values[0] + } + else { + $result = $values + } + } + else { + + # Merge values back into the object if any key matches the KeyName + if ($values.Count -gt 0) { + $obj[$ParameterName] = if ($values.Count -eq 1) { $values[0] } else { $values } + } + + # Return the hashtable if it contains any key-value pairs + if ($obj.Count -gt 0) { + return $obj + } + else { + return $values + } + } + } + } + else { + # Handling explode=false + + # Split by semicolon to get segments + $segments = $SerializedInput -split ',' + + # If there's only one segment, return it directly + if ($segments.Count -eq 1) { + $result = $segments[0] + } + # Check if it's likely an object by checking if the count of segments is even + elseif ($segments.Count % 2 -eq 0) { + # Try to parse as an object + $obj = @{} + for ($i = 0; $i -lt $segments.Count; $i += 2) { + $key = $segments[$i] + # Validate the key format + if ($key -match '^[a-zA-Z_][a-zA-Z0-9_]*$') { + $obj[$key] = $segments[$i + 1] + } + else { + # If the key is invalid, return the original segments as an array + $result = $segments + break + } + } + if (!$result) { + # Return the object if all keys are valid + $result = $obj + } + } + else { + # If not an object, treat it as an array + $result = $segments + } + + } + + if ($headerName) { + return @{$headerName = $result } + } + else { + return $result + } + } + + 'SpaceDelimited' { + if ($Explode) { + # Remove the leading semicolon (;) prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart('?') + + # For explode=true, split by '&' to treat each value as a separate occurrence + $segments = $SerializedInput -split '&' + + # Initialize an array to store values that match the specified KeyName + $values = @() + foreach ($segment in $segments) { + if ($segment.Contains('=')) { + $key, $value = $segment -split '=', 2 + # Only add values where the key matches the specified KeyName + if ($key -eq $ParameterName) { + $values += $value + } + } + } + # Return the array of values that matched the KeyName + return $values + } + else { + # Remove the leading semicolon '?id=' prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart('?id=') + # For explode=false, split by space (%20) to handle the combined string format + return $SerializedInput -split ' ' + } + } + + 'PipeDelimited' { + if ($Explode) { + $SerializedInput = $SerializedInput.TrimStart('?') + # For explode=true, split by '&' to treat each value as a separate occurrence + $segments = $SerializedInput -split '&' + + # Initialize an array to store values that match the specified KeyName + $values = @() + foreach ($segment in $segments) { + if ($segment.Contains('=')) { + $key, $value = $segment -split '=', 2 + # Only add values where the key matches the specified KeyName + if ($key -eq $ParameterName) { + $values += $value + } + } + } + # Return the array of values that matched the KeyName + return $values + } + else { + # Remove the leading '?id=' prefix from the serialized string + $SerializedInput = $SerializedInput.TrimStart('?id=') + # For explode=false, split by | to handle the combined string format + return $SerializedInput -split '\|' + } + } + + 'DeepObject' { + $SerializedInput = $SerializedInput.TrimStart('?') + + # Split the string by '&' to get each key-value pair + $segments = $SerializedInput -split '&' + + # Initialize an empty hashtable to store the nested key-value pairs + $obj = @{} + foreach ($segment in $segments) { + if ($segment.Contains('=')) { + # Split each segment by '=' into key and value + $key, $value = $segment -split '=', 2 + + # Extract the main key and nested keys using regex + $allMatches = [regex]::Matches($key, '([^\[\]]+)') + + # Extract the main key (first match) and remaining nested keys + $mainKey = $allMatches[0].Groups[1].Value + # Manually extract remaining nested keys as a list of strings + $nestedKeys = @() + for ($i = 1; $i -lt $allMatches.Count; $i++) { + $nestedKeys += $allMatches[$i].Groups[1].Value + } + + # Only process the segment if the main key matches the specified KeyName + if ($mainKey -eq $ParameterName) { + # Initialize a reference to the root object + $current = $obj + + # Iterate over the nested keys to build the structure + foreach ($nestedKey in $nestedKeys) { + # If this is the last key, assign the value + if ($nestedKey -eq $nestedKeys[-1]) { + $current[$nestedKey] = $value + } + else { + # Create a new hashtable if the nested key doesn't exist + if (-not $current.ContainsKey($nestedKey)) { + $current[$nestedKey] = @{} + } + # Move deeper into the nested structure + $current = $current[$nestedKey] + } + } + } + } + } + + # Return the constructed hashtable with nested keys and values + return $obj + } + + } + } +} + +<# +.SYNOPSIS + Retrieves a specific parameter value from the current Pode web event. + +.DESCRIPTION + The `Get-PodePathParameter` function extracts and returns the value of a specified parameter + from the current Pode web event. This function can access parameters passed in the URL path, query string, + or body of a web request, making it useful in web applications to dynamically handle incoming data. + + The function supports deserialization of parameter values when the `-Deserialize` switch is used. + This allows for interpreting serialized data structures, like arrays or complex objects, from the web request. + +.PARAMETER Name + The name of the parameter to retrieve. This parameter is mandatory. + +.PARAMETER Deserialize + Specifies that the parameter value should be deserialized. When this switch is used, the value will be interpreted + based on the provided style and other deserialization options. + +.PARAMETER Explode + Specifies whether to explode arrays when deserializing the parameter value. This is useful when parameters contain + comma-separated values. Applicable only when the `-Deserialize` switch is used. + +.PARAMETER Style + Defines the deserialization style to use when interpreting the parameter value. Valid options are 'Simple', 'Label', + and 'Matrix'. The default is 'Simple'. Applicable only when the `-Deserialize` switch is used. + +.PARAMETER ParameterName + Specifies the key name to use when deserializing the parameter value. The default value is 'id'. + This option is useful for mapping the parameter data accurately during deserialization. Applicable only + when the `-Deserialize` switch is used. + +.EXAMPLE + Get-PodePathParameter -Name 'action' + Returns the value of the 'action' parameter from the current web event. + +.EXAMPLE + Get-PodePathParameter -Name 'item' -Deserialize -Style 'Label' -Explode + Retrieves and deserializes the value of the 'item' parameter using the 'Label' style and exploding arrays. + +.EXAMPLE + Get-PodePathParameter -Name 'id' -Deserialize -KeyName 'userId' + Deserializes the 'id' parameter using the key name 'userId'. + +.NOTES + This function should be used within a route's script block in a Pode server. + The `-Deserialize` switch enables more advanced handling of complex data structures. +#> +function Get-PodePathParameter { + [CmdletBinding(DefaultParameterSetName = 'BuiltIn' )] + param( + [Parameter(Mandatory, ParameterSetName = 'Deserialize')] + [Parameter(Mandatory, ParameterSetName = 'BuiltIn')] + [string] + $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Deserialize')] + [switch] + $Deserialize, + + [Parameter(ParameterSetName = 'Deserialize')] + [switch] + $Explode, + + [Parameter(ParameterSetName = 'Deserialize')] + [ValidateSet('Simple', 'Label', 'Matrix')] + [string] + $Style = 'Simple', + + [Parameter(ParameterSetName = 'Deserialize')] + [string] + $ParameterName = 'id' + + ) + if ($WebEvent) { + if ($Deserialize.IsPresent) { + return ConvertFrom-PodeSerializedString -SerializedInput $WebEvent.Parameters[$Name] -Style $Style -Explode:$Explode -ParameterName $ParameterName + } + return $WebEvent.Parameters[$Name] + } +} + +<# +.SYNOPSIS + Retrieves the body data from the current Pode web event. + +.DESCRIPTION + The `Get-PodeBodyData` function extracts and returns the body data of the current Pode web event. + This function is designed to access the main content sent in web requests, including methods such as PUT, POST, or any other HTTP methods that support a request body. + It also supports deserialization of the body data, allowing for the interpretation of serialized content. + +.PARAMETER Deserialize + Specifies that the body data should be deserialized. When this switch is used, the body data will be interpreted + based on the provided style and other deserialization options. + +.PARAMETER NoExplode + Prevents deserialization from exploding arrays in the body data. This is useful when handling parameters that + contain comma-separated values and when array expansion is not desired. Applicable only when the `-Deserialize` + switch is used. + +.PARAMETER Style + Defines the deserialization style to use when interpreting the body data. Valid options are 'Simple', 'Label', + 'Matrix', 'Form', 'SpaceDelimited', 'PipeDelimited', and 'DeepObject'. The default is 'Form'. Applicable only + when the `-Deserialize` switch is used. + +.PARAMETER ParameterName + Specifies the key name to use when deserializing the body data. The default value is 'id'. This option is useful + for mapping the body data accurately during deserialization. Applicable only when the `-Deserialize` switch is used. + +.EXAMPLE + Get-PodeBodyData + Returns the body data of the current web event. + +.EXAMPLE + Get-PodeBodyData -Deserialize -Style 'Matrix' + Retrieves and deserializes the body data using the 'Matrix' style. + +.EXAMPLE + Get-PodeBodyData -Deserialize -NoExplode + Deserializes the body data without exploding arrays. + +.NOTES + This function should be used within a route's script block in a Pode server. The `-Deserialize` switch enables + advanced handling of complex body data structures. +#> +function Get-PodeBodyData { + [CmdletBinding(DefaultParameterSetName = 'BuiltIn' )] + param( + [Parameter(Mandatory = $true, ParameterSetName = 'Deserialize')] + [switch] + $Deserialize, + + [Parameter(ParameterSetName = 'Deserialize')] + [switch] + $NoExplode, + + [Parameter(ParameterSetName = 'Deserialize')] + [ValidateSet('Simple', 'Label', 'Matrix', 'Form', 'SpaceDelimited', 'PipeDelimited', 'DeepObject')] + [string] + $Style = 'Form', + + [Parameter(ParameterSetName = 'Deserialize')] + [string] + $ParameterName = 'id' + ) + if ($WebEvent) { + if ($Deserialize.IsPresent) { + return ConvertFrom-PodeSerializedString -SerializedInput $WebEvent.Data -Style $Style -Explode:(!$NoExplode) -ParameterName $ParameterName + } + return $WebEvent.Data + } +} + + + +<# +.SYNOPSIS + Retrieves a specific query parameter value from the current Pode web event. + +.DESCRIPTION + The `Get-PodeQueryParameter` function extracts and returns the value of a specified query parameter + from the current Pode web event. This function is designed to access query parameters passed in the URL of a web request, + enabling the handling of incoming data in web applications. + + The function supports deserialization of query parameter values when the `-Deserialize` switch is used, + allowing for interpretation of complex data structures from the query string. + +.PARAMETER Name + The name of the query parameter to retrieve. This parameter is mandatory. + +.PARAMETER Deserialize + Specifies that the query parameter value should be deserialized. When this switch is used, the value will be + interpreted based on the provided style and other deserialization options. + +.PARAMETER NoExplode + Prevents deserialization from exploding arrays in the query parameter value. This is useful when handling + parameters that contain comma-separated values and when array expansion is not desired. Applicable only when + the `-Deserialize` switch is used. + +.PARAMETER Style + Defines the deserialization style to use when interpreting the query parameter value. Valid options are 'Simple', + 'Label', 'Matrix', 'Form', 'SpaceDelimited', 'PipeDelimited', and 'DeepObject'. The default is 'Form'. + Applicable only when the `-Deserialize` switch is used. + +.PARAMETER ParameterName + Specifies the key name to use when deserializing the query parameter value. The default value is 'id'. + This option is useful for mapping the query parameter data accurately during deserialization. Applicable only + when the `-Deserialize` switch is used. + +.EXAMPLE + Get-PodeQueryParameter -Name 'userId' + Returns the value of the 'userId' query parameter from the current web event. + +.EXAMPLE + Get-PodeQueryParameter -Name 'filter' -Deserialize -Style 'SpaceDelimited' + Retrieves and deserializes the value of the 'filter' query parameter, using the 'SpaceDelimited' style. + +.EXAMPLE + Get-PodeQueryParameter -Name 'data' -Deserialize -NoExplode + Deserializes the 'data' query parameter value without exploding arrays. + +.NOTES + This function should be used within a route's script block in a Pode server. The `-Deserialize` switch enables + advanced handling of complex query parameter data structures. +#> +function Get-PodeQueryParameter { + [CmdletBinding(DefaultParameterSetName = 'BuiltIn' )] + param( + [Parameter(Mandatory, ParameterSetName = 'Deserialize')] + [Parameter(Mandatory, ParameterSetName = 'BuiltIn')] + [string] + $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Deserialize')] + [switch] + $Deserialize, + + [Parameter(ParameterSetName = 'Deserialize')] + [switch] + $NoExplode, + + [Parameter(ParameterSetName = 'Deserialize')] + [ValidateSet('Simple', 'Label', 'Matrix', 'Form', 'SpaceDelimited', 'PipeDelimited', 'DeepObject' )] + [string] + $Style = 'Form', + + [Parameter(ParameterSetName = 'Deserialize')] + [string] + $ParameterName = 'id' + ) + if ($WebEvent) { + if ($Deserialize.IsPresent) { + return ConvertFrom-PodeSerializedString -SerializedInput $WebEvent.Query[$Name] -Style $Style -Explode:(!$NoExplode) -ParameterName $ParameterName + } + return $WebEvent.Query[$Name] + } +} + diff --git a/tests/unit/Utility.Tests.ps1 b/tests/unit/Utility.Tests.ps1 new file mode 100644 index 000000000..2c9f5d0db --- /dev/null +++ b/tests/unit/Utility.Tests.ps1 @@ -0,0 +1,956 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() + +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' + + $PodeContext = @{ 'Server' = $null; } +} + +Describe 'ConvertFrom-PodeSerializedString' { + + Describe 'Path Parameters' { + It 'Convert Simple(Explode) style serialized string to a primitive value' { + $serialized = '5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode + $result | Should -be '5' + } + + It 'Convert Simple(Explode) style serialized string to hashtable' { + $serialized = 'role=admin,firstName=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Simple(Explode) style serialized string to array' { + $serialized = '3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode + $result | Should -be @('3', '4', '5') + } + + It 'Convert Simple style serialized string to a primitive value' { + $serialized = '5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple + $result | Should -be '5' + } + + It 'Convert Simple style serialized string to hashtable' { + $serialized = 'role,admin,firstName,Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Simple style serialized string to array' { + $serialized = '3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple + $result | Should -be @('3', '4', '5') + } + + + It 'Convert Label(Explode) style serialized string to a primitive value' { + $serialized = '.5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Label -Explode + $result | Should -be 5 + } + + It 'Convert Label(Explode) style serialized string to hashtable' { + $serialized = '.role=admin.firstName=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Label -Explode + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Label(Explode) style serialized string to array' { + $serialized = '.3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Label -Explode + $result | Should -be @('3', '4', '5') + } + + It 'Convert Simple style serialized string to a primitive value' { + $serialized = '.5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Label + $result | Should -be 5 + } + + It 'Convert Label style serialized string to hashtable' { + $serialized = '.role,admin,firstName,Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Label + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Label style serialized string to array' { + $serialized = '.3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Label + $result | Should -be @('3', '4', '5') + } + + + + It 'Convert Matrix(Explode) style serialized string to a primitive value' { + $serialized = ';id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix -Explode + $result | Should -be 5 + } + + It 'Convert Matrix(Explode) style serialized string to hashtable' { + $serialized = ';role=admin;firstName=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix -Explode + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Matrix(Explode) style serialized string to array' { + $serialized = ';id=3;id=4;id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix -Explode + $result | Should -be @('3', '4', '5') + } + + It 'Convert Simple style serialized string to a primitive value' { + $serialized = ';id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix + $result | Should -be 5 + } + + It 'Convert Matrix style serialized string to hashtable' { + $serialized = ';id=role,admin,firstName,Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Matrix style serialized string to array' { + $serialized = ';id=3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix + $result | Should -be @('3', '4', '5') + } + + + It 'Convert Matrix(Explode) style serialized string to a primitive value' { + $serialized = ';id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix -Explode + $result | Should -be 5 + } + + It 'Convert Matrix(Explode) style serialized string to hashtable' { + $serialized = ';role=admin;firstName=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix -Explode + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Matrix(Explode) style serialized string to array' { + $serialized = ';id=3;id=4;id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix -Explode + $result | Should -be @('3', '4', '5') + } + + It 'Convert Matrix style serialized string to a primitive value' { + $serialized = ';id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix + $result | Should -be 5 + } + + It 'Convert Matrix style serialized string to hashtable' { + $serialized = ';id=role,admin,firstName,Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Matrix style serialized string to array' { + $serialized = ';id=3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix + $result | Should -be @('3', '4', '5') + } + } + + Describe 'Query Parameters' { + It 'Convert Form(Explode) style serialized string to a primitive value' { + $serialized = '?id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form -Explode + $result | Should -be 5 + } + + It 'Convert Form(Explode) style serialized string to hashtable' { + $serialized = '?role=admin&firstName=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form -Explode + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Form(Explode) style serialized string to array' { + $serialized = '?id=3&id=4&id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form -Explode + $result | Should -be @('3', '4', '5') + } + + It 'Convert Form style serialized string to a primitive value' { + $serialized = '?id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form + $result | Should -be 5 + } + + It 'Convert Form style serialized string to hashtable' { + $serialized = '?id=role,admin,firstName,Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert Form style serialized string to array' { + $serialized = '?id=3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form + $result | Should -be @('3', '4', '5') + } + + + It 'Convert SpaceDelimited(Explode) style serialized string to array' { + $serialized = '?id=3&id=4&id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style SpaceDelimited -Explode + $result | Should -be @('3', '4', '5') + } + + It 'Convert SpaceDelimited style serialized string to array' { + $serialized = '?id=3 4 5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style SpaceDelimited + $result | Should -be @('3', '4', '5') + } + + + It 'Convert pipeDelimited(Explode) style serialized string to array' { + $serialized = '?id=3&id=4&id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style pipeDelimited -Explode + $result | Should -be @('3', '4', '5') + } + + It 'Convert pipeDelimited style serialized string to array' { + $serialized = '?id=3|4|5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style pipeDelimited + $result | Should -be @('3', '4', '5') + } + + It 'Convert DeepObject(Explode) style serialized string to hashtable' { + $serialized = '?id[role]=admin&id[firstName]=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style DeepObject + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + # Ensure both hashtables have the same number of keys + $result.Keys.Count | Should -Be $expected.Keys.Count + + # Compare values for each key + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Convert DeepObject(Explode) style nested object serialized to hashtable' { + $serialized = '?id[role][type]=admin&id[role][level]=high&id[firstName]=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style DeepObject + $expected = @{ + role = @{ + type = 'admin' + level = 'high' + } + firstName = 'Alex' + } + $result['role'].GetEnumerator() | ForEach-Object { + $expected['role'][$_.Key] | Should -Be $_.Value + } + $result['firstName'] | Should -Be $expected['firstName'] + } + + } + + + Describe 'Header Parameters' { + It 'Convert Simple(Explode) style serialized string to a primitive value' { + $serialized = 'X-MyHeader: 5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode + $result['X-MyHeader'] | Should -be 5 + } + + It 'Convert Simple(Explode) style serialized string to hashtable' { + $serialized = 'X-MyHeader: role=admin,firstName=Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + $result['X-MyHeader'].GetEnumerator() | ForEach-Object { + $expected[$_.Key] | Should -Be $_.Value + } + } + + It 'Convert Simple(Explode) style serialized string to array' { + $serialized = 'X-MyHeader: 3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode + $result['X-MyHeader'] | Should -be @('3', '4', '5') + } + + It 'Convert Simple style serialized string to a primitive value' { + $serialized = 'X-MyHeader: 5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple + $result['X-MyHeader'] | Should -be 5 + } + + It 'Convert Simple style serialized string to hashtable' { + $serialized = 'X-MyHeader: role,admin,firstName,Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + $result['X-MyHeader'].GetEnumerator() | ForEach-Object { + $expected[$_.Key] | Should -Be $_.Value + } + } + + It 'Convert Simple style serialized string to array' { + $serialized = 'X-MyHeader: 3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple + $result['X-MyHeader'] | Should -be @('3', '4', '5') + } + } + + Describe 'Cookie Parameters' { + It 'Convert Form(Explode) style serialized string to a primitive value' { + $serialized = 'Cookie: id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form -Explode + $result['Cookie'] | Should -be 5 + } + + It 'Convert Form style serialized string to a primitive value' { + $serialized = 'Cookie: id=5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form + $result['Cookie'] | Should -be 5 + } + + It 'Convert Form style serialized string to hashtable' { + $serialized = 'Cookie: id=role,admin,firstName,Alex' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form + $expected = @{ + role = 'admin' + firstName = 'Alex' + } + $result['Cookie'].GetEnumerator() | ForEach-Object { + $expected[$_.Key] | Should -Be $_.Value + } + } + + It 'Convert Form style serialized string to array' { + $serialized = 'Cookie: id=3,4,5' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form + $result['Cookie'] | Should -be @('3', '4', '5') + } + } + + Describe 'Edge cases' { + + It 'Throws an error for invalid serialization style' { + $serialized = 'some data' + { ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style 'InvalidStyle' } | Should -Throw + } + + It 'Properly decodes URL-encoded characters' { + $serialized = 'name%3DJohn%20Doe%2Cage%3D30' + + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode -UrlDecode + + # Define the expected hashtable + $expected = @{ + 'name' = 'John Doe' + 'age' = '30' + } + $result.Keys.Count | Should -Be $expected.Keys.Count + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + + It 'Handles special characters in keys and values' { + $serialized = 'na!me=Jo@hn,do#e=30$' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode + $expected = @{ + 'na!me' = 'Jo@hn' + 'do#e' = '30$' + } + $result.Keys.Count | Should -Be $expected.Keys.Count + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Parses deeply nested structures in DeepObject style' { + $serialized = '?user[address][street]=Main St&user[address][city]=Anytown&user[details][age]=30' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style DeepObject -ParameterName 'user' + $expected = @{ + 'address' = @{ + 'street' = 'Main St' + 'city' = 'Anytown' + } + 'details' = @{ + 'age' = '30' + } + } + # Recursive comparison function + function Compare-Hashtable($expected, $actual) { + $expected.Keys.Count | Should -Be $actual.Keys.Count + foreach ($key in $expected.Keys) { + $actual.ContainsKey($key) | Should -BeTrue -Because "Key '$key' is missing." + if ($expected[$key] -is [hashtable]) { + Compare-Hashtable $expected[$key] $actual[$key] + } + else { + $actual[$key] | Should -Be $expected[$key] + } + } + } + Compare-Hashtable $expected $result + } + + + It 'Handles multiple occurrences of the same parameter in Query style' { + $serialized = '?id=1&id=2&id=3' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form -Explode -ParameterName 'id' + $result | Should -Be @('1', '2', '3') + } + + It 'Handles single value in SpaceDelimited style without wrapping in an array' { + $serialized = '?id=42' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style SpaceDelimited -ParameterName 'id' + $result | Should -Be '42' + } + + It 'Parses Matrix style without explode correctly' { + $serialized = ';id=1,2,3' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Matrix -ParameterName 'id' + $result | Should -Be @('1', '2', '3') + } + It 'Handles missing dot prefix in Label style gracefully' { + $serialized = 'name=value' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Label -Explode + $expected = @{ 'name' = 'value' } + $result.Keys.Count | Should -Be $expected.Keys.Count + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + It 'Parses headers with multiple values correctly' { + $serialized = 'X-Custom-Header: value1,value2,value3' + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple + $result['X-Custom-Header'] | Should -Be @('value1', 'value2', 'value3') + } + + It 'return the SerializedString content for malformed input string' { + $serialized = 'name===value' + ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Simple -Explode | Should -be $serialized + } + + It 'Throws an error for unsupported characters in Style parameter' { + { ConvertFrom-PodeSerializedString -SerializedInput 'data' -Style 'S!mple' } | Should -Throw + } + + It 'Parses complex real-world query strings correctly' { + $serialized = '?filter=name%20eq%20%27John%27&sort=asc&limit=10' + + $result = ConvertFrom-PodeSerializedString -SerializedInput $serialized -Style Form -Explode -UrlDecode + + $expected = @{ + 'filter' = "name eq 'John'" + 'sort' = 'asc' + 'limit' = '10' + } + $result.Keys.Count | Should -Be $expected.Keys.Count + foreach ($key in $expected.Keys) { + $result[$key] | Should -Be $expected[$key] + } + } + + } + +} + + + +Describe 'ConvertTo-PodeSerializedString' { + + BeforeAll { + function SortSerializedString { + param ( + [string] $SerializedString, + [string] $Delimiter, + [switch] $GroupPairs, + [string] $SkipHead = '', + [string] $RemovePattern = '' + ) + + # If a head to skip is specified, separate it from the rest of the string + if ($SkipHead -and $SerializedString.StartsWith($SkipHead)) { + # Extract the head and the rest of the string + $head = $SkipHead + $SerializedString = $SerializedString.Substring($SkipHead.Length) + } + else { + $head = '' + } + + # Split the remaining string into individual elements + $elements = $SerializedString -split $Delimiter + + # Apply pattern removal if specified + if ($RemovePattern) { + $elements = $elements.ForEach({ + $_ -replace $RemovePattern, '' + }) + } + + if ($GroupPairs) { + # Group elements into pairs (key-value) + $pairs = for ($i = 0; $i -lt $elements.Count; $i += 2) { + # Check if the next element exists to avoid a trailing delimiter + if ($i + 1 -lt $elements.Count) { + "$($elements[$i])$Delimiter$($elements[$i + 1])" + } + else { + # If the last element doesn't have a pair, add it as is + $elements[$i] + } + } + + # Sort the pairs + $sortedPairs = $pairs | Sort-Object + + # Join sorted pairs back into a single string + $sortedString = $sortedPairs -join $Delimiter + } + else { + # Sort elements individually without grouping into pairs + $sortedElements = $elements | Sort-Object + + # Join sorted elements back into a single string + $sortedString = $sortedElements -join $Delimiter + } + + # Reattach the head (if any) at the start of the sorted string + $result = "$head$sortedString" + + # Remove any trailing delimiter that may have been inadvertently added + if ($result.EndsWith($Delimiter)) { + $result = $result.Substring(0, $result.Length - 1) + } + + return $result + } + } + It 'should convert hashtable to Simple style serialized string' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Simple' + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter ',' -GroupPairs + $expected = 'name,value,number,10,anotherName,anotherValue' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter ',' -GroupPairs + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert hashtable to Simple style serialized string with Explode' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Simple' -Explode + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter ',' + $expected = 'name=value,number=10,anotherName=anotherValue' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter ',' + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert hashtable to Label style serialized string' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Label' + + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter ',' -SkipHead '.' + $expected = '.anotherName,anotherValue,number,10,name,value' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter ',' -SkipHead '.' + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert hashtable to Label style serialized string with Explode' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Label' -Explode + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter ',' -SkipHead '.' + $expected = '.anotherName=anotherValue,number=10,name=value' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter ',' -SkipHead '.' + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert hashtable to Matrix style serialized string' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Matrix' + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter ',' -GroupPairs -SkipHead ';id=' + $expected = ';id=name,value,number,10,anotherName,anotherValue' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter ',' -GroupPairs -SkipHead ';id=' + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert hashtable to Matrix style serialized string with Explode' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Matrix' -Explode + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter ';' -SkipHead ';' + $expected = ';name=value;number=10;anotherName=anotherValue' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter ';' -SkipHead ';' + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert hashtable to Form style serialized string' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Form' + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter ',' -GroupPairs -SkipHead '?id=' + $expected = '?id=name,value,number,10,anotherName,anotherValue' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter ',' -GroupPairs -SkipHead '?id=' + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert hashtable to Form style serialized string with Explode' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Form' -Explode + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter '&' -SkipHead '?' + $expected = '?name=value&number=10&anotherName=anotherValue' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter '&' -SkipHead '?' + $sortedResult | Should -Be $sortedExpected + } + + + + It 'should convert hashtable to DeepObject style serialized string' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'DeepObject' -Explode + $sortedResult = SortSerializedString -SerializedInput $result -Delimiter '&' -SkipHead '?' -RemovePattern 'id\[|\]' + $expected = '?id[name]=value&id[number]=10&id[anotherName]=anotherValue' + $sortedExpected = SortSerializedString -SerializedInput $expected -Delimiter '&' -SkipHead '?' -RemovePattern 'id\[|\]' + $sortedResult | Should -Be $sortedExpected + } + + It 'should convert array to Simple style serialized string' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'Simple' + $result | Should -Be '3,4,5' + } + + It 'should convert array to Simple style serialized string with Explode' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'Simple' -Explode + $result | Should -Be '3,4,5' + } + + It 'should convert array to Label style serialized string' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'Label' + $result | Should -Be '.3,4,5' + } + + It 'should convert array to Label style serialized string with Explode' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'Label' -Explode + $result | Should -Be '.3,4,5' + } + + It 'should convert array to Matrix style serialized string' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'Matrix' + $result | Should -Be ';id=3,4,5' + } + + It 'should convert array to Matrix style serialized string with Explode' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'Matrix' -Explode + $result | Should -Be ';id=3;id=4;id=5' + } + + It 'should convert array to SpaceDelimited style serialized string' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'SpaceDelimited' + $result | Should -Be '?id=3%204%205' + } + + It 'should convert array to SpaceDelimited style serialized string with Explode' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'SpaceDelimited' -Explode + $result | Should -Be '?id=3&id=4&id=5' + } + + It 'should convert array to PipeDelimited style serialized string' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'PipeDelimited' + $result | Should -Be '?id=3%7C4%7C5' + } + + It 'should convert array to PipeDelimited style serialized string with Explode' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'PipeDelimited' -Explode + $result | Should -Be '?id=3&id=4&id=5' + } + + + It 'should throw an error for unsupported serialization style' { + $hashtable = @{ + name = 'value' + anotherName = 'anotherValue' + number = 10 + } + { ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Unsupported' } | Should -Throw + } + + + It 'should convert array to Matrix style without URL encoding' { + $array = @('value one', 'value/two', 'value&three') + $result = $array | ConvertTo-PodeSerializedString -Style 'Matrix' -NoUrlEncode + $result | Should -Be ';id=value one,value/two,value&three' + } + + It 'should handle special characters with URL encoding' { + $array = @('value one', 'value/two', 'value&three') + $result = $array | ConvertTo-PodeSerializedString -Style 'Matrix' + $result | Should -Be ';id=value%20one,value%2Ftwo,value%26three' + } + + It 'should handle empty array input' { + $array = @() + $result = $array | ConvertTo-PodeSerializedString -Style 'Simple' + $result | Should -Be '' + } + + It 'should handle empty hashtable input by returning an empty string' { + $hashtable = @{} + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Simple' + $result | Should -Be '' + } + + It 'should use custom parameter name' { + $array = @(3, 4, 5) + $result = $array | ConvertTo-PodeSerializedString -Style 'Matrix' -ParameterName 'customId' + $result | Should -Be ';customId=3,4,5' + } + + It 'should correctly serialize single-element array' { + $array = @('singleValue') + $result = ConvertTo-PodeSerializedString -InputObject $array -Style 'Simple' + $result | Should -Be 'singleValue' + } + + It 'should correctly serialize single-entry hashtable' { + $hashtable = @{ key = 'value' } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Form' -Explode + $result | Should -Be '?key=value' + } + + It 'should URL-encode special characters in keys and values' { + $hashtable = @{ + 'name with spaces' = 'value/with/special&chars' + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Form' -Explode + $expected = '?name%20with%20spaces=value%2Fwith%2Fspecial%26chars' + $result | Should -Be $expected + } + It 'should not URL-encode when NoUrlEncode switch is used' { + $hashtable = @{ + 'name with spaces' = 'value/with/special&chars' + } + $result = ConvertTo-PodeSerializedString -InputObject $hashtable -Style 'Form' -Explode -NoUrlEncode + $expected = '?name with spaces=value/with/special&chars' + $result | Should -Be $expected + } + + It 'should use custom ParameterName in serialization' { + $array = @(1, 2, 3) + $result = ConvertTo-PodeSerializedString -InputObject $array -Style 'Matrix' -ParameterName 'customParam' + $result | Should -Be ';customParam=1,2,3' + } + + +} + + +Describe 'Get-PodePathParameter' { + BeforeEach { + # Mock the $WebEvent variable + $Script:WebEvent = [PSCustomObject]@{ + Parameters = @{ 'action' = 'create' } + } + } + + It 'should return the specified parameter value from the web event' { + # Call the function + $result = Get-PodePathParameter -Name 'action' + + # Assert the result + $result | Should -Be 'create' + } +} + + +Describe 'Get-PodeQueryParameter' { + BeforeEach { + # Mock the $WebEvent variable + $Script:WebEvent = [PSCustomObject]@{ + Query = @{ 'userId' = '12345' } + } + } + + It 'should return the specified query parameter value from the web event' { + # Call the function + $result = Get-PodeQueryParameter -Name 'userId' + + # Assert the result + $result | Should -Be '12345' + } +} + + +Describe 'Get-PodeBodyData' { + BeforeEach { + # Mock the $WebEvent variable + $Script:WebEvent = [PSCustomObject]@{ + Data = 'This is the body data' + } + } + + It 'should return the body data of the web event' { + # Call the function + $result = Get-PodeBodyData + + # Assert the result + $result | Should -Be 'This is the body data' + } +} \ No newline at end of file From 02ba1bd412919e3158672b25e0b170c64d069cde Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:32:32 -0800 Subject: [PATCH 05/12] Squashed commit of the following: commit 7cbeabca0e71c1480520587d4a3eaa5744e3ecf5 Author: mdaneri Date: Sun Mar 2 09:16:22 2025 -0800 Fix PSObject to hashtable conversion commit 11ced8e2898cd7cafb91a04aca2c847ca15a28c2 Author: mdaneri Date: Sun Mar 2 08:35:38 2025 -0800 fix tests commit d15f3995ac5a0486dd2c587311f8864a2b555aaf Author: mdaneri Date: Sat Mar 1 17:56:58 2025 -0800 simplify merge commit a662515a692bd8870888ca89e096fb23df0d19f8 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sat Mar 1 23:36:31 2025 +0000 fix helper commit 77a56a1f045243d243b653dee8ec18ae37a49a46 Merge: e40e0d9d 67505f56 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Feb 23 07:30:37 2025 -0800 Merge branch 'develop' into threadsafe-state commit e40e0d9dfcf8f95dee253333b3ffd795350864a9 Merge: a3de2b35 cbdc62fe Author: mdaneri Date: Sat Feb 22 09:18:33 2025 -0800 Merge remote-tracking branch 'upstream/develop' into threadsafe-state commit a3de2b35ae8646a7f98c02299f8c48feccc11959 Merge: f073f50f fbf6ecfb Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sat Feb 22 06:31:37 2025 -0800 Merge branch 'develop' into threadsafe-state commit f073f50f0dcb569e197f5fd862b78a2467ea6942 Merge: a1beca34 a76741bc Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Feb 16 06:15:51 2025 -0800 Merge branch 'develop' into threadsafe-state commit a1beca34859a2c817a3761b6ee9594d80ce543b7 Merge: be8f4070 a236a1a0 Author: mdaneri Date: Tue Feb 11 18:49:41 2025 -0800 Merge remote-tracking branch 'upstream/develop' into threadsafe-state commit be8f40702f0a6b20e8d1738ae359feed22c08645 Merge: b7b085cf 75e29626 Author: mdaneri Date: Sun Feb 9 07:22:47 2025 -0800 Merge remote-tracking branch 'upstream/develop' into threadsafe-state commit b7b085cf8ac8d999b5987b00bb068354b08010fd Merge: a8931271 6b23fc33 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Wed Feb 5 13:13:11 2025 -0800 Merge branch 'develop' into threadsafe-state commit a89312714674f0e9da96ce6e7ebfab7a63e24dea Author: mdaneri Date: Tue Feb 4 08:06:04 2025 -0800 Get-PodeUtcNow commit 18dcdd61f32379c19381a2c2bb340e556d831ce6 Author: mdaneri Date: Mon Feb 3 18:30:32 2025 -0800 Update Convert.ps1 commit f2d70fa9579480620d7823ec532fc8ead45b0220 Author: mdaneri Date: Mon Feb 3 18:13:20 2025 -0800 Update State.Tests.ps1 commit aae3d5d05e9dace30110d1db272f1549581e34d7 Author: mdaneri Date: Mon Feb 3 18:08:02 2025 -0800 fix tests commit 8f50237d4fef081cf8fcf438f9da2d3b073170d3 Author: mdaneri Date: Mon Feb 3 17:39:06 2025 -0800 improvement commit 8594cc57bedd1b3f67848d6947308a3d88ec2d64 Author: mdaneri Date: Mon Feb 3 17:30:51 2025 -0800 remove Get-PodeApplicationName commit fc41e3c5303289156d2885fe5086861cf2d5d18f Author: mdaneri Date: Mon Feb 3 17:25:56 2025 -0800 Add translations commit 81802c1705664eb2c09c5bae589224bf6ee31dd7 Author: mdaneri Date: Mon Feb 3 16:38:32 2025 -0800 fix compatibility with older versions commit c634e0a2b338938a675e16dde6ca4129f098e795 Author: mdaneri Date: Mon Feb 3 16:22:17 2025 -0800 Delete System.Collections.Hashtable commit 556ef81b8e95453de20d5b6ef63b432eb557d9cf Author: mdaneri Date: Mon Feb 3 16:20:43 2025 -0800 update commit aab9fa57b8bebec982d04c4c2171957bbc7dece2 Author: mdaneri Date: Mon Feb 3 09:12:49 2025 -0800 Update SharedState.md commit 37226cf7c41317e86691c99fca05ed4ddc4d87a1 Author: mdaneri Date: Mon Feb 3 08:48:14 2025 -0800 fixes and doc update commit 1ad233461dee01f9e04379c2489212d202fc969d Author: mdaneri Date: Sun Feb 2 17:09:38 2025 -0800 fixes commit 0e0694c8f4aed712cd40369d3bef38a5ea6730b9 Author: mdaneri Date: Sun Feb 2 16:05:51 2025 -0800 final commit afaabe9f290a6ba02f810e3e1400048091a0748f Author: mdaneri Date: Sun Feb 2 10:13:58 2025 -0800 working version commit 2e55aac25ceb1d21414339e1d3c6a1241f3e0d63 Author: mdaneri Date: Sun Feb 2 08:14:31 2025 -0800 first drop --- .gitignore | 2 + docs/Tutorials/SharedState.md | 174 ++++++----- examples/Shared-State.ps1 | 32 +- examples/Shared-ThreadSafeState.ps1 | 138 +++++++++ examples/ThreadSafeState.json | 0 examples/test.json | 1 + src/Locales/ar/Pode.psd1 | 5 + src/Locales/de/Pode.psd1 | 5 + src/Locales/en-us/Pode.psd1 | 5 + src/Locales/en/Pode.psd1 | 5 + src/Locales/es/Pode.psd1 | 5 + src/Locales/fr/Pode.psd1 | 5 + src/Locales/it/Pode.psd1 | 5 + src/Locales/ja/Pode.psd1 | 5 + src/Locales/ko/Pode.psd1 | 5 + src/Locales/nl/Pode.psd1 | 5 + src/Locales/pl/Pode.psd1 | 5 + src/Locales/pt/Pode.psd1 | 5 + src/Locales/zh/Pode.psd1 | 5 + src/Private/Context.ps1 | 3 +- src/Private/Convert.ps1 | 455 ++++++++++++++++++++++++++++ src/Private/Helpers.ps1 | 73 ++++- src/Private/ScopedVariables.ps1 | 2 +- src/Public/Core.ps1 | 2 +- src/Public/State.ps1 | 338 +++++++++++++-------- src/Public/Utilities.ps1 | 16 + tests/shared/TestHelper.ps1 | 142 +++++++++ tests/unit/Convert.Tests.ps1 | 130 ++++++++ tests/unit/State.Tests.ps1 | 123 +++++++- 29 files changed, 1470 insertions(+), 226 deletions(-) create mode 100644 examples/Shared-ThreadSafeState.ps1 create mode 100644 examples/ThreadSafeState.json create mode 100644 examples/test.json create mode 100644 src/Private/Convert.ps1 create mode 100644 tests/unit/Convert.Tests.ps1 diff --git a/.gitignore b/.gitignore index 6938c5f15..99de26566 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ docs/[Ff]unctions/ examples/state.json examples/issue-* examples/issues/ +examples/State/ pkg/ deliverable/ .vs/ @@ -269,3 +270,4 @@ docs/Getting-Started/Samples.md # Dump Folder Dump + diff --git a/docs/Tutorials/SharedState.md b/docs/Tutorials/SharedState.md index b34ba137e..b596c2721 100644 --- a/docs/Tutorials/SharedState.md +++ b/docs/Tutorials/SharedState.md @@ -1,79 +1,122 @@ # Shared State -Most things in Pode run in isolated runspaces: routes, middleware, schedules - to name a few. This means you can't create a variable in a timer, and then access that variable in a route. To overcome this limitation you can use the Shared State feature within Pode, which allows you to set/get variables on a state shared between all runspaces. This lets you can create a variable in a timer and store it within the shared state; then you can retrieve the variable from the state in a route. +Most things in Pode run in isolated runspaces: routes, middleware, schedules - to name a few. This means you can't create a variable in a timer, and then access that variable in a route. To overcome this limitation you can use the Shared State feature within Pode, which allows you to set/get variables on a state shared between all runspaces. This lets you create a variable in a timer and store it within the shared state; then you can retrieve the variable from the state in a route. You also have the option of saving the current state to a file, and then restoring the state back on server start. This way you won't lose state between server restarts. -You can also use the State in combination with [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject) to ensure thread safety - if needed. +Pode supports various structures for shared state, some of which are thread-safe: + +**Thread-Safe Structures:** + +- `ConcurrentDictionary` +- `ConcurrentBag` +- `ConcurrentQueue` +- `ConcurrentStack` + +**Non-Thread-Safe Structures (Require Locking):** + +- `OrderedDictionary` +- `Hashtable` +- `PSCustomObject`When using a thread-safe object, `Lock-PodeObject` is no longer required. !!! tip - It's wise to use the State in conjunction with [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject), to ensure thread safety between runspaces. +It's wise to use the State in conjunction with [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject) when dealing with non-thread-safe objects to ensure thread safety between runspaces. !!! warning - If you omit the use of [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject), you might run into errors due to multi-threading. Only omit if you are *absolutely confident* you do not need locking. (ie: you set in state once and then only ever retrieve, never updating the variable). +If you are using a non-thread-safe object, such as `Hashtable`, `OrderedDictionary`, or `PSCustomObject`, you should use [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject) to ensure thread safety between runspaces. Omitting this may lead to concurrency issues and unpredictable behavior. ## Usage -Where possible use the same casing for the `-Name` of state keys. When using [`Restore-PodeState`](../../Functions/State/Restore-PodeState) the state will become case-sensitive due to the nature of how `ConvertFrom-Json` works. +Where possible, use the same casing for the `-Name` of state keys. When using [`Restore-PodeState`](../../Functions/State/Restore-PodeState), the state will become case-sensitive due to the nature of how `ConvertFrom-Json` works. ### Set -The [`Set-PodeState`](../../Functions/State/Set-PodeState) function will create/update a variable in the state. You need to supply a name and a value to set on the state, there's also an optional scope that can be supplied - which lets you save specific state objects with a certain scope. +#### **NewCollectionType Parameter** + +The `-NewCollectionType` parameter allows users to specify the type of collection to initialize within the shared state. This eliminates the need to manually instantiate collections before setting them in the state. + +**Supported Collection Types:** + +- `Hashtable` +- `ConcurrentDictionary` +- `OrderedDictionary` +- `ConcurrentBag` +- `ConcurrentQueue` +- `ConcurrentStack` + +If `-NewCollectionType` is used, the specified collection type will be created and stored in the state. The `-Value` parameter is ignored when this option is used. -An example of setting a hashtable variable in the state is as follows: +**Examples:** ```powershell -Start-PodeServer { - Add-PodeTimer -Name 'do-something' -Interval 5 -ScriptBlock { - Lock-PodeObject -ScriptBlock { - Set-PodeState -Name 'data' -Value @{ 'Name' = 'Rick Sanchez' } | Out-Null - } - } -} +# Set a simple hashtable in shared state +Set-PodeState -Name 'Data' -Value @{ 'Name' = 'Rick Sanchez' } + +# Initialize a ConcurrentDictionary instead of providing a value +Set-PodeState -Name 'Cache' -NewCollectionType 'ConcurrentDictionary' + +# Create a ConcurrentQueue for shared state management +Set-PodeState -Name 'Tasks' -NewCollectionType 'ConcurrentQueue' +``` + +The [`Set-PodeState`](../../Functions/State/Set-PodeState) function will create/update a variable in the state. You need to supply a name and a value to set on the state, and there's also an optional scope that can be supplied - which lets you save specific state objects with a certain scope. + +!!! tip +The .NET collections `ConcurrentDictionary` and `OrderedDictionary` are case-sensitive by default. To make them case-insensitive, initialize them as follows: + +```powershell +# Case-insensitive ConcurrentDictionary +Set-PodeState -Name 'Cache' -Value ([System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)) + +# Case-insensitive OrderedDictionary +Set-PodeState -Name 'Config' -Value ([System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)) + +# Case-insensitive OrderedDictionary +Set-PodeState -Name 'Running' -Value ([ordered]@{}) +``` + +Alternatively, you can use: + +```powershell +Set-PodeState -Name 'Cache' -NewCollectionType 'ConcurrentDictionary' +Set-PodeState -Name 'Config' -NewCollectionType 'OrderedDictionary' +Set-PodeState -Name 'Running' -NewCollectionType 'OrderedDictionary' ``` -Alternatively you could use the `$state:` variable scope to set a variable in state. This variable will be scopeless, so if you need scope then use [`Set-PodeState`](../../Functions/State/Set-PodeState). `$state:` can be used anywhere, but keep in mind that like `$session:` Pode can only remap the this in scriptblocks it's aware of; so using it in a function of a custom module won't work. Similar to the example above: +#### Example: Non-Thread-Safe Objects + +If using a non-thread-safe object, such as `Hashtable`, `OrderedDictionary`, or `PSCustomObject`, wrap access to the state in `Lock-PodeObject` to prevent concurrency issues. ```powershell Start-PodeServer { Add-PodeTimer -Name 'do-something' -Interval 5 -ScriptBlock { Lock-PodeObject -ScriptBlock { - $state:data = @{ 'Name' = 'Rick Sanchez' } + Set-PodeState -Name 'data' -Value @{ 'Name' = 'Rick Sanchez' } | Out-Null } } } ``` -### Get +The [`Set-PodeState`](../../Functions/State/Set-PodeState) function will create/update a variable in the state. You need to supply a name and a value to set on the state, and there's also an optional scope that can be supplied - which lets you save specific state objects with a certain scope. -The [`Get-PodeState`](../../Functions/State/Get-PodeState) function will return the value currently stored in the state for a variable. If the variable doesn't exist then `$null` is returned. - -An example of retrieving a value from the state is as follows: +An example of setting a `ConcurrentDictionary` variable in the state is as follows: ```powershell Start-PodeServer { Add-PodeTimer -Name 'do-something' -Interval 5 -ScriptBlock { - $value = $null - - Lock-PodeObject -ScriptBlock { - $value = (Get-PodeState -Name 'data') - } - - # do something with $value + Set-PodeState -Name 'data' -Value ([System.Collections.Concurrent.ConcurrentDictionary[string, string]]::new()) | Out-Null } } ``` -Alternatively you could use the `$state:` variable scope to get a variable in state. `$state:` can be used anywhere, but keep in mind that like `$session:` Pode can only remap the this in scriptblocks it's aware of; so using it in a function of a custom module won't work. Similar to the example above: +### Get + +The [`Get-PodeState`](../../Functions/State/Get-PodeState) function will return the value currently stored in the state for a variable. If the variable doesn't exist, `$null` is returned. ```powershell Start-PodeServer { Add-PodeTimer -Name 'do-something' -Interval 5 -ScriptBlock { - $value = $null - - Lock-PodeObject -ScriptBlock { - $value = $state:data - } + $value = (Get-PodeState -Name 'data') # do something with $value } @@ -84,45 +127,29 @@ Start-PodeServer { The [`Remove-PodeState`](../../Functions/State/Remove-PodeState) function will remove a variable from the state. It will also return the value stored in the state before removing the variable. -An example of removing a variable from the state is as follows: - ```powershell Start-PodeServer { Add-PodeTimer -Name 'do-something' -Interval 5 -ScriptBlock { - Lock-PodeObject -ScriptBlock { - Remove-PodeState -Name 'data' | Out-Null - } + Remove-PodeState -Name 'data' | Out-Null } } ``` ### Save -The [`Save-PodeState`](../../Functions/State/Save-PodeState) function will save the current state, as JSON, to the specified file. The file path can either be relative, or literal. When saving the state, it's recommended to wrap the function within [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject). - -An example of saving the current state every hour is as follows: +The [`Save-PodeState`](../../Functions/State/Save-PodeState) function will save the current state, as JSON, to the specified file. The file path can either be relative or literal. When saving the state, it's recommended to wrap the function within [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject) if dealing with non-thread-safe objects. ```powershell Start-PodeServer { Add-PodeSchedule -Name 'save-state' -Cron '@hourly' -ScriptBlock { - Lock-PodeObject -ScriptBlock { - Save-PodeState -Path './state.json' - } + Save-PodeState -Path './state.json' } } ``` -When saving the state, you can also use the `-Exclude` or `-Include` parameters to exclude/include certain state objects from being saved. Saving also has a `-Scope` parameter, which allows you so save only state objects created with the specified scope(s). - -You can use all the above 3 parameter in conjunction, with `-Exclude` having the highest precedence and `-Scope` having the lowest. - -By default the JSON will be saved expanded, but you can saved the JSON as compressed by supplying the `-Compress` switch. - ### Restore -The [`Restore-PodeState`](../../Functions/State/Restore-PodeState) function will restore the current state from the specified file. The file path can either be relative, or a literal path. if you're restoring the state immediately on server start, you don't need to use [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject). - -An example of restore the current state on server start is as follows: +The [`Restore-PodeState`](../../Functions/State/Restore-PodeState) function will restore the current state from the specified file. The file path can either be relative or a literal path. If you're restoring the state immediately on server start, you don't need to use [`Lock-PodeObject`](../../Functions/Threading/Lock-PodeObject). ```powershell Start-PodeServer { @@ -130,54 +157,37 @@ Start-PodeServer { } ``` -By default, restoring from a state file will overwrite the current state. You can change this so the restored state is merged instead by using the `-Merge` switch. (Note: if you restore a key that already exists in state, this will still overwrite that key). - ## Full Example -The following is a full example of using the State functions. It is a simple Timer that creates and updates a `hashtable` variable, and then a Route is used to retrieve that variable. There is also another route that will remove the variable from the state. The state is also saved on every iteration of the timer, and restored on server start: +The following is a full example of using the State functions. It is a simple Timer that creates and updates a `ConcurrentDictionary` variable, and then a Route is used to retrieve that variable. There is also another route that will remove the variable from the state. The state is also saved on every iteration of the timer and restored on server start: ```powershell Start-PodeServer { Add-PodeEndpoint -Address * -Port 8080 -Protocol Http # create the shared variable - Set-PodeState -Name 'hash' -Value @{ 'values' = @(); } | Out-Null + Set-PodeState -Name 'dict' -Value ([System.Collections.Concurrent.ConcurrentDictionary[string, int]]::new()) | Out-Null - # attempt to re-initialise the state (will do nothing if the file doesn't exist) + # attempt to re-initialize the state (will do nothing if the file doesn't exist) Restore-PodeState -Path './state.json' # timer to add a random number to the shared state Add-PodeTimer -Name 'forever' -Interval 2 -ScriptBlock { - # ensure we're thread safe - Lock-PodeObject -ScriptBlock { - # attempt to get the hashtable from the state - $hash = (Get-PodeState -Name 'hash') - - # add a random number - $hash.values += (Get-Random -Minimum 0 -Maximum 10) - - # save the state to file - Save-PodeState -Path './state.json' - } + $dict = (Get-PodeState -Name 'dict') + $dict["random"] = (Get-Random -Minimum 0 -Maximum 10) + Save-PodeState -Path './state.json' } - # route to return the value of the hashtable from shared state + # route to return the value of the dictionary from shared state Add-PodeRoute -Method Get -Path '/' -ScriptBlock { - # again, ensure we're thread safe - Lock-PodeObject -ScriptBlock { - # get the hashtable from the state and return it - $hash = (Get-PodeState -Name 'hash') - Write-PodeJsonResponse -Value $hash - } + $dict = (Get-PodeState -Name 'dict') + Write-PodeJsonResponse -Value $dict } - # route to remove the hashtable from shared state + # route to remove the dictionary from shared state Add-PodeRoute -Method Delete -Path '/' -ScriptBlock { - # ensure we're thread safe - Lock-PodeObject -ScriptBlock { - # remove the hashtable from the state - Remove-PodeState -Name 'hash' | Out-Null - } + Remove-PodeState -Name 'dict' | Out-Null } } ``` + diff --git a/examples/Shared-State.ps1 b/examples/Shared-State.ps1 index a62f0e687..5f73c3b5d 100644 --- a/examples/Shared-State.ps1 +++ b/examples/Shared-State.ps1 @@ -44,17 +44,34 @@ Start-PodeServer { Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + $Path = Join-Path (Get-PodeServerPath) "State" + if (!(Test-Path -Path $Path -PathType Container)) { + New-Item -Path $Path -ItemType Directory + + } + $stateScope1Path = Join-Path -Path $Path -ChildPath 'LegacyStateScope1.json' + #if no previous state exist create a new one old Pode style + if (!( Test-Path $stateScope1Path)) { + @{ + 'hash1' = @{ + 'Scope' = @('Scope0', 'Scope1') + 'Value' = @{ + 'values' = @(4, 4, 8, 0, 5, 5, 1, 9, 1, 1, 2, 4, 9, 2, 5) + } + } + } | ConvertTo-Json -Depth 10 | Out-File $stateScope1Path + } # re-initialise the state - Restore-PodeState -Path './state.json' + Restore-PodeState -Path $stateScope1Path # initialise if there was no file - if ($null -eq ($hash = (Get-PodeState -Name 'hash1'))) { + if (!(Test-PodeState -Name 'hash1')) { $hash = Set-PodeState -Name 'hash1' -Value @{} -Scope Scope0, Scope1 $hash['values'] = @() } - if ($null -eq ($hash = (Get-PodeState -Name 'hash2'))) { + if (!(Test-PodeState -Name 'hash2')) { $hash = Set-PodeState -Name 'hash2' -Value @{} -Scope Scope0, Scope2 $hash['values'] = @() } @@ -63,14 +80,17 @@ Start-PodeServer { $state:hash3 = @{ values = @() } } + Save-PodeState -Path $stateScope1Path -Scope Scope1 + # create timer to update a hashtable and make it globally accessible - Add-PodeTimer -Name 'forever' -Interval 2 -ScriptBlock { + Add-PodeTimer -Name 'forever' -Interval 2 -ArgumentList $stateScope1Path -ScriptBlock { + param([string]$stateScope1Path) $hash = $null Lock-PodeObject -ScriptBlock { $hash = (Get-PodeState -Name 'hash1') $hash.values += (Get-Random -Minimum 0 -Maximum 10) - Save-PodeState -Path './state.json' -Scope Scope1 #-Exclude 'hash1' + Save-PodeState -Path $stateScope1Path -Scope Scope1 #-Exclude 'hash1' } Lock-PodeObject -ScriptBlock { @@ -95,7 +115,7 @@ Start-PodeServer { # route to remove the hashtable from global state Add-PodeRoute -Method Delete -Path '/array' -ScriptBlock { Lock-PodeObject -ScriptBlock { - $hash = (Set-PodeState -Name 'hash1' -Value @{}) + $hash = (Set-PodeState -Name 'hash1' -Value @{} -Scope Scope0, Scope1) $hash.values = @() } } diff --git a/examples/Shared-ThreadSafeState.ps1 b/examples/Shared-ThreadSafeState.ps1 new file mode 100644 index 000000000..923bec1ea --- /dev/null +++ b/examples/Shared-ThreadSafeState.ps1 @@ -0,0 +1,138 @@ +<# +.SYNOPSIS + A sample PowerShell script to set up a Pode server with thread-safe state management and logging. + +.DESCRIPTION + This script sets up a Pode server that listens on port 8081, logs requests and errors to the terminal, and manages state using thread-safe collections such as `ConcurrentDictionary` and `ConcurrentBag`. The server initializes state from a JSON file, updates state periodically using timers, and provides routes to interact with the state. + +.EXAMPLE + To run the sample: ./Shared-ThreadSafeState.ps1 + + Invoke-RestMethod -Uri http://localhost:8081/array -Method Get + Invoke-RestMethod -Uri http://localhost:8081/array3 -Method Get + Invoke-RestMethod -Uri http://localhost:8081/array -Method Delete + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/Shared-ThreadSafeState.ps1 + +.NOTES + Author: Pode Team + License: MIT License + This script uses `ConcurrentDictionary` and `ConcurrentBag` to ensure thread-safe state handling in a multi-threaded Pode environment. +#> + +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } + +# or just: +# Import-Module Pode + +# create a basic server +Start-PodeServer { + + Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http + New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + $Path = Get-PodeRelativePath -Path './State' -JoinRoot -Resolve + + if (!(Test-Path -Path $Path -PathType Container)) { + New-Item -Path $Path -ItemType Directory + + } + $stateScope1Path = Join-Path -Path $Path -ChildPath 'ThreadSafeStateScope1.json' + $stateScope2Path = Join-Path -Path $Path -ChildPath 'ThreadSafeStateScope2.json' + $stateScope0Path = Join-Path -Path $Path -ChildPath 'ThreadSafeStateScope0.json' + $stateNoScopePath = Join-Path -Path $Path -ChildPath 'ThreadSafeStateNoScope.json' + # re-initialise the state + Restore-PodeState -Path $stateScope1Path + Restore-PodeState -Path $stateScope2Path -Merge + Save-PodeState -Path $stateNoScopePath + # initialise if there was no file + if (!(Test-PodeState -Name 'hash1')) { + $hash = (Set-PodeState -Name 'hash1' -NewCollectionType ConcurrentDictionary -Scope Scope0, Scope1 ) + $hash.bag = [System.Collections.Concurrent.ConcurrentBag[object]]::new() + $hash.array = @() + $hash.psCustomSum = [PSCustomObject]@{ + bag = 0 + array = 0 + } + $hash.string = 'Never deleted' + $hash.deleted = 0 + # Assign a custom PsTypeName + $hash.psCustomSum.PSTypeNames.Insert(0, 'Pode.StateSum') + } + + if (!(Test-PodeState -Name 'hash2')) { + $hash = Set-PodeState -Name 'hash2' -NewCollectionType Hashtable -Scope Scope0, Scope2 + $hash['values'] = @() + } + + if ($null -eq $state:hash3) { + $state:hash3 = @{ values = @() } + } + + # create timer to update a hashtable and make it globally accessible + Add-PodeTimer -Name 'forever' -Interval 2 -ArgumentList $stateScope1Path, $stateScope2Path, $stateScope0Path, $stateNoScopePath -ScriptBlock { + param([string]$stateScope1Path, [string]$stateScope2Path, [string]$stateScope0Path, [string]$stateNoScopePath) + $hash = $null + + $hash = (Get-PodeState -Name 'hash1') + $hash.bag.add((Get-Random -Minimum 0 -Maximum 10)) + $hash.array += (Get-Random -Minimum 0 -Maximum 10) + + $hash.psCustomSum.bag = $hash.bag.ToArray() | Measure-Object -Sum | Select-Object -ExpandProperty Sum + $hash.psCustomSum.array = $hash.array | Measure-Object -Sum | Select-Object -ExpandProperty Sum + + + $state:hash3.values += (Get-Random -Minimum 0 -Maximum 10) + + $hash2 = (Get-PodeState -Name 'hash2') + $hash2.values += (Get-Random -Minimum 100 -Maximum 200) + Save-PodeState -Path $stateScope1Path -Scope Scope1 #-Exclude 'hash2' + Save-PodeState -Path $stateScope2Path -Scope Scope2 + Save-PodeState -Path $stateScope0Path -Scope Scope0 + Save-PodeState -Path $stateNoScopePath + } + + # route to retrieve and return the value of the hashtable from global state + Add-PodeRoute -Method Get -Path '/array' -ScriptBlock { + $hash = (Get-PodeState 'hash1') + Write-PodeJsonResponse -Value $hash + } + + Add-PodeRoute -Method Get -Path '/array3' -ScriptBlock { + Write-PodeJsonResponse -Value $state:hash3 + } + + # route to remove the hashtable from global state + Add-PodeRoute -Method Delete -Path '/array' -ScriptBlock { + $value = (Get-PodeState -Name 'hash1' ) + $hash = (Set-PodeState -Name 'hash1' -NewCollectionType ConcurrentDictionary -Scope Scope0, Scope1 ) + $hash.bag = [System.Collections.Concurrent.ConcurrentBag[object]]::new() + $hash.bag.add((Get-Random -Minimum 0 -Maximum 10)) + $hash.array = @((Get-Random -Minimum 0 -Maximum 10)) + + $hash.psCustomSum = [PSCustomObject]@{ + bag = $hash.bag.ToArray() | Measure-Object -Sum | Select-Object -ExpandProperty Sum + array = $hash.array | Measure-Object -Sum | Select-Object -ExpandProperty Sum + } + # Assign a custom PsTypeName + $hash.psCustomSum.PSTypeNames.Insert(0, 'Pode.StateSum') + $hash.deleted = $value.number + 1 + $hash.string = "Deleted $($hash.number) times" + + } + +} \ No newline at end of file diff --git a/examples/ThreadSafeState.json b/examples/ThreadSafeState.json new file mode 100644 index 000000000..e69de29bb diff --git a/examples/test.json b/examples/test.json new file mode 100644 index 000000000..14484b493 --- /dev/null +++ b/examples/test.json @@ -0,0 +1 @@ +{"Type":"ConcurrentDictionary","Items":[{"Key":"Name","Value":{"Type":"ConcurrentDictionary","Items":[{"Key":"Value","Value":{"Type":"Hashtable","Items":[{"Key":"Name","Value":"Morty"}]}},{"Key":"Scope","Value":null}]}}]} diff --git a/src/Locales/ar/Pode.psd1 b/src/Locales/ar/Pode.psd1 index d9617f469..0cdf650f9 100644 --- a/src/Locales/ar/Pode.psd1 +++ b/src/Locales/ar/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = 'قاعدة الحد الأقصى للمعدل غير موجودة: {0}' accessLimitRuleAlreadyExistsExceptionMessage = 'تم تعريف قاعدة الحد الأقصى للوصول بالفعل: {0}' accessLimitRuleDoesNotExistExceptionMessage = 'قاعدة الحد الأقصى للوصول غير موجودة: {0}' + invalidPodeStateFormatExceptionMessage = 'ملف PodeState "{0}" يحتوي على تنسيق غير صالح. كان متوقعًا هيكل يشبه القاموس (ConcurrentDictionary أو Hashtable أو OrderedDictionary)، ولكن تم العثور على [{1}]. يرجى التحقق من محتوى الملف أو إعادة تهيئة الحالة.' + unknownJsonDictionaryTypeExceptionMessage = 'نوع قاموس/مجموعة غير معروف في JSON: {0}' + invalidPodeStateDataExceptionMessage = 'البيانات المقدمة لا تمثل حالة Pode صالحة.' + podeStateVersionMismatchExceptionMessage = 'بيانات الحالة المقدمة تأتي من إصدار أحدث من Pode: {0}.' + podeStateApplicationMismatchExceptionMessage = 'بيانات الحالة المقدمة تنتمي إلى تطبيق آخر: {0}.' } diff --git a/src/Locales/de/Pode.psd1 b/src/Locales/de/Pode.psd1 index d85929f70..6de56c63d 100644 --- a/src/Locales/de/Pode.psd1 +++ b/src/Locales/de/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "Die Rate-Limit-Regel mit dem Namen '{0}' existiert nicht." accessLimitRuleAlreadyExistsExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert bereits." accessLimitRuleDoesNotExistExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert nicht." + invalidPodeStateFormatExceptionMessage = 'Die PodeState-Datei "{0}" enthält ein ungültiges Format. Erwartet wurde eine Dictionary-ähnliche Struktur (ConcurrentDictionary, Hashtable oder OrderedDictionary), aber gefunden wurde [{1}]. Bitte überprüfen Sie den Dateiinhalt oder initialisieren Sie den Zustand neu.' + unknownJsonDictionaryTypeExceptionMessage = 'Unbekannter Wörterbuch-/Sammlungstyp in JSON: {0}' + invalidPodeStateDataExceptionMessage = 'Die bereitgestellten Daten stellen keinen gültigen Pode-Status dar.' + podeStateVersionMismatchExceptionMessage = 'Die bereitgestellten Statusdaten stammen aus einer neueren Pode-Version: {0}.' + podeStateApplicationMismatchExceptionMessage = 'Die bereitgestellten Statusdaten gehören zu einer anderen Anwendung: {0}.' } \ No newline at end of file diff --git a/src/Locales/en-us/Pode.psd1 b/src/Locales/en-us/Pode.psd1 index c2ac6d2b0..1b9cd35da 100644 --- a/src/Locales/en-us/Pode.psd1 +++ b/src/Locales/en-us/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "A rate limit rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An access limit rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An access limit rule with the name '{0}' does not exist." + invalidPodeStateFormatExceptionMessage = 'The PodeState file "{0}" contains an invalid format. Expected a dictionary-like structure (ConcurrentDictionary, Hashtable, or OrderedDictionary), but found [{1}]. Please verify the file content or reinitialize the state.' + unknownJsonDictionaryTypeExceptionMessage = 'Unknown dictionary/collection type in JSON: {0}' + invalidPodeStateDataExceptionMessage = 'The provided data does not represent a valid Pode state.' + podeStateVersionMismatchExceptionMessage = 'The provided state data originates from a newer Pode version: {0}.' + podeStateApplicationMismatchExceptionMessage = 'The provided state data belongs to a different application: {0}.' } \ No newline at end of file diff --git a/src/Locales/en/Pode.psd1 b/src/Locales/en/Pode.psd1 index fc04db0d2..58bfcb91d 100644 --- a/src/Locales/en/Pode.psd1 +++ b/src/Locales/en/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "A Rate Limit Rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An Access Limit Rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An Access Limit Rule with the name '{0}' does not exist." + invalidPodeStateFormatExceptionMessage = 'The PodeState file "{0}" contains an invalid format. Expected a dictionary-like structure (ConcurrentDictionary, Hashtable, or OrderedDictionary), but found [{1}]. Please verify the file content or reinitialize the state.' + unknownJsonDictionaryTypeExceptionMessage = 'Unknown dictionary/collection type in JSON: {0}' + invalidPodeStateDataExceptionMessage = 'The provided data does not represent a valid Pode state.' + podeStateVersionMismatchExceptionMessage = 'The provided state data originates from a newer Pode version: {0}.' + podeStateApplicationMismatchExceptionMessage = 'The provided state data belongs to a different application: {0}.' } \ No newline at end of file diff --git a/src/Locales/es/Pode.psd1 b/src/Locales/es/Pode.psd1 index d7db64e6c..ea7531bc8 100644 --- a/src/Locales/es/Pode.psd1 +++ b/src/Locales/es/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regla de límite de velocidad con el nombre '{0}' no existe." accessLimitRuleAlreadyExistsExceptionMessage = "La regla de límite de acceso con el nombre '{0}' ya existe." accessLimitRuleDoesNotExistExceptionMessage = "La regla de límite de acceso con el nombre '{0}' no existe." + invalidPodeStateFormatExceptionMessage = 'El archivo PodeState "{0}" tiene un formato no válido. Se esperaba una estructura similar a un diccionario (ConcurrentDictionary, Hashtable o OrderedDictionary), pero se encontró [{1}]. Verifique el contenido del archivo o reinicialice el estado.' + unknownJsonDictionaryTypeExceptionMessage = 'Tipo de diccionario/colección desconocido en JSON: {0}' + invalidPodeStateDataExceptionMessage = 'Los datos proporcionados no representan un estado válido de Pode.' + podeStateVersionMismatchExceptionMessage = 'Los datos del estado proporcionados provienen de una versión más reciente de Pode: {0}.' + podeStateApplicationMismatchExceptionMessage = 'Los datos del estado proporcionados pertenecen a otra aplicación: {0}.' } \ No newline at end of file diff --git a/src/Locales/fr/Pode.psd1 b/src/Locales/fr/Pode.psd1 index a0d769eab..f0d704d92 100644 --- a/src/Locales/fr/Pode.psd1 +++ b/src/Locales/fr/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "La règle de limite de taux '{0}' n'existe pas." accessLimitRuleAlreadyExistsExceptionMessage = "Une règle de limite d'accès nommée '{0}' existe déjà." accessLimitRuleDoesNotExistExceptionMessage = "La règle de limite d'accès '{0}' n'existe pas." + invalidPodeStateFormatExceptionMessage = 'Il file PodeState "{0}" contiene un formato non valido. Era prevista una struttura simile a un dizionario (ConcurrentDictionary, Hashtable o OrderedDictionary), ma è stato trovato [{1}]. Verifica il contenuto del file o reinizializza lo stato.' + unknownJsonDictionaryTypeExceptionMessage = 'Type de dictionnaire/collection inconnu dans le JSON : {0}' + invalidPodeStateDataExceptionMessage = 'Les données fournies ne représentent pas un état valide de Pode.' + podeStateVersionMismatchExceptionMessage = "Les données d'état fournies proviennent d'une version plus récente de Pode : {0}." + podeStateApplicationMismatchExceptionMessage = "Les données d'état fournies appartiennent à une autre application : { 0 }." } \ No newline at end of file diff --git a/src/Locales/it/Pode.psd1 b/src/Locales/it/Pode.psd1 index 5dd3c47f4..2de825473 100644 --- a/src/Locales/it/Pode.psd1 +++ b/src/Locales/it/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione del tasso con il nome '{0}' non esiste." accessLimitRuleAlreadyExistsExceptionMessage = "Una regola di limitazione dell'accesso con il nome '{0}' esiste già." accessLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione dell'accesso con il nome '{0}' non esiste." + invalidPodeStateFormatExceptionMessage = 'Il file PodeState "{0}" contiene un formato non valido. Era prevista una struttura simile a un dizionario (ConcurrentDictionary, Hashtable o OrderedDictionary), ma è stato trovato [{1}]. Verifica il contenuto del file o reinizializza lo stato.' + unknownJsonDictionaryTypeExceptionMessage = 'Tipo di dizionario/collezione sconosciuto in JSON: {0}' + invalidPodeStateDataExceptionMessage = 'I dati forniti non rappresentano uno stato valido di Pode.' + podeStateVersionMismatchExceptionMessage = 'I dati di stato forniti provengono da una versione più recente di Pode: {0}.' + podeStateApplicationMismatchExceptionMessage = "I dati di stato forniti appartengono a un'altra applicazione: { 0 }." } \ No newline at end of file diff --git a/src/Locales/ja/Pode.psd1 b/src/Locales/ja/Pode.psd1 index 43d8243bc..ae2bf038f 100644 --- a/src/Locales/ja/Pode.psd1 +++ b/src/Locales/ja/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のレート制限ルールは存在しません。" accessLimitRuleAlreadyExistsExceptionMessage = "名前が '{0}' のアクセス制限ルールは既に存在します。" accessLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のアクセス制限ルールは存在しません。" + invalidPodeStateFormatExceptionMessage = 'PodeState ファイル "{0}" の形式が無効です。辞書のような構造 (ConcurrentDictionary、Hashtable、OrderedDictionary) が期待されましたが、[{1}] が見つかりました。ファイルの内容を確認するか、状態を再初期化してください。' + unknownJsonDictionaryTypeExceptionMessage = 'JSON 内の不明な辞書/コレクション型: {0}' + invalidPodeStateDataExceptionMessage = '提供されたデータは有効なPodeの状態ではありません。' + podeStateVersionMismatchExceptionMessage = '提供された状態データは、新しいバージョンのPode ({0}) からのものです。' + podeStateApplicationMismatchExceptionMessage = '提供された状態データは、別のアプリケーション ({0}) のものです。' } \ No newline at end of file diff --git a/src/Locales/ko/Pode.psd1 b/src/Locales/ko/Pode.psd1 index a0d882588..8dc6a7be5 100644 --- a/src/Locales/ko/Pode.psd1 +++ b/src/Locales/ko/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 비율 제한 규칙이 존재하지 않습니다." accessLimitRuleAlreadyExistsExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 이미 존재합니다." accessLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 존재하지 않습니다." + invalidPodeStateFormatExceptionMessage = 'Het PodeState-bestand "{0}" bevat een ongeldig formaat. Verwacht werd een dictionary-achtige structuur (ConcurrentDictionary, Hashtable of OrderedDictionary), maar gevonden werd [{1}]. Controleer de inhoud van het bestand of initialiseert u de status opnieuw.' + unknownJsonDictionaryTypeExceptionMessage = 'JSON에서 알 수 없는 사전/컬렉션 유형: {0}' + invalidPodeStateDataExceptionMessage = '제공된 데이터는 유효한 Pode 상태 데이터가 아닙니다.' + podeStateVersionMismatchExceptionMessage = '제공된 상태 데이터는 최신 Pode 버전 ({0}) 에서 생성되었습니다.' + podeStateApplicationMismatchExceptionMessage = '제공된 상태 데이터는 다른 애플리케이션 ({0}) 에서 생성되었습니다.' } \ No newline at end of file diff --git a/src/Locales/nl/Pode.psd1 b/src/Locales/nl/Pode.psd1 index 6aad6ebc4..eb0b5522b 100644 --- a/src/Locales/nl/Pode.psd1 +++ b/src/Locales/nl/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "Rate Limit-regel met de naam '{0}' bestaat niet." accessLimitRuleAlreadyExistsExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat al." accessLimitRuleDoesNotExistExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat niet." + invalidPodeStateFormatExceptionMessage = 'Plik PodeState "{0}" ma nieprawidłowy format. Oczekiwano struktury podobnej do słownika (ConcurrentDictionary, Hashtable lub OrderedDictionary), ale znaleziono [{1}]. Sprawdź zawartość pliku lub ponownie zainicjalizuj stan.' + unknownJsonDictionaryTypeExceptionMessage = 'Onbekend woordenboek-/collectietype in JSON: {0}' + invalidPodeStateDataExceptionMessage = 'De opgegeven gegevens vertegenwoordigen geen geldige Pode-status.' + podeStateVersionMismatchExceptionMessage = 'De opgegeven statusgegevens zijn afkomstig van een nieuwere versie van Pode: {0}.' + podeStateApplicationMismatchExceptionMessage = 'De opgegeven statusgegevens behoren tot een andere applicatie: {0}.' } \ No newline at end of file diff --git a/src/Locales/pl/Pode.psd1 b/src/Locales/pl/Pode.psd1 index cf5dd507b..5292410af 100644 --- a/src/Locales/pl/Pode.psd1 +++ b/src/Locales/pl/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "Reguła limitu szybkości o nazwie '{0}' nie istnieje." accessLimitRuleAlreadyExistsExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' już istnieje." accessLimitRuleDoesNotExistExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' nie istnieje." + invalidPodeStateFormatExceptionMessage = 'Plik PodeState "{0}" ma nieprawidłowy format. Oczekiwano struktury podobnej do słownika (ConcurrentDictionary, Hashtable lub OrderedDictionary), ale znaleziono [{1}]. Sprawdź zawartość pliku lub ponownie zainicjalizuj stan.' + unknownJsonDictionaryTypeExceptionMessage = 'Nieznany typ słownika/kolekcji w JSON: {0}' + invalidPodeStateDataExceptionMessage = 'Podane dane nie są poprawnym stanem Pode.' + podeStateVersionMismatchExceptionMessage = 'Podane dane stanu pochodzą z nowszej wersji Pode: {0}.' + podeStateApplicationMismatchExceptionMessage = 'Podane dane stanu należą do innej aplikacji: {0}.' } \ No newline at end of file diff --git a/src/Locales/pt/Pode.psd1 b/src/Locales/pt/Pode.psd1 index b8e1e4cd1..af35ad6de 100644 --- a/src/Locales/pt/Pode.psd1 +++ b/src/Locales/pt/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = "A regra de limite de taxa com o nome '{0}' não existe." accessLimitRuleAlreadyExistsExceptionMessage = "A regra de limite de acesso com o nome '{0}' já existe." accessLimitRuleDoesNotExistExceptionMessage = "A regra de limite de acesso com o nome '{0}' não existe." + invalidPodeStateFormatExceptionMessage = 'O arquivo PodeState "{0}" contém um formato inválido. Era esperado uma estrutura semelhante a um dicionário (ConcurrentDictionary, Hashtable ou OrderedDictionary), mas foi encontrado [{1}]. Verifique o conteúdo do arquivo ou reinicialize o estado.' + unknownJsonDictionaryTypeExceptionMessage = 'Tipo de dicionário/coleção desconhecido no JSON: {0}' + invalidPodeStateDataExceptionMessage = 'Os dados fornecidos não representam um estado válido do Pode.' + podeStateVersionMismatchExceptionMessage = 'Os dados de estado fornecidos são de uma versão mais recente do Pode: {0}.' + podeStateApplicationMismatchExceptionMessage = 'Os dados de estado fornecidos pertencem a outra aplicação: {0}.' } \ No newline at end of file diff --git a/src/Locales/zh/Pode.psd1 b/src/Locales/zh/Pode.psd1 index 3653dc151..2cb1bd87f 100644 --- a/src/Locales/zh/Pode.psd1 +++ b/src/Locales/zh/Pode.psd1 @@ -326,4 +326,9 @@ rateLimitRuleDoesNotExistExceptionMessage = '速率限制规则不存在: {0}' accessLimitRuleAlreadyExistsExceptionMessage = '访问限制规则已存在: {0}' accessLimitRuleDoesNotExistExceptionMessage = '访问限制规则不存在: {0}' + invalidPodeStateFormatExceptionMessage = 'PodeState 文件 "{0}" 的格式无效。预期为类似字典的结构 (ConcurrentDictionary、Hashtable 或 OrderedDictionary),但发现 [{1}]。请验证文件内容或重新初始化状态。' + unknownJsonDictionaryTypeExceptionMessage = 'JSON 中的未知字典/集合类型: {0}' + invalidPodeStateDataExceptionMessage = '提供的数据不是有效的 Pode 状态数据。' + podeStateVersionMismatchExceptionMessage = '提供的状态数据来自较新版本的 Pode:{0}。' + podeStateApplicationMismatchExceptionMessage = '提供的状态数据属于另一个应用程序:{0}。' } \ No newline at end of file diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index a4f4f9d98..70b8b6072 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -97,6 +97,7 @@ function New-PodeContext { $ctx.Server.PodeModule = (Get-PodeModuleInfo) $ctx.Server.Console = $Console $ctx.Server.ComputerName = [System.Net.DNS]::GetHostName() + $ctx.Server.ApplicationName = (Get-PodeApplicationName) # list of created listeners/receivers $ctx.Listeners = @() @@ -343,7 +344,7 @@ function New-PodeContext { $ctx.Server.InbuiltDrives = @{} # shared state between runspaces - $ctx.Server.State = @{} + $ctx.Server.State = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) # setup caching $ctx.Server.Cache = @{ diff --git a/src/Private/Convert.ps1 b/src/Private/Convert.ps1 new file mode 100644 index 000000000..3e944aa6c --- /dev/null +++ b/src/Private/Convert.ps1 @@ -0,0 +1,455 @@ + +<# +.SYNOPSIS + Deserializes JSON from ConvertTo-PodeCustomDictionaryJson (nested) back into + the original dictionary/collection type (Hashtable, ConcurrentDictionary, OrderedDictionary, + ConcurrentBag, ConcurrentQueue, ConcurrentStack, or PSCustomObject with PsTypeName). + +.DESCRIPTION + Recursively reads the JSON, checks the "Type" property, and reconstructs + the corresponding dictionary/collection. Also handles arrays, PSCustomObjects, and primitive types. + +.PARAMETER Json + A JSON string containing "Type" and "Items" at each dictionary/collection level. + +.OUTPUTS + - [Hashtable] + - [System.Collections.Concurrent.ConcurrentDictionary[string, object]] + - [System.Collections.Specialized.OrderedDictionary] + - [System.Collections.Concurrent.ConcurrentBag[object]] + - [System.Collections.Concurrent.ConcurrentQueue[object]] + - [System.Collections.Concurrent.ConcurrentStack[object]] + - [PSCustomObject] (when applicable, with preserved PsTypeName) + - Arrays, primitives, or other structures. + +.NOTES + This function is for internal Pode usage and may be subject to change. +#> +function ConvertFrom-PodeCustomDictionaryJson { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$Json + ) + + function Construct { + param([object]$obj) + + <# Handle Null Values #> + if ($null -eq $obj) { + return $null + } + + <# Handle Arrays/Lists #> + if ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) { + $resultList = @() + foreach ($item in $obj) { + $resultList += Construct $item + } + return $resultList + } + + <# Handle PSCustomObject (Check for "Type" Property) #> + if ($obj -is [PSCustomObject]) { + if ($obj.PSObject.Properties.Name -contains 'Type') { + # Reconstruct Dictionaries & Collections + switch ($obj.Type) { + 'Hashtable' { + $dict = @{} + foreach ($pair in $obj.Items) { + $dict[$pair.Key] = (Construct -obj $pair.Value) + } + return $dict + } + 'ConcurrentDictionary' { + $dict = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($pair in $obj.Items) { + $null = $dict.TryAdd($pair.Key, (Construct -obj $pair.Value)) + } + return $dict + } + 'OrderedDictionary' { + $dict = [ordered]@{} + foreach ($pair in $obj.Items) { + $dict[$pair.Key] = (Construct -obj $pair.Value) + } + return $dict + } + 'ConcurrentBag' { + # Rebuild a ConcurrentBag[object] + $bag = [System.Collections.Concurrent.ConcurrentBag[object]]::new() + foreach ($item in $obj.Items) { + $bag.Add((Construct -obj $item)) + } + return , $bag # Prepend with a comma to return it as an object, not Object[] + } + 'ConcurrentQueue' { + # Rebuild a ConcurrentQueue[object] + $queue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new() + foreach ($item in $obj.Items) { + $queue.Enqueue((Construct -obj $item)) + } + return $queue + } + 'ConcurrentStack' { + # Rebuild a ConcurrentStack[object] + $stack = [System.Collections.Concurrent.ConcurrentStack[object]]::new() + foreach ($item in $obj.Items) { + $stack.Push((Construct -obj $item)) + } + return $stack + } + default { + throw ($PodeLocale.unknownJsonDictionaryTypeExceptionMessage -f $obj.Type) + } + } + } + else { + <# Preserve PSCustomObject Instead of Converting to Hashtable #> + $restoredObject = [PSCustomObject]@{} + + <# Add Other Properties #> + $properties = $obj | Get-Member -MemberType NoteProperty, AliasProperty, ScriptProperty + foreach ($prop in $properties) { + if ($prop.Name -ne '__PsTypeName__') { + $restoredObject | Add-Member -MemberType NoteProperty -Name $prop.Name -Value (Construct($obj.$($prop.Name))) -Force + } + else { + if ( $obj.$($prop.Name) -ne 'System.Management.Automation.PSCustomObject') { + $restoredObject.PSTypeNames.Insert(0, $obj.$($prop.Name)) + } + } + } + + return $restoredObject + } + } + + <# Return Primitive Values as-is #> + return $obj + } + + + # Parse the top-level JSON into a PSObject/Array + $parsed = $Json | ConvertFrom-Json + if ($parsed.Metadata) { + if ($parsed.Metadata.Product -ne 'Pode') { + # 'The provided data does not represent a valid Pode state.' + throw $PodeLocale.invalidPodeStateDataExceptionMessage + } + $podeVersion = (Get-PodeVersion -Raw) + if (!($podeVersion -eq '[dev]' -or ( ([System.Version]$parsed.Metadata) -le ([System.Version]$podeVersion))) ) { + # The provided state data originates from a newer Pode version: + throw ($PodeLocale.podeStateVersionMismatchExceptionMessage -f $parsed.Metadata) + } + if ($parsed.Metadata.Application -ne ($PodeContext.Server.ApplicationName)) { + # The provided state data belongs to a different application + throw ($PodeLocale.podeStateApplicationMismatchExceptionMessage -f $parsed.Metadata.Application) + } + + <# Rebuild the Full Structure from JSON #> + return Construct -obj $parsed.Data + } + else { + return ConvertTo-PodeHashtable -InputObject $parsed + } +} + + +<# +.SYNOPSIS + Serializes specialized PowerShell/Concurrent collections to JSON, preserving type info. + +.DESCRIPTION + This function checks the .NET type of the supplied object. If it's a [Hashtable], + [OrderedDictionary], [ConcurrentDictionary], [ConcurrentBag], [ConcurrentQueue], or [ConcurrentStack], + it serializes the data in a structured format with a "Type" property. Arrays and custom objects + are similarly processed recursively. + +.PARAMETER Dictionary + The object/collection to serialize. + +.PARAMETER Depth + Specifies how many levels of contained objects should be included in the JSON. Default is 10. + +.PARAMETER Compress + If supplied, the output JSON will be condensed (no extra whitespace). + +.OUTPUTS + [string] (JSON) + +.NOTES + This function is for internal Pode usage and may be subject to change. +#> +function ConvertTo-PodeCustomDictionaryJson { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$Dictionary, + + [Parameter()] + [int16] + $Depth = 20, + + [switch] + $Compress + ) + + # Nested helper to recursively deconstruct objects + function Deconstruct { + param([Parameter()] $Object) + + if ($null -eq $Object) { + return $null # Return null without modification + } + + # Return common primitives directly + if ($Object.PSObject.BaseObject.GetType().IsPrimitive -or + $Object -is [string] -or + $Object -is [datetime]) { + return $Object + } + + <# Handle PSCustomObject (Preserve PsTypeName) #> + if ($Object -is [PSCustomObject]) { + $serializedObject = [ordered]@{} + if ($Object.PSTypeNames.Count -gt 0) { + $serializedObject['__PsTypeName__'] = $Object.PSTypeNames[0] # Preserve the first PsTypeName + } + foreach ($prop in $Object.PSObject.Properties) { + $serializedObject[$prop.Name] = Deconstruct($prop.Value) + } + return $serializedObject + } + + # Handle OrderedDictionary + if ($Object -is [System.Collections.Specialized.OrderedDictionary]) { + $wrapper = [PSCustomObject]@{ Type = 'OrderedDictionary'; Items = @() } + foreach ($key in $Object.Keys) { + $wrapper.Items += [PSCustomObject]@{ + Key = $key + Value = Deconstruct($Object[$key]) + } + } + return $wrapper + } + + # Handle Hashtable + if ($Object -is [System.Collections.Hashtable]) { + $wrapper = [PSCustomObject]@{ Type = 'Hashtable'; Items = @() } + foreach ($key in $Object.Keys) { + $wrapper.Items += [PSCustomObject]@{ + Key = $key + Value = Deconstruct($Object[$key]) + } + } + return $wrapper + } + + # Handle ConcurrentDictionary + if ($Object -is [System.Collections.Concurrent.ConcurrentDictionary[string, object]]) { + $wrapper = [PSCustomObject]@{ Type = 'ConcurrentDictionary'; Items = @() } + foreach ($key in $Object.Keys) { + $wrapper.Items += [PSCustomObject]@{ + Key = $key + Value = Deconstruct($Object[$key]) + } + } + return $wrapper + } + + # Handle ConcurrentBag[object] + if ($Object -is [System.Collections.Concurrent.ConcurrentBag[object]]) { + $wrapper = [PSCustomObject]@{ Type = 'ConcurrentBag'; Items = @() } + foreach ($item in $Object) { + $wrapper.Items += Deconstruct($item) + } + return $wrapper + } + + # Handle ConcurrentQueue[object] + if ($Object -is [System.Collections.Concurrent.ConcurrentQueue[object]]) { + $wrapper = [PSCustomObject]@{ Type = 'ConcurrentQueue'; Items = @() } + foreach ($item in $Object) { + $wrapper.Items += Deconstruct($item) + } + return $wrapper + } + + # Handle ConcurrentStack[object] + if ($Object -is [System.Collections.Concurrent.ConcurrentStack[object]]) { + $wrapper = [PSCustomObject]@{ Type = 'ConcurrentStack'; Items = @() } + foreach ($item in $Object) { + $wrapper.Items += Deconstruct($item) + } + return $wrapper + } + + # If it's a list/array, process each item but return as array + if ($Object -is [System.Collections.IEnumerable] -and $Object -isnot [string]) { + if ($Object.Count -eq 0) { + return , @() + } + $convertedArray = @() + foreach ($item in $Object) { + $convertedArray += Deconstruct($item) + } + return $convertedArray + } + + # If it's a PSCustomObject, process each property individually + if ($Object -is [PSCustomObject]) { + $newObj = [ordered]@{} + $properties = $Object | Get-Member -MemberType NoteProperty, AliasProperty, ScriptProperty + foreach ($prop in $properties) { + $newObj[$prop.Name] = Deconstruct($Object.$($prop.Name)) + } + return $newObj + } + + # Fallback: Return object as-is (any other primitive or type) + return $Object + } + + $converted = [ordered]@{ + Metadata = [ordered]@{ + Product = 'Pode' + Version = Get-PodeVersion + Timestamp = Get-PodeUtcNow + Application = $PodeContext.Server.ApplicationName + } + Data = @{} + } + # If top-level is null, treat as an empty dictionary + if ($null -ne $Dictionary) { + # Recursively convert any nested structures + $converted.Data = Deconstruct -Object $Dictionary + } + + # Finally convert to JSON + return $converted | ConvertTo-Json -Depth $Depth -Compress:$Compress +} + +<# +.SYNOPSIS + Converts a PSCustomObject or nested object structure into a hashtable. + +.DESCRIPTION + The `ConvertTo-PodeHashtable` function recursively converts a PowerShell `PSCustomObject` + into a hashtable while preserving the original data structure. It ensures that objects, + arrays, and collections are properly transformed, while primitive types such as numbers, + booleans, and strings remain unchanged. Optionally, it can create an ordered hashtable. + +.PARAMETER InputObject + Specifies the input object to convert. The function can accept: + - A `PSCustomObject`, which will be transformed into a hashtable. + - A collection (`Array`, `List`), which will be processed recursively. + - A primitive type (`String`, `Number`, `Boolean`), which will remain unchanged. + +.PARAMETER Ordered + If specified, the resulting hashtable will be an ordered dictionary (`[ordered]@{}`), + preserving the order of properties as they appear in the original object. + +.OUTPUTS + [hashtable] + Returns a hashtable representation of the provided PSCustomObject. + +.EXAMPLE + $psCustomObject = [PSCustomObject]@{ + Name = "Pode" + Version = 2.0 + Active = $true + Metadata = [PSCustomObject]@{ + Author = "Pode Team" + Created = "2025-02-03" + Stats = [PSCustomObject]@{ + Users = 150 + Servers = 5 + } + } + Features = @("Fast", "Lightweight", "Modular") + } + + $hashtable = ConvertTo-PodeHashtable -InputObject $psCustomObject + $hashtable + +.EXAMPLE + # Convert a list of PSCustomObjects to an array of hashtables + $users = @( + [PSCustomObject]@{ ID = 1; Name = "Alice" } + [PSCustomObject]@{ ID = 2; Name = "Bob" } + ) + + $hashtableList = ConvertTo-PodeHashtable -InputObject $users + $hashtableList + +.EXAMPLE + # Using pipeline input + $users | ConvertTo-PodeHashtable + +.EXAMPLE + # Convert a PSCustomObject to an ordered hashtable + $orderedHashtable = ConvertTo-PodeHashtable -InputObject $psCustomObject -Ordered + $orderedHashtable + +.NOTES + - This function ensures deep conversion of nested PSCustomObjects while leaving primitive values intact. + - Collections (e.g., Arrays, Lists) are processed recursively, preserving structure. + - The `Ordered` switch allows for property order preservation in the resulting hashtable. + - This function is for internal Pode usage and may be subject to change. +#> +function ConvertTo-PodeHashtable { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [PSCustomObject]$InputObject, + [switch] + $Ordered + ) + begin { + # Define a recursive function within the process block + function Convert-ObjectRecursively { + param ( + [Parameter(Mandatory = $true)] + [System.Object] + $InputObject + ) + # Initialize an ordered dictionary + $hashtable = if ($Ordered) { [ordered]@{} }else { @{} } + + # Loop through each property of the PSCustomObject + foreach ($property in $InputObject.PSObject.Properties) { + # Check if the property value is a PSCustomObject + if ($property.Value -is [PSCustomObject]) { + # Recursively convert the nested PSCustomObject + $hashtable[$property.Name] = Convert-ObjectRecursively -InputObject $property.Value + } + elseif ($property.Value -is [System.Collections.IEnumerable] -and -not ($property.Value -is [string])) { + # If the value is a collection, check each element + $convertedCollection = @() + foreach ($item in $property.Value) { + if ($item -is [PSCustomObject]) { + $convertedCollection += Convert-ObjectRecursively -InputObject $item + } + else { + $convertedCollection += $item + } + } + $hashtable[$property.Name] = $convertedCollection + } + else { + # Add the property name and value to the ordered hashtable + $hashtable[$property.Name] = $property.Value + } + } + + # Return the resulting ordered hashtable + return $hashtable + } + } + process { + # Call the recursive helper function for each input object + Convert-ObjectRecursively -InputObject $InputObject + } +} \ No newline at end of file diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index b2a32e66c..15925c209 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -3942,6 +3942,77 @@ function Test-PodeIsISEHost { return ((Test-PodeIsWindows) -and ('Windows PowerShell ISE Host' -eq $Host.Name)) } +<# +.SYNOPSIS + Retrieves the name of the main Pode application script. + +.DESCRIPTION + The `Get-PodeApplicationName` function determines the name of the primary script (`.ps1`) + that started execution. It does this by examining the PowerShell call stack and + extracting the first script file that appears. + + If no script file is found in the call stack, the function returns `"NoName"`. + +.OUTPUTS + [string] + Returns the filename of the main application script, or `"NoName"` if no script is found. + +.EXAMPLE + Get-PodeApplicationName + + This retrieves the name of the main script that launched the Pode application. + +.EXAMPLE + $AppName = Get-PodeApplicationName + Write-Host "Application Name: $AppName" + + This stores the retrieved application name in a variable and prints it. + +.NOTES + - This function relies on `Get-PSCallStack`, meaning it must be run within a script execution context. + - If called interactively or if no `.ps1` script is in the call stack, it will return `"NoName"`. + - This is an internal function and may change in future releases of Pode. +#> +function Get-PodeApplicationName { + $scriptFrame = (Get-PSCallStack | Where-Object { $_.Command -match '\.ps1$' } | Select-Object -First 1) + if ($scriptFrame) { + return [System.IO.Path]::GetFileName($scriptFrame.Command) + } + else { + return 'NoName' + } +} + + +<# +.SYNOPSIS + Returns the current date and time in UTC format. + +.DESCRIPTION + This function retrieves the current date and time in Coordinated Universal Time (UTC), ensuring consistency across different time zones. + +.OUTPUTS + [DateTime] - The current UTC date and time. + +.EXAMPLE + Get-PodeUtcNow + + Returns the current UTC datetime. + +.NOTES + - This function is required to allow Pester test to mock it + - This function is for internal Pode usage and may be subject to change. +#> +function Get-PodeUtcNow { + [CmdletBinding()] + [OutputType([System.DateTime])] + param () + + process { + return [System.DateTime]::UtcNow + } +} + <# .SYNOPSIS Creates aliases for Pode OpenAPI functions to support legacy naming conventions. @@ -3952,7 +4023,7 @@ function Test-PodeIsISEHost { - Enable-PodeOA as an alias for Enable-PodeOpenApi. - Get-PodeOpenApiDefinition as an alias for Get-PodeOADefinition. The function helps maintain backward compatibility and simplifies calling Pode OpenAPI functions. - + .PARAMETER None This function does not accept any parameters. .OUTPUTS diff --git a/src/Private/ScopedVariables.ps1 b/src/Private/ScopedVariables.ps1 index 2991e13e3..cfcf32e80 100644 --- a/src/Private/ScopedVariables.ps1 +++ b/src/Private/ScopedVariables.ps1 @@ -76,7 +76,7 @@ function Add-PodeScopedVariableInbuiltSession { function Add-PodeScopedVariableInbuiltState { Add-PodeScopedVariable -Name 'state' ` -SetReplace "Set-PodeState -Name '{{name}}' -Value " ` - -GetReplace "`$PodeContext.Server.State.'{{name}}'.Value" + -GetReplace "`$PodeContext.Server.State['{{name}}'].Value" } function Add-PodeScopedVariableInbuiltUsing { diff --git a/src/Public/Core.ps1 b/src/Public/Core.ps1 index 5d7054bd7..1c013f539 100644 --- a/src/Public/Core.ps1 +++ b/src/Public/Core.ps1 @@ -273,7 +273,7 @@ function Start-PodeServer { # Create main context object $PodeContext = New-PodeContext @ContextParams - + # Define parameter values with comments explaining each one $ConfigParameters = @{ DisableTermination = $DisableTermination # Disable termination of the Pode server from the console diff --git a/src/Public/State.ps1 b/src/Public/State.ps1 index 1c841e586..b4cef555e 100644 --- a/src/Public/State.ps1 +++ b/src/Public/State.ps1 @@ -1,40 +1,67 @@ <# .SYNOPSIS -Sets an object within the shared state. + Sets an object within the shared state. .DESCRIPTION -Sets an object within the shared state. + Sets an object within the shared state, allowing for the creation of different collection types, such as a Hashtable, ConcurrentDictionary, or other concurrent collections. .PARAMETER Name -The name of the state object. + The name of the state object. .PARAMETER Value -The value to set in the state. + The value to set in the state. If a collection type is specified using `-NewCollectionType`, this value is ignored. .PARAMETER Scope -An optional Scope for the state object, used when saving the state. + An optional scope for the state object, used when saving the state. + +.PARAMETER NewCollectionType + Specifies the type of collection to create. Supported options include: + - Hashtable + - ConcurrentDictionary + - OrderedDictionary + - ConcurrentBag + - ConcurrentQueue + - ConcurrentStack + + If this parameter is used, the state object will be initialized as the specified collection type. + +.EXAMPLE + Set-PodeState -Name 'Data' -Value @{ 'Name' = 'Rick Sanchez' } + +.EXAMPLE + Set-PodeState -Name 'Users' -Value @('user1', 'user2') -Scope General, Users .EXAMPLE -Set-PodeState -Name 'Data' -Value @{ 'Name' = 'Rick Sanchez' } + Set-PodeState -Name 'Cache' -NewCollectionType 'ConcurrentDictionary' .EXAMPLE -Set-PodeState -Name 'Users' -Value @('user1', 'user2') -Scope General, Users + Set-PodeState -Name 'Tasks' -NewCollectionType 'ConcurrentQueue' + +.NOTES + - `NewCollectionType` and `Value` are mutually exclusive; only one can be used at a time. + - The function ensures thread safety when using concurrent collections. + - Pode must be initialized before calling this function. #> function Set-PodeState { - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Value')] [OutputType([object])] param( [Parameter(Mandatory = $true)] [string] $Name, - [Parameter(ValueFromPipeline = $true, Position = 0)] + [Parameter(ValueFromPipeline = $true, Position = 0, ParameterSetName = 'Value')] [object] $Value, [Parameter()] [string[]] - $Scope + $Scope, + + [Parameter(Mandatory = $true, ParameterSetName = 'Collection')] + [ValidateSet('Hashtable', 'ConcurrentDictionary', 'OrderedDictionary', 'ConcurrentBag', 'ConcurrentQueue', 'ConcurrentStack')] + [string] + $NewCollectionType ) begin { @@ -52,25 +79,38 @@ function Set-PodeState { } process { - # Add the current piped-in value to the array + # Collect piped-in values $pipelineValue += $_ } end { - # Set Value to the array of values + # If multiple values were piped in, store them as an array if ($pipelineValue.Count -gt 1) { $Value = $pipelineValue } - $PodeContext.Server.State[$Name] = @{ - Value = $Value - Scope = $Scope + # Initialize the state as a case-insensitive ConcurrentDictionary + $PodeContext.Server.State[$Name] = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + + # Create the specified collection type, or use the provided value + $PodeContext.Server.State[$Name].Value = switch ($NewCollectionType) { + 'Hashtable' { @{} } + 'ConcurrentDictionary' { [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } + 'OrderedDictionary' { [ordered]@{} } + 'ConcurrentBag' { [System.Collections.Concurrent.ConcurrentBag[object]]::new() } + 'ConcurrentQueue' { [System.Collections.Concurrent.ConcurrentQueue[object]]::new() } + 'ConcurrentStack' { [System.Collections.Concurrent.ConcurrentStack[object]]::new() } + default { $Value } # If no collection type is specified, use the provided value } - return $Value + # Store the scope for the state object + $PodeContext.Server.State[$Name].Scope = $Scope + + return $PodeContext.Server.State[$Name].Value } } + <# .SYNOPSIS Retrieves some state object from the shared state. @@ -144,7 +184,6 @@ function Get-PodeStateNames { ) if ($null -eq $PodeContext.Server.State) { - # Pode has not been initialized throw ($PodeLocale.podeNotInitializedExceptionMessage) } @@ -152,13 +191,16 @@ function Get-PodeStateNames { $Scope = @() } - $tempState = $PodeContext.Server.State.Clone() - $keys = $tempState.Keys + # Directly retrieve the keys from the ConcurrentDictionary + $keys = $PodeContext.Server.State.Keys if ($Scope.Length -gt 0) { $keys = @(foreach ($key in $keys) { - if ($tempState[$key].Scope -iin $Scope) { - $key + if ($PodeContext.Server.State.ContainsKey($key)) { + $scopeValue = $PodeContext.Server.State[$key]['Scope'] + if ($scopeValue -is [string] -and ($scopeValue -iin $Scope)) { + $key + } } }) } @@ -174,6 +216,7 @@ function Get-PodeStateNames { return $keys } + <# .SYNOPSIS Removes some state object from the shared state. @@ -189,56 +232,81 @@ Remove-PodeState -Name 'Data' #> function Remove-PodeState { [CmdletBinding()] - [OutputType([object])] param( [Parameter(Mandatory = $true)] [string] $Name ) - if ($null -eq $PodeContext.Server.State) { + if ($null -eq $PodeContext -or $null -eq $PodeContext.Server -or $null -eq $PodeContext.Server.State) { # Pode has not been initialized throw ($PodeLocale.podeNotInitializedExceptionMessage) } - $value = $PodeContext.Server.State[$Name].Value - $null = $PodeContext.Server.State.Remove($Name) - return $value + # ConcurrentDictionary requires TryRemove to remove and retrieve the value + $removedValue = $null + $removed = $PodeContext.Server.State.TryRemove($Name, [ref]$removedValue) + + if ($removed) { + return $removedValue.Value + } + + # If not removed (key didn't exist), return $null + return $null } <# .SYNOPSIS -Saves the current shared state to a supplied JSON file. + Saves the current Pode server state to a JSON file. .DESCRIPTION -Saves the current shared state to a supplied JSON file. When using this function, it's recommended to wrap it in a Lock-PodeObject block. + This function serializes the Pode state into a JSON file while preserving the structure + of dictionaries (`ConcurrentDictionary`, `Hashtable`, `OrderedDictionary`). It allows + filtering the saved state by scope, inclusion, or exclusion of specific keys. + + For thread safety, it is recommended to wrap this function inside a `Lock-PodeObject` block. .PARAMETER Path -The path to a JSON file which the current state will be saved to. + Specifies the file path where the state should be saved. .PARAMETER Scope -An optional array of scopes for state objects that should be saved. (This has a lower precedence than Exclude/Include) + Filters the state objects to be saved based on their scope. + Only state objects within the specified scope(s) will be included. + This filter has **lower precedence** than Exclude and Include. .PARAMETER Exclude -An optional array of state object names to exclude from being saved. (This has a higher precedence than Include) + Specifies state object names to **exclude** from being saved. + This filter has **higher precedence** than Include. .PARAMETER Include -An optional array of state object names to only include when being saved. + Specifies state object names to **only** include in the saved state. + This filter has **lower precedence** than Exclude. .PARAMETER Depth -Saved JSON maximum depth. Will be passed to ConvertTo-JSON's -Depth parameter. Default is 10. + Defines the maximum depth for JSON serialization. + This value is passed to `ConvertTo-PodeCustomDictionaryJson`. Default is **20**. .PARAMETER Compress -If supplied, the saved JSON will be compressed. + If specified, the JSON output will be minified (no extra whitespace). .EXAMPLE -Save-PodeState -Path './state.json' + Save-PodeState -Path './state.json' + Saves the entire Pode state to `state.json`. .EXAMPLE -Save-PodeState -Path './state.json' -Exclude Name1, Name2 + Save-PodeState -Path './state.json' -Exclude 'SessionData', 'UserCache' + Saves the Pode state but **excludes** the specified state keys. .EXAMPLE -Save-PodeState -Path './state.json' -Scope Users + Save-PodeState -Path './state.json' -Scope 'Users' + Saves **only** state objects that belong to the `"Users"` scope. + +.OUTPUTS + [System.Void] - This function does not return an output. The state is saved to a file. + +.NOTES + - This function is intended for internal Pode usage and may be subject to changes. + - For more information, refer to: https://github.com/Badgerati/Pode/tree/develop #> function Save-PodeState { [CmdletBinding()] @@ -261,158 +329,190 @@ function Save-PodeState { [Parameter()] [int16] - $Depth = 10, + $Depth = 20, [switch] $Compress ) - # error if attempting to use outside of the pode server - if ($null -eq $PodeContext.Server.State) { - # Pode has not been initialized + + # Validate Pode Server Context + + if ($null -eq $PodeContext -or + $null -eq $PodeContext.Server -or + $null -eq $PodeContext.Server.State) { throw ($PodeLocale.podeNotInitializedExceptionMessage) } - # get the full path to save the state + # Convert relative path to absolute $Path = Get-PodeRelativePath -Path $Path -JoinRoot - # contruct the state to save (excludes, etc) - $state = $PodeContext.Server.State.Clone() - # scopes - if (($null -ne $Scope) -and ($Scope.Length -gt 0)) { - foreach ($_key in $state.Clone().Keys) { - # remove if no scope - if (($null -eq $state[$_key].Scope) -or ($state[$_key].Scope.Length -eq 0)) { - $null = $state.Remove($_key) - continue - } + # Create a Shallow Copy of the Current State + + # A new ConcurrentDictionary is created to store a snapshot of the current state, + # preventing modifications while the state is being serialized. + $state = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($kvp in $PodeContext.Server.State.GetEnumerator()) { + $null = $state.TryAdd($kvp.Key, $kvp.Value) + } - # check scopes (only remove if none match) - $found = $false - foreach ($_scope in $state[$_key].Scope) { - if ($Scope -icontains $_scope) { - $found = $true - break + # Filter State by Scope + + if (($null -ne $Scope) -and ($Scope.Length -gt 0)) { + $keys = $state.Keys + foreach ($key in $keys) { + if ($state.ContainsKey($key)) { + $value = $state[$key] + + # Remove state objects that lack a scope + if (($null -eq $value.Scope) -or ($value.Scope.Count -eq 0)) { + $null = $state.TryRemove($key, [ref]$null) + continue } - } - if ($found) { - continue - } + # Remove objects that do not match the specified scope(s) + $found = $false + foreach ($item in $value.Scope) { + if ($Scope -icontains $item) { + $found = $true + break + } + } - # none matched, remove - $null = $state.Remove($_key) + if (!$found) { + $null = $state.TryRemove($key, [ref]$null) + } + } } } - # include keys + + # If Include is defined, only keep the specified keys if (($null -ne $Include) -and ($Include.Length -gt 0)) { - foreach ($_key in $state.Clone().Keys) { - if ($Include -inotcontains $_key) { - $null = $state.Remove($_key) + $keys = $state.Keys + foreach ($key in $keys) { + if ($Include -inotcontains $key) { + $null = $state.TryRemove($key, [ref]$null) } } } - # exclude keys + # If Exclude is defined, remove the specified keys from the state if (($null -ne $Exclude) -and ($Exclude.Length -gt 0)) { - foreach ($_key in $state.Clone().Keys) { - if ($Exclude -icontains $_key) { - $null = $state.Remove($_key) + $keys = $state.Keys + foreach ($key in $keys) { + if ($Exclude -icontains $key) { + $null = $state.TryRemove($key, [ref]$null) } } } - # save the state - $null = ConvertTo-Json -InputObject $state -Depth $Depth -Compress:$Compress | Out-File -FilePath $Path -Force + # The state is converted to JSON while preserving dictionary types (Hashtable, + # OrderedDictionary, ConcurrentDictionary). The Compress flag minifies output. + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $state -Depth $Depth -Compress:$Compress + $json | Out-File -FilePath $Path -Force } <# .SYNOPSIS -Restores the shared state from some JSON file. + Restores the Pode shared state from a JSON file. .DESCRIPTION -Restores the shared state from some JSON file. + This function reads a JSON file and restores the Pode server state. + It preserves dictionary types (ConcurrentDictionary, Hashtable, OrderedDictionary) + and ensures state integrity. If the file does not exist, the function exits silently. + + The function supports **merging** the restored state with the current Pode state or + **overwriting** it entirely. .PARAMETER Path -The path to a JSON file that contains the state information. + Specifies the JSON file path containing the saved state. .PARAMETER Merge -If supplied, the state loaded from the JSON file will be merged with the current state, instead of overwriting it. + If specified, the loaded state will be merged with the existing Pode state instead + of replacing it. .PARAMETER Depth -Saved JSON maximum depth. Will be passed to ConvertFrom-JSON's -Depth parameter (Powershell >=6). Default is 10. + Defines the maximum depth for JSON deserialization. + This value is passed to `ConvertFrom-PodeCustomDictionaryJson`. Default is **20**. + +.EXAMPLE + Restore-PodeState -Path './state.json' + Restores the Pode state from `state.json`, replacing the current state. .EXAMPLE -Restore-PodeState -Path './state.json' + Restore-PodeState -Path './state.json' -Merge + Merges the loaded state with the existing Pode state. + +.OUTPUTS + [System.Void] - The function updates `$PodeContext.Server.State` but does not return a value. + +.NOTES + - This function is intended for internal Pode usage and may be subject to changes. + - For more details, refer to: https://github.com/Badgerati/Pode/tree/develop #> function Restore-PodeState { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string] - $Path, + [string]$Path, - [switch] - $Merge, + [switch]$Merge, - [int16] - $Depth = 10 + [int16]$Depth = 20 ) - # error if attempting to use outside of the pode server - if ($null -eq $PodeContext.Server.State) { - # Pode has not been initialized + <# Validate Pode Server Context #> + if ($null -eq $PodeContext -or + $null -eq $PodeContext.Server -or + $null -eq $PodeContext.Server.State) { throw ($PodeLocale.podeNotInitializedExceptionMessage) } - # get the full path to the state + <# Resolve File Path and Check Existence #> $Path = Get-PodeRelativePath -Path $Path -JoinRoot if (!(Test-Path $Path)) { - return + return # Exit silently if the file does not exist } - # restore the state from file - $state = @{} - - if (Test-PodeIsPSCore) { - $state = (Get-Content $Path -Force | ConvertFrom-Json -AsHashtable -Depth $Depth) + <# Read and Deserialize JSON #> + $json = Get-Content -Path $Path -Raw -Force + if (![string]::IsNullOrWhiteSpace($json)) { + # Deserialize the JSON, preserving dictionary structures + $state = ConvertFrom-PodeCustomDictionaryJson -Json $json } else { - $props = (Get-Content $Path -Force | ConvertFrom-Json).psobject.properties - foreach ($prop in $props) { - $state[$prop.Name] = $prop.Value - } + return # Exit if the file is empty } - # check for no scopes, and add for backwards compat - $convert = $false - foreach ($_key in $state.Clone().Keys) { - if ($null -eq $state[$_key].Scope) { - $convert = $true - break - } - } + <# Ensure Backward Compatibility for Missing Scopes #> + # Older versions of Pode may not include scope properties in state objects. + foreach ($_key in $state.Keys) { + if ($_key) { + if ($null -eq $state[$_key].Scope) { + $state[$_key].Scope = @() - if ($convert) { - foreach ($_key in $state.Clone().Keys) { - $state[$_key] = @{ - Value = $state[$_key] - Scope = @() } } } - # set the scope to the main context - if ($Merge) { - foreach ($_key in $state.Clone().Keys) { - $PodeContext.Server.State[$_key] = $state[$_key] + + <# Validate and Apply the Restored State #> + if ($state -is [System.Collections.IDictionary]) { + if (! $Merge) { + # If not merging, clear the existing state before applying the restored data + $PodeContext.Server.State.Clear() + } + # Merge or replace each key in the state + foreach ($key in $state.Keys) { + $null = $PodeContext.Server.State.TryAdd($key, $state[$key]) } } else { - $PodeContext.Server.State = $state.Clone() + # Raise an error if the file format is invalid + throw ($PodeLocale.invalidPodeStateFormatExceptionMessage -f $Path, $state.GetType().FullName) } } @@ -444,4 +544,4 @@ function Test-PodeState { } return $PodeContext.Server.State.ContainsKey($Name) -} \ No newline at end of file +} diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index 64d21b677..fdc942f3f 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -1357,12 +1357,19 @@ function New-PodeCron { <# .SYNOPSIS Retrieves the version of the Pode module. + Retrieves the version of the Pode module. .DESCRIPTION The `Get-PodeVersion` function checks the version of the Pode module as specified in the module manifest. If the module version is **not** the placeholder value (`'$version$'`), it returns the actual version prefixed with `'v'`. If the module version **is** the placeholder value, indicating the development branch, it returns `"[dev]"`. + The `Get-PodeVersion` function checks the version of the Pode module as specified in the module manifest. + If the module version is **not** the placeholder value (`'$version$'`), it returns the actual version prefixed with `'v'`. + If the module version **is** the placeholder value, indicating the development branch, it returns `"[dev]"`. +.PARAMETER Raw + If specified, the function returns only the raw module version without the `'v'` prefix. + By default, the function formats the version as `'vX.Y.Z'` unless the module is in development mode. .PARAMETER Raw If specified, the function returns only the raw module version without the `'v'` prefix. By default, the function formats the version as `'vX.Y.Z'` unless the module is in development mode. @@ -1372,17 +1379,26 @@ function New-PodeCron { Returns a string representing the Pode module version in one of the following formats: - `"vX.Y.Z"` for a release version (e.g., `"v1.2.3"`). - `"[dev]"` for development versions. + System.String + Returns a string representing the Pode module version in one of the following formats: + - `"vX.Y.Z"` for a release version (e.g., `"v1.2.3"`). + - `"[dev]"` for development versions. .EXAMPLE PS> Get-PodeVersion Returns the Pode module version, e.g., `'v1.2.3'` for release versions or `"[dev]"` if in development. + PS> Get-PodeVersion + Returns the Pode module version, e.g., `'v1.2.3'` for release versions or `"[dev]"` if in development. .EXAMPLE PS> Get-PodeVersion -Raw Returns the raw version number, e.g., `'1.2.3'`, without the `'v'` prefix. + PS> Get-PodeVersion -Raw + Returns the raw version number, e.g., `'1.2.3'`, without the `'v'` prefix. .NOTES - If the module version is a placeholder (`'$version$'`), the function assumes it's running from the development branch. + - If the module version is a placeholder (`'$version$'`), the function assumes it's running from the development branch. #> function Get-PodeVersion { param ( diff --git a/tests/shared/TestHelper.ps1 b/tests/shared/TestHelper.ps1 index 38ccacbe2..2d4bc638b 100644 --- a/tests/shared/TestHelper.ps1 +++ b/tests/shared/TestHelper.ps1 @@ -47,3 +47,145 @@ function Import-PodeAssembly { Add-Type -LiteralPath (Join-Path -Path $netFolder -ChildPath 'Pode.dll') -ErrorAction Stop } } + + +function Compare-Hashtable { + param ( + [object]$Hashtable1, + [object]$Hashtable2 + ) + + # Function to compare two hashtable values + function Compare-Value($value1, $value2) { + # Check if both values are hashtables + if ((($value1 -is [hashtable] -or $value1 -is [System.Collections.Specialized.OrderedDictionary]) -and + ($value2 -is [hashtable] -or $value2 -is [System.Collections.Specialized.OrderedDictionary]))) { + return Compare-Hashtable -Hashtable1 $value1 -Hashtable2 $value2 + } + # Check if both values are arrays + elseif (($value1 -is [Object[]]) -and ($value2 -is [Object[]])) { + if ($value1.Count -ne $value2.Count) { + return $false + } + for ($i = 0; $i -lt $value1.Count; $i++) { + $found = $false + for ($j = 0; $j -lt $value2.Count; $j++) { + if ( Compare-Value $value1[$i] $value2[$j]) { + $found = $true + } + } + if ($found -eq $false) { + return $false + } + } + return $true + } + else { + if ($value1 -is [string] -and $value2 -is [string]) { + return Compare-StringRnLn $value1 $value2 + } + # Check if the values are equal + return $value1 -eq $value2 + } + } + + $keys1 = $Hashtable1.Keys + $keys2 = $Hashtable2.Keys + + # Check if both hashtables have the same keys + if ($keys1.Count -ne $keys2.Count) { + return $false + } + + foreach ($key in $keys1) { + if (! ($Hashtable2.Keys -contains $key)) { + return $false + } + + if ($Hashtable2[$key] -is [hashtable] -or $Hashtable2[$key] -is [System.Collections.Specialized.OrderedDictionary]) { + if (! (Compare-Hashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])) { + return $false + } + } + elseif (!(Compare-Value $Hashtable1[$key] $Hashtable2[$key])) { + return $false + } + } + + return $true +} + + +function Compare-StringRnLn { + param ( + [string]$InputString1, + [string]$InputString2 + ) + return ($InputString1.Trim() -replace "`r`n|`n|`r", "`n") -eq ($InputString2.Trim() -replace "`r`n|`n|`r", "`n") +} + +function Convert-PsCustomObjectToOrderedHashtable { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [PSCustomObject]$InputObject + ) + begin { + # Define a recursive function within the process block + function Convert-ObjectRecursively { + param ( + [Parameter(Mandatory = $true)] + [System.Object] + $InputObject + ) + + # Initialize an ordered dictionary + $orderedHashtable = [ordered]@{} + + # Loop through each property of the PSCustomObject + foreach ($property in $InputObject.PSObject.Properties) { + # Check if the property value is a PSCustomObject + if ($property.Value -is [PSCustomObject]) { + # Recursively convert the nested PSCustomObject + $orderedHashtable[$property.Name] = Convert-ObjectRecursively -InputObject $property.Value + } + elseif ($property.Value -is [System.Collections.IEnumerable] -and -not ($property.Value -is [string])) { + # If the value is a collection, check each element + $convertedCollection = @() + foreach ($item in $property.Value) { + if ($item -is [PSCustomObject]) { + $convertedCollection += Convert-ObjectRecursively -InputObject $item + } + else { + $convertedCollection += $item + } + } + $orderedHashtable[$property.Name] = $convertedCollection + } + else { + # Add the property name and value to the ordered hashtable + $orderedHashtable[$property.Name] = $property.Value + } + } + + # Return the resulting ordered hashtable + return $orderedHashtable + } + } + process { + # Call the recursive helper function for each input object + Convert-ObjectRecursively -InputObject $InputObject + } +} + +function Get-PodeModuleManifest { + param( + [string]$Src + ) + # Construct the path to the module manifest (.psd1 file) + $moduleManifestPath = Join-Path -Path $Src -ChildPath 'Pode.psd1' + + # Import the module manifest to access its properties + $moduleManifest = Import-PowerShellDataFile -Path $moduleManifestPath + return $moduleManifest +} \ No newline at end of file diff --git a/tests/unit/Convert.Tests.ps1 b/tests/unit/Convert.Tests.ps1 new file mode 100644 index 000000000..f51c60162 --- /dev/null +++ b/tests/unit/Convert.Tests.ps1 @@ -0,0 +1,130 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] +param() +BeforeAll { + Add-Type -AssemblyName 'System.Net.Http' -ErrorAction SilentlyContinue + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' + + $helperPath = (Split-Path -Parent -Path $path) -ireplace 'unit', 'shared' + . "$helperPath/TestHelper.ps1" + + # Import the module manifest to access its properties + $PodeManifest = Get-PodeModuleManifest -Src $src +} + +Describe 'ConvertFrom-PodeCustomDictionaryJson' { + BeforeAll { + $PodeContext = @{Server = @{ ApplicationName = 'Pester' } } + } + It 'Should correctly deserialize a Hashtable' { + $json = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:30:24.6971206Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"Foo","Value":"Bar"},{"Key":"Baz","Value":42}]}}' + $result = ConvertFrom-PodeCustomDictionaryJson -Json $json + + $result | Should -BeOfType Hashtable + $result['Foo'] | Should -Be 'Bar' + $result['Baz'] | Should -Be 42 + } + + It 'Should correctly deserialize a ConcurrentDictionary' { + $json = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:30:24.6971206Z","Application":"Pester"},"Data":{"Type":"ConcurrentDictionary","Items":[{"Key":"Key1","Value":123},{"Key":"Key2","Value":"Test"}]}}' + $result = ConvertFrom-PodeCustomDictionaryJson -Json $json + + $result | Should -BeOfType 'System.Collections.Concurrent.ConcurrentDictionary[string, object]' + $result.ContainsKey('Key1') | Should -BeTrue + $result['Key1'] | Should -Be 123 + $result['Key2'] | Should -Be 'Test' + } + + It 'Should correctly deserialize an OrderedDictionary' { + $json = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:30:24.6971206Z","Application":"Pester"},"Data":{"Type":"OrderedDictionary","Items":[{"Key":"First","Value":1},{"Key":"Second","Value":2}]}}' + $result = ConvertFrom-PodeCustomDictionaryJson -Json $json + + $result | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' + $result['First'] | Should -Be 1 + $result['Second'] | Should -Be 2 + } + + It 'Should correctly deserialize a ConcurrentBag' { + $json = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:30:24.6971206Z","Application":"Pester"},"Data":{"Type":"ConcurrentBag","Items":["Item1","Item2","Item3"]}}' + $result = ConvertFrom-PodeCustomDictionaryJson -Json $json + + $result.GetType().Name | Should -Be 'ConcurrentBag`1' + $result.Count | Should -Be 3 + } + + It 'Should correctly deserialize a PSCustomObject' { + $json = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:30:24.6971206Z","Application":"Pester"},"Data":{"Name":"John","Age":30,"__PsTypeName__":"CustomType"}}' + $result = ConvertFrom-PodeCustomDictionaryJson -Json $json + + $result | Should -BeOfType PSCustomObject + $result.Name | Should -Be 'John' + $result.Age | Should -Be 30 + $result.PSTypeNames[0] | Should -Be 'CustomType' + } + + It 'Should correctly deserialize a recursively nested dictionary' { + $json = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:30:24.6971206Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"Level1","Value":{"Type":"OrderedDictionary","Items":[{"Key":"Level2","Value":{"Type":"Hashtable","Items":[{"Key":"Final","Value":"Reached"}]}}]}}]}}' + $result = ConvertFrom-PodeCustomDictionaryJson -Json $json + + $result | Should -BeOfType Hashtable + $result['Level1'] | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' + $result['Level1']['Level2'] | Should -BeOfType Hashtable + $result['Level1']['Level2']['Final'] | Should -Be 'Reached' + } +} + + +Describe 'ConvertTo-PodeCustomDictionaryJson' { + BeforeAll { + $PodeContext = @{Server = @{ ApplicationName = 'Pester' } } + mock Get-PodeUtcNow { '2025-02-04T01:54:30.6400033Z' } + } + It 'Should correctly serialize a recursively nested dictionary' { + $dictionary = @{ 'Level1' = @{ 'Level2' = @{ 'Final' = 'Reached' } } } + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"Level1","Value":{"Type":"Hashtable","Items":[{"Key":"Level2","Value":{"Type":"Hashtable","Items":[{"Key":"Final","Value":"Reached"}]}}]}}]}}' | + ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + Compare-Hashtable $json $expected | Should -BeTrue + } + + It 'Should correctly serialize a dictionary with multiple types' { + $dictionary = @{ 'String' = 'Test'; 'Number' = 123; 'Boolean' = $true; 'Array' = @(1, 2, 3) } + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"Array","Value":[1,2,3]},{"Key":"Boolean","Value":true},{"Key":"Number","Value":123},{"Key":"String","Value":"Test"}]}}' | + ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + + Compare-Hashtable $json $expected | Should -BeTrue + } + + It 'Should correctly serialize nested dictionaries and collections' { + $dictionary = @{ 'Dict' = @{ 'SubDict' = @{ 'Key' = 'Value' } }; 'List' = @(1, 2, @{ 'Nested' = 'Yes' }) } + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"List","Value":[1,2,{"Type":"Hashtable","Items":[{"Key":"Nested","Value":"Yes"}]}]},{"Key":"Dict","Value":{"Type":"Hashtable","Items":[{"Key":"SubDict","Value":{"Type":"Hashtable","Items":[{"Key":"Key","Value":"Value"}]}}]}}]}}' | + ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + + Compare-Hashtable $json $expected | Should -BeTrue + } + + It 'Should correctly serialize thread-safe collections' { + $concurrentDictionary = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + $concurrentDictionary['Key1'] = 'Value1' + $concurrentDictionary['Key2'] = 42 + + $concurrentBag = [System.Collections.Concurrent.ConcurrentBag[object]]::new() + $concurrentBag.Add('Item1') + $concurrentBag.Add('Item2') + + $dictionary = @{ 'ConcurrentDict' = $concurrentDictionary; 'ConcurrentBag' = $concurrentBag } + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"ConcurrentBag","Value":{"Type":"ConcurrentBag","Items":["Item2","Item1"]}},{"Key":"ConcurrentDict","Value":{"Type":"ConcurrentDictionary","Items":[{"Key":"Key1","Value":"Value1"},{"Key":"Key2","Value":42}]}}]}}' | + ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + + Compare-Hashtable $json $expected | Should -BeTrue + } +} + + + diff --git a/tests/unit/State.Tests.ps1 b/tests/unit/State.Tests.ps1 index f8c2ff61b..b3b7440b5 100644 --- a/tests/unit/State.Tests.ps1 +++ b/tests/unit/State.Tests.ps1 @@ -7,6 +7,12 @@ BeforeAll { Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' + $helperPath = (Split-Path -Parent -Path $path) -ireplace 'unit', 'shared' + . "$helperPath/TestHelper.ps1" + + # Import the module manifest to access its properties + $PodeManifest = Get-PodeModuleManifest -Src $src + $PodeContext = @{ 'Server' = $null; } } @@ -17,7 +23,7 @@ Describe 'Set-PodeState' { } It 'Sets and returns an object' { - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } $result = Set-PodeState -Name 'test' -Value 7 $result | Should -Be 7 @@ -26,11 +32,11 @@ Describe 'Set-PodeState' { } It 'Sets by pipe and returns an object array' { - $PodeContext.Server = @{ 'State' = @{} } - $result = @(7,3,4)|Set-PodeState -Name 'test' + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } + $result = @(7, 3, 4) | Set-PodeState -Name 'test' - $result | Should -Be @(7,3,4) - $PodeContext.Server.State['test'].Value | Should -Be @(7,3,4) + $result | Should -Be @(7, 3, 4) + $PodeContext.Server.State['test'].Value | Should -Be @(7, 3, 4) $PodeContext.Server.State['test'].Scope | Should -Be @() } } @@ -42,7 +48,7 @@ Describe 'Get-PodeState' { } It 'Gets an object from the state' { - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } Set-PodeState -Name 'test' -Value 8 Get-PodeState -Name 'test' | Should -Be 8 } @@ -55,7 +61,7 @@ Describe 'Remove-PodeState' { } It 'Removes an object from the state' { - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } Set-PodeState -Name 'test' -Value 8 Remove-PodeState -Name 'test' | Should -Be 8 $PodeContext.Server.State['test'] | Should -Be $null @@ -72,7 +78,7 @@ Describe 'Save-PodeState' { Mock Get-PodeRelativePath { return $Path } Mock Out-File {} - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } Set-PodeState -Name 'test' -Value 8 Save-PodeState -Path './state.json' @@ -83,7 +89,7 @@ Describe 'Save-PodeState' { Mock Get-PodeRelativePath { return $Path } Mock Out-File {} - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } Set-PodeState -Name 'test' -Value 8 Save-PodeState -Path './state.json' -Include 'test' @@ -94,7 +100,7 @@ Describe 'Save-PodeState' { Mock Get-PodeRelativePath { return $Path } Mock Out-File {} - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } Set-PodeState -Name 'test' -Value 8 Save-PodeState -Path './state.json' -Exclude 'test' @@ -111,9 +117,9 @@ Describe 'Restore-PodeState' { It 'Restores the state from file' { Mock Get-PodeRelativePath { return $Path } Mock Test-Path { return $true } - Mock Get-Content { return '{ "Name": "Morty" }' } + Mock Get-Content { return '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"ConcurrentDictionary","Items":[{"Key":"Name","Value":{"Type":"ConcurrentDictionary","Items":[{"Key":"Value","Value":"Morty"},{"Key":"Scope","Value":[]}]}}]}}' } - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase); ApplicationName = 'Pester' } Restore-PodeState -Path './state.json' Get-PodeState -Name 'Name' | Should -Be 'Morty' } @@ -126,14 +132,101 @@ Describe 'Test-PodeState' { } It 'Returns true for an object being in the state' { - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } Set-PodeState -Name 'test' -Value 8 Test-PodeState -Name 'test' | Should -Be $true } It 'Returns false for an object not being in the state' { - $PodeContext.Server = @{ 'State' = @{} } + $PodeContext.Server = @{ 'State' = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) } Set-PodeState -Name 'test' -Value 8 Test-PodeState -Name 'tests' | Should -Be $false } -} \ No newline at end of file +} + +# Get-PodeStateNames.Tests.ps1 +# Pester 5 test script for Get-PodeStateNames + +# If your function is in a separate file, dot-source it. Adjust the path as needed: +# . "$PSScriptRoot\..\Functions\Get-PodeStateNames.ps1" + +Describe 'Get-PodeStateNames' -Tags 'Unit', 'Pode' { + BeforeAll { + # Mocking up $PodeLocale and $PodeContext to simulate Pode's environment. + $PodeLocale = @{ + podeNotInitializedExceptionMessage = 'Pode has not been initialized.' + } + + $PodeContext = @{ + Server = @{ + State = $null + } + } + + } + + Context 'When PodeContext.Server.State is $null' { + It 'Throws an exception if state is null' { + { Get-PodeStateNames } | Should -Throw 'Pode has not been initialized.' + } + } + + Context 'When PodeContext.Server.State is a valid ConcurrentDictionary' { + BeforeEach { + # Initialize the thread-safe dictionary before each test + $PodeContext.Server.State = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + + # For each key, store another ConcurrentDictionary with "Scope" and "Data" + # Key1 -> { Scope = 'Test1'; Data = 'Value1' } + # Key2 -> { Scope = 'Test2'; Data = 'Value2' } + # SpecialKey -> { Scope = 'Test1'; Data = 'SpecialValue' } + + $cd1 = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + $cd1['Scope'] = 'Test1' + $cd1['Data'] = 'Value1' + $null = $PodeContext.Server.State.TryAdd('Key1', $cd1) + + $cd2 = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + $cd2['Scope'] = 'Test2' + $cd2['Data'] = 'Value2' + $null = $PodeContext.Server.State.TryAdd('Key2', $cd2) + + $cd3 = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + $cd3['Scope'] = 'Test1' + $cd3['Data'] = 'SpecialValue' + $null = $PodeContext.Server.State.TryAdd('SpecialKey', $cd3) + } + + It 'Returns all keys if no scope or pattern is specified' { + $keys = Get-PodeStateNames + $keys | Should -Contain 'Key1' + $keys | Should -Contain 'Key2' + $keys | Should -Contain 'SpecialKey' + $keys.Count | Should -Be 3 + } + + It 'Filters by scope correctly' { + $keys = Get-PodeStateNames -Scope 'Test1' + $keys.Count | Should -Be 2 + $keys | Should -Contain 'Key1' + $keys | Should -Contain 'SpecialKey' + $keys | Should -Not -Contain 'Key2' + } + + It 'Filters by pattern correctly' { + # Pattern to match "Key\d" (e.g. Key1, Key2) + $keys = Get-PodeStateNames -Pattern 'Key\d' + $keys.Count | Should -Be 2 + $keys | Should -Contain 'Key1' + $keys | Should -Contain 'Key2' + $keys | Should -Not -Contain 'SpecialKey' + } + + It 'Filters by both scope and pattern' { + # e.g. Scope = 'Test1', Pattern = 'Special' + $keys = Get-PodeStateNames -Scope 'Test1' -Pattern 'Special' + $keys.Count | Should -Be 1 + $keys | Should -Contain 'SpecialKey' + } + } +} From 019bf82aec7fbede8ff5058aa0aedcf9ca6ef868 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:32:44 -0800 Subject: [PATCH 06/12] Create Version.json --- Version.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Version.json diff --git a/Version.json b/Version.json new file mode 100644 index 000000000..13efd672d --- /dev/null +++ b/Version.json @@ -0,0 +1,4 @@ +{ + "Version": "2.13.0", + "Prerelease": "alpha.3" +} \ No newline at end of file From 182df9838e2c2dc7968053ad9734d485206cfa35 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:43:08 -0800 Subject: [PATCH 07/12] Squashed commit of the following: commit f057eff21ddd1e9e9cfac651c28a6616d5679615 Author: mdaneri Date: Sun Mar 2 10:07:28 2025 -0800 Folder reorganization commit d91b7a5bfb2091c8472968df58f3f8f8bddade69 Author: mdaneri Date: Sat Mar 1 14:13:58 2025 -0800 fix powershell support message commit 5d7db3723080ed6d6b888a9bf57555bf41206ae7 Author: mdaneri Date: Sat Mar 1 04:47:58 2025 -0800 fix tests commit e1f4e244a4a994705e67a35193686e7fbc2e40e4 Author: mdaneri Date: Sat Mar 1 03:12:49 2025 -0800 fix tests for 5.1 commit c54d0a61669dfd62a15861f10245d95593411ca6 Author: mdaneri Date: Fri Feb 28 07:41:21 2025 -0800 new Digest client module with documentation commit 044070de08cdd593f2b1a1f1cbfebc74faae9bc6 Author: mdaneri Date: Fri Feb 28 06:39:02 2025 -0800 update commit 11a574cf13e8034ef2c5b069495d8fd702184cea Author: mdaneri Date: Fri Feb 28 06:18:33 2025 -0800 new Invoke-WebRequestDigest commit ea89876bca3e79ea906c4e5f436567d2ec00e815 Author: mdaneri Date: Thu Feb 27 12:09:18 2025 -0800 fix Export-Certificate commit 3789b335cb8e6ecd7acce77ddb8791cb7af719d2 Author: mdaneri Date: Thu Feb 27 10:58:50 2025 -0800 Update Cryptography.ps1 commit 1ef78ac34eaaee502ced748986835989e498204e Author: mdaneri Date: Thu Feb 27 10:53:35 2025 -0800 change parameter Import-PodeCertificate -FilePath to -Path commit 6a428339566852d7ecc03d1bb0ee2abd6cc8baee Author: mdaneri Date: Thu Feb 27 09:20:13 2025 -0800 added digest check for $QualityOfProtection 'auth-int' commit 9f83eccbbbde354676557cbbf7cc4ae6279d7277 Author: mdaneri Date: Wed Feb 26 10:16:53 2025 -0800 Update DigestAuthentication.Tests.ps1 commit ec6fb18a8497efca96dcc7f5409719b062545af0 Author: mdaneri Date: Wed Feb 26 10:15:40 2025 -0800 added digest tests commit 6116794b543c42cf5c7e2c1d1480ea9056be6e59 Author: mdaneri Date: Tue Feb 25 18:41:45 2025 -0800 test update and fixes commit 76bab58e6613e78ab84b143da56c0ccd42180147 Author: mdaneri Date: Tue Feb 25 15:35:06 2025 -0800 Fix Test and build commit 57588200ca38d9386aff5775dadfaf3f8cd49b11 Author: mdaneri Date: Tue Feb 25 10:50:05 2025 -0800 update tests commit f0ca68ed64978624d4bb6470aeec6aaa97c34205 Author: mdaneri Date: Tue Feb 25 08:45:44 2025 -0800 Update JWTAuthentication.Tests.ps1 commit 2df6dfe8da270c0cf31ad5e34993499ad77ea68f Author: mdaneri Date: Sun Feb 23 21:23:58 2025 -0800 fix mac os issue with ssl commit 72674fcfe945f7e9ec99a5f9ec0c1baebf9a3a1f Author: mdaneri Date: Sun Feb 23 18:14:49 2025 -0800 update macOS commit 2f4e5ce26ad7a6d31ee4c0e327081eb513725ec1 Author: MDaneri Date: Sun Feb 23 17:18:29 2025 -0800 fix linux mac SSL detection issue commit c3c24d816d479abc1cd43745cc9e405161273d14 Author: mdaneri Date: Sun Feb 23 10:13:51 2025 -0800 update Test-PodeCertificate and SSL documentation commit c42e497469cf53559aa1293b65895a013846cd36 Merge: 6dd871d9 67505f56 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Feb 23 07:30:47 2025 -0800 Merge branch 'develop' into RFC-7616-Compliance commit 6dd871d9e61078b53ff6fc7925037786e1669bda Author: mdaneri Date: Sat Feb 22 18:54:09 2025 -0800 added certificate test commit 04d76a6ce599cc622b736084dce527c1f9cc025b Merge: 7db8ff72 cbdc62fe Author: mdaneri Date: Sat Feb 22 09:17:52 2025 -0800 Merge remote-tracking branch 'upstream/develop' into RFC-7616-Compliance commit 7db8ff724b3a22885e5572afb0c7b85730602f69 Author: mdaneri Date: Sat Feb 22 09:00:11 2025 -0800 added certificate documentation commit ecc2729e59bef6ce6a1a55337410c5398298e4fd Merge: 908cdafb b40d4e84 Author: mdaneri Date: Sat Feb 22 07:32:52 2025 -0800 Merge branch 'RFC-7616-Compliance' of https://github.com/mdaneri/Pode into RFC-7616-Compliance commit 908cdafbe7b03d95f300fea469cdecb7f6a79b35 Author: mdaneri Date: Sat Feb 22 07:32:50 2025 -0800 Fix MacOS EphemeralKeySet issue commit b40d4e8458de02c3b84e29405859bb03de13c314 Merge: 5fc7a002 fbf6ecfb Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sat Feb 22 06:32:32 2025 -0800 Merge branch 'develop' into RFC-7616-Compliance commit 5fc7a002f764410750d73dbd40577c01fad48b05 Author: mdaneri Date: Fri Feb 21 16:04:02 2025 -0800 fixex commit 4b0d09cd3ef7499b4c19d1339621c243a027b799 Author: mdaneri Date: Fri Feb 21 13:42:52 2025 -0800 fix issue with ephemeral commit 7f06e5095bb563844bdd9e40d132fb3ade156708 Author: mdaneri Date: Fri Feb 21 10:07:28 2025 -0800 revert pode.build commit 5bbd552a5df0762f8dfa48f2656c950bf272159c Author: mdaneri Date: Fri Feb 21 08:57:00 2025 -0800 added certificate management functions commit 6add82dee252c99df1d87863fc546fea94216bce Author: mdaneri Date: Wed Feb 19 08:41:14 2025 -0800 added JWT lifecycle commit 601c1cbf9620a84df882865420a946bec9bf1a72 Author: mdaneri Date: Wed Feb 19 08:05:10 2025 -0800 update JWT lifecycle commit 9fd8589ce25cc202721b0295ecdceba682a050ad Author: mdaneri Date: Tue Feb 18 20:23:19 2025 -0800 update build for 5.1 commit 9a35551bae9742d1348c22c3997309b2fbb2e896 Author: mdaneri Date: Tue Feb 18 10:05:39 2025 -0800 Added Update-PodeJWT and support for bearer Body token commit 393998337ae318c9d7abb091c4c9a2f15fbfaf97 Author: mdaneri Date: Sun Feb 16 18:58:13 2025 -0800 moved Authentication example inside Authentication folder commit fc4ed64c09937ec7be324882e4b62626f83d0e96 Author: mdaneri Date: Sun Feb 16 18:46:34 2025 -0800 Update Cryptography.ps1 commit 74584e947f7f02cf3e48cc08a80d766a9353808f Author: MDaneri Date: Sun Feb 16 18:45:19 2025 -0800 added header to Get-PodeJwtSigningAlgorithm commit cf107b2c95ff32bcbe395d2ba70a68eacf05ef25 Author: MDaneri Date: Sun Feb 16 18:17:22 2025 -0800 added workaround for .Net Linux issue commit 6f54b3f7ff259b2049e501268ce153e35c8d82e0 Author: mdaneri Date: Sun Feb 16 11:12:37 2025 -0800 macos fixes commit 852d8c06fb539441ef01b78bdd0d814a8f3dc575 Author: mdaneri Date: Sun Feb 16 10:58:52 2025 -0800 fixed tests commit 0f0c2528cc604fc9bf76b4fc9496ce01f05ed3b2 Author: mdaneri Date: Sun Feb 16 10:29:30 2025 -0800 updated comments and documentation commit 73685484d8051d4da52a27fbe10b36588b40191d Author: mdaneri Date: Sun Feb 16 09:43:52 2025 -0800 updated to adhere the Pode standard for the certificate commit feb7a2dfe73225f191ce5c53ebae884ebd4ba0f2 Merge: 363168b3 a76741bc Author: mdaneri Date: Sun Feb 16 07:06:14 2025 -0800 Merge remote-tracking branch 'upstream/develop' into RFC-7616-Compliance commit 363168b34d02aa59b32883ffbac101c15cd05355 Author: mdaneri Date: Sat Feb 15 16:51:30 2025 -0800 Change from PEM cert to PFX and adding 5.1 support commit 84bda14066918b7e45a54ba27aa4debe838002e4 Author: mdaneri Date: Thu Feb 13 09:57:07 2025 -0800 Update Cryptography.Tests.ps1 commit 61072c2f18fdf8c48cf1be0ce1c7c429bef3ef3f Author: mdaneri Date: Thu Feb 13 09:44:17 2025 -0800 Update Cryptography.Tests.ps1 commit 881b72cb89fb7010935e44eafdf7dbc0f5fe9643 Author: mdaneri Date: Thu Feb 13 09:28:45 2025 -0800 doc update commit 0e3ae54f4efc15400f237d7603b579bf3f4c8484 Author: mdaneri Date: Thu Feb 13 08:22:59 2025 -0800 fix Desktop tests commit 5a2f77f7b7f1722bccab0e6e1cd6ecf394deb0c9 Author: mdaneri Date: Thu Feb 13 07:26:32 2025 -0800 Update pode.build.ps1 commit 2bc4d848663b6087bbb7fe49861defd9ef101819 Author: MDaneri Date: Tue Feb 11 19:32:47 2025 -0800 fix not windows issue commit e98748ae0810b18780b2cbfbba892e378fecfa2f Merge: 119d479d a236a1a0 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Tue Feb 11 18:47:14 2025 -0800 Merge branch 'develop' into RFC-7616-Compliance commit 119d479d9560049f86e82e311fc6ab68ac13e4ee Author: mdaneri Date: Tue Feb 11 18:19:20 2025 -0800 working version commit a148489d3cc0f935f12709371ae4078d86a9d8b1 Author: mdaneri Date: Tue Feb 11 10:00:16 2025 -0800 work in progress commit 71315d5ed95f1eacee3fe29ecfe4c5a666d0fe2b Merge: d5fface5 e9e73340 Author: mdaneri Date: Mon Feb 10 06:47:34 2025 -0800 Merge remote-tracking branch 'upstream/develop' into RFC-7616-Compliance commit d5fface5f8544fcdfe9ab5e5afe71c645472891b Author: mdaneri Date: Mon Feb 10 06:47:15 2025 -0800 added missing header and language entries commit 93fb8ea007e20615ddd63252ce06a73db52f2034 Author: mdaneri Date: Sun Feb 9 21:00:34 2025 -0800 fixes commit 7a8f61a1d820c96ed82d59f0a87209b41a16987f Merge: 615d4ab8 75e29626 Author: mdaneri Date: Sun Feb 9 07:21:04 2025 -0800 Merge remote-tracking branch 'upstream/develop' into RFC-7616-Compliance commit 615d4ab8d4fe3c738ca1a2e12a438b8a269d95a1 Author: mdaneri Date: Sat Feb 8 11:23:24 2025 -0800 fix tests and remove Invoke-PodeJWTSign merge Invoke-PodeJWTSign in New-PodeJwtSignature commit 815ca109792384ee7d12b1efe0fca78fedda7a14 Author: mdaneri Date: Sat Feb 8 10:16:44 2025 -0800 Enhance Authentication: RFC Compliance, JWT Algorithms, and Bearer Query Support - Added full support for RFC 7518 JWT algorithms: NONE, HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512. - Introduced `-PrivateKey` parameter in `New-PodeAuthScheme` for RSA and ECDSA JWT signature verification. - Ensured JWT signature validation follows RFC 7518 standards. - Improved JWT validation for `exp` (expiration) and `nbf` (not before) claims. - Added support for passing Bearer tokens via query parameters (`-BearerLocation Query`) as per RFC 6750. - Updated `WWW-Authenticate` handling to correctly return headers on authentication failures for all authentication methods. - Ensured Pode authentication mechanisms align with industry security standards. - Updated documentation to reflect these enhancements. commit 2af345c840425a4ecea4af084d4ab7bfafc6b7f5 Merge: 23889e8e 6b23fc33 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Wed Feb 5 13:13:29 2025 -0800 Merge branch 'develop' into RFC-7616-Compliance commit 23889e8e9d637b44e258155feb29a9d02b4c070b Author: mdaneri Date: Fri Jan 31 06:52:55 2025 -0800 Fix markdown commit 4690effd3ed851155e4ee34bf767530958cf59fc Author: mdaneri Date: Fri Jan 31 06:34:56 2025 -0800 update documentation commit b53f07d6b93b8bcb89372ef498f3c7c7bbe2f47b Author: mdaneri Date: Thu Jan 30 10:31:14 2025 -0800 Update Cryptography.ps1 commit 3088db2184d044e4ec98cf2683c1f195d23dfded Author: mdaneri Date: Thu Jan 30 10:11:08 2025 -0800 Fixed tests commit 9090b07942525acaffdc85940879192a82524867 Author: mdaneri Date: Thu Jan 30 09:30:23 2025 -0800 add rawdata commit 71d4747d6501036a9ca38be866bf1f3401c2784f Author: mdaneri Date: Thu Jan 30 07:50:04 2025 -0800 Adding auth-int commit ed3a0f5f6b98f8f3a507fb0427b0364b2ebf6fa3 Author: mdaneri Date: Wed Jan 29 18:05:34 2025 -0800 fixes commit 062cc2d60f9651e99f3034ba7be7a9fe93968b95 Author: mdaneri Date: Wed Jan 29 09:01:17 2025 -0800 first drop --- .gitignore | 6 +- .vscode/settings.json | 8 + README.md | 65 +- .../Authentication/Methods/ApiKey.md | 4 +- .../Authentication/Methods/Bearer.md | 195 +- .../Authentication/Methods/Digest.md | 180 +- docs/Tutorials/Authentication/Methods/JWT.md | 307 ++- docs/Tutorials/Basics.md | 5 + docs/Tutorials/Certificates.md | 310 ++- docs/Tutorials/Ssl.md | 85 + docs/index.md | 61 +- .../Authentication/Modules/Invoke-Digest.psm1 | 662 ++++++ .../Web-AuthApiKey.ps1} | 4 +- .../{ => Authentication}/Web-AuthBasic.ps1 | 6 +- .../Web-AuthBasicAccess.ps1 | 4 +- .../Web-AuthBasicAdhoc.ps1 | 4 +- .../Web-AuthBasicAnon.ps1 | 4 +- .../Web-AuthBasicBearer.ps1 | 14 +- .../Web-AuthBasicClientcert.ps1 | 4 +- .../Web-AuthBasicHeader.ps1 | 13 +- examples/Authentication/Web-AuthBearerJWT.ps1 | 248 +++ .../Web-AuthBearerJWTLifecycle.ps1 | 274 +++ examples/Authentication/Web-AuthDigest.ps1 | 215 ++ .../{ => Authentication}/Web-AuthForm.ps1 | 6 +- .../Web-AuthFormAccess.ps1 | 4 +- .../{ => Authentication}/Web-AuthFormAd.ps1 | 4 +- .../{ => Authentication}/Web-AuthFormAnon.ps1 | 4 +- .../Web-AuthFormCreds.ps1 | 4 +- .../{ => Authentication}/Web-AuthFormFile.ps1 | 4 +- .../Web-AuthFormLocal.ps1 | 4 +- .../Web-AuthFormMerged.ps1 | 4 +- .../Web-AuthFormSessionAuth.ps1 | 4 +- .../{ => Authentication}/Web-AuthMerged.ps1 | 6 +- .../Web-AuthNegotiate.ps1 | 4 +- .../{ => Authentication}/Web-AuthOauth2.ps1 | 4 +- .../Web-AuthOauth2Form.ps1 | 4 +- .../Web-AuthOauth2Oidc.ps1 | 6 +- .../{ => Authentication}/Web-UsePodeAuth.ps1 | 4 +- .../WebAuth-ApikeyJWT.ps1} | 4 +- .../{ => Authentication}/auth/SampleAuth.ps1 | 0 .../Authentication/client/New-JwtKeyPair.ps1 | 150 ++ .../client/Test-BearerClient.ps1 | 146 ++ examples/Authentication/outfile.json | 1 + examples/OpenApi-TuttiFrutti.ps1 | 21 +- examples/Web-AuthDigest.ps1 | 80 - examples/certs/cert.pem | 21 - examples/certs/cert_nodes.pem | 21 - examples/certs/key.pem | 30 - examples/certs/key_nodes.pem | 28 - examples/certs/pode-cert.cer | Bin 796 -> 0 bytes examples/certs/pode-cert.pfx | Bin 2597 -> 0 bytes pode.build.ps1 | 14 + src/Locales/ar/Pode.psd1 | 33 +- src/Locales/de/Pode.psd1 | 32 +- src/Locales/en-us/Pode.psd1 | 32 +- src/Locales/en/Pode.psd1 | 33 +- src/Locales/es/Pode.psd1 | 32 +- src/Locales/fr/Pode.psd1 | 34 +- src/Locales/it/Pode.psd1 | 32 +- src/Locales/ja/Pode.psd1 | 32 +- src/Locales/ko/Pode.psd1 | 32 +- src/Locales/nl/Pode.psd1 | 32 +- src/Locales/pl/Pode.psd1 | 32 +- src/Locales/pt/Pode.psd1 | 32 +- src/Locales/zh/Pode.psd1 | 32 +- src/Pode.psd1 | 19 +- src/Private/ADAuthentication.ps1 | 749 +++++++ src/Private/Authentication.ps1 | 1841 +++++++++-------- src/Private/Certificate.ps1 | 425 ++++ src/Private/Context.ps1 | 14 +- src/Private/Cryptography.ps1 | 258 +-- src/Private/Helpers.ps1 | 303 ++- src/Private/Jwt.ps1 | 918 ++++++++ src/Private/Middleware.ps1 | 3 +- src/Private/Security.ps1 | 250 ++- src/Public/Authentication.ps1 | 626 +++--- src/Public/Certificate.ps1 | 1052 ++++++++++ src/Public/Core.ps1 | 16 +- src/Public/Endpoint.ps1 | 28 +- src/Public/Jwt.ps1 | 979 +++++++++ src/Public/Routes.ps1 | 8 + tests/integration/Authentication.Tests.ps1 | 4 +- .../DigestAuthentication.Tests.ps1 | 444 ++++ tests/integration/JWTAuthentication.Tests.ps1 | 600 ++++++ tests/shared/TestHelper.ps1 | 2 + tests/unit/Certificate.Tests.ps1 | 381 ++++ tests/unit/Cryptography.Tests.ps1 | 7 +- tests/unit/Jwt.Tests.ps1 | 132 ++ 88 files changed, 10736 insertions(+), 2003 deletions(-) create mode 100644 docs/Tutorials/Ssl.md create mode 100644 examples/Authentication/Modules/Invoke-Digest.psm1 rename examples/{WebAuth-ApikeyJWT.ps1 => Authentication/Web-AuthApiKey.ps1} (94%) rename examples/{ => Authentication}/Web-AuthBasic.ps1 (93%) rename examples/{ => Authentication}/Web-AuthBasicAccess.ps1 (96%) rename examples/{ => Authentication}/Web-AuthBasicAdhoc.ps1 (94%) rename examples/{ => Authentication}/Web-AuthBasicAnon.ps1 (94%) rename examples/{ => Authentication}/Web-AuthBasicBearer.ps1 (80%) rename examples/{ => Authentication}/Web-AuthBasicClientcert.ps1 (92%) rename examples/{ => Authentication}/Web-AuthBasicHeader.ps1 (84%) create mode 100644 examples/Authentication/Web-AuthBearerJWT.ps1 create mode 100644 examples/Authentication/Web-AuthBearerJWTLifecycle.ps1 create mode 100644 examples/Authentication/Web-AuthDigest.ps1 rename examples/{ => Authentication}/Web-AuthForm.ps1 (92%) rename examples/{ => Authentication}/Web-AuthFormAccess.ps1 (95%) rename examples/{ => Authentication}/Web-AuthFormAd.ps1 (94%) rename examples/{ => Authentication}/Web-AuthFormAnon.ps1 (95%) rename examples/{ => Authentication}/Web-AuthFormCreds.ps1 (95%) rename examples/{ => Authentication}/Web-AuthFormFile.ps1 (94%) rename examples/{ => Authentication}/Web-AuthFormLocal.ps1 (94%) rename examples/{ => Authentication}/Web-AuthFormMerged.ps1 (95%) rename examples/{ => Authentication}/Web-AuthFormSessionAuth.ps1 (95%) rename examples/{ => Authentication}/Web-AuthMerged.ps1 (95%) rename examples/{ => Authentication}/Web-AuthNegotiate.ps1 (89%) rename examples/{ => Authentication}/Web-AuthOauth2.ps1 (93%) rename examples/{ => Authentication}/Web-AuthOauth2Form.ps1 (94%) rename examples/{ => Authentication}/Web-AuthOauth2Oidc.ps1 (93%) rename examples/{ => Authentication}/Web-UsePodeAuth.ps1 (93%) rename examples/{Web-AuthApiKey.ps1 => Authentication/WebAuth-ApikeyJWT.ps1} (93%) rename examples/{ => Authentication}/auth/SampleAuth.ps1 (100%) create mode 100644 examples/Authentication/client/New-JwtKeyPair.ps1 create mode 100644 examples/Authentication/client/Test-BearerClient.ps1 create mode 100644 examples/Authentication/outfile.json delete mode 100644 examples/Web-AuthDigest.ps1 delete mode 100644 examples/certs/cert.pem delete mode 100644 examples/certs/cert_nodes.pem delete mode 100644 examples/certs/key.pem delete mode 100644 examples/certs/key_nodes.pem delete mode 100644 examples/certs/pode-cert.cer delete mode 100644 examples/certs/pode-cert.pfx create mode 100644 src/Private/ADAuthentication.ps1 create mode 100644 src/Private/Certificate.ps1 create mode 100644 src/Private/Jwt.ps1 create mode 100644 src/Public/Certificate.ps1 create mode 100644 src/Public/Jwt.ps1 create mode 100644 tests/integration/DigestAuthentication.Tests.ps1 create mode 100644 tests/integration/JWTAuthentication.Tests.ps1 create mode 100644 tests/unit/Certificate.Tests.ps1 create mode 100644 tests/unit/Jwt.Tests.ps1 diff --git a/.gitignore b/.gitignore index 99de26566..937948c82 100644 --- a/.gitignore +++ b/.gitignore @@ -270,4 +270,8 @@ docs/Getting-Started/Samples.md # Dump Folder Dump - +examples/certs/*-public.pem +examples/certs/*-private.pem +tests/certs/* +/examples/certs +examples/Authentication/certs/* diff --git a/.vscode/settings.json b/.vscode/settings.json index b5be5ef8e..4dd3cd280 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,5 +38,13 @@ "javascript.format.insertSpaceBeforeFunctionParenthesis": false, "[yaml]": { "editor.tabSize": 2 + }, + "markdownlint.config": { + "default": true, + "MD045": false, + "MD033": false, + "MD026": { + "punctuation": ".,;:" + } } } \ No newline at end of file diff --git a/README.md b/README.md index 3ffdf9f6f..30dcb52fe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -

+

-

+

[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/Badgerati/Pode/master/LICENSE.txt) [![Documentation](https://img.shields.io/github/v/release/badgerati/pode?label=docs&logo=readthedocs&logoColor=white)](https://badgerati.github.io/Pode) @@ -53,36 +53,37 @@ Then navigate to `http://127.0.0.1:8000` in your browser. ## 🚀 Features -* Cross-platform using PowerShell Core (with support for PS5) -* Docker support, including images for ARM/Raspberry Pi -* Azure Functions, AWS Lambda, and IIS support -* OpenAPI specification version 3.0.x and 3.1.0 -* OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf -* Listen on a single or multiple IP(v4/v6) address/hostnames -* Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) -* Host REST APIs, Web Pages, and Static Content (with caching) -* Support for custom error pages -* Request and Response compression using GZip/Deflate -* Multi-thread support for incoming requests -* Inbuilt template engine, with support for third-parties -* Async timers for short-running repeatable processes -* Async scheduled tasks using cron expressions for short/long-running processes -* Supports logging to CLI, Files, and custom logic for other services like LogStash -* Cross-state variable access across multiple runspaces -* Restart the server via file monitoring, or defined periods/times -* Ability to allow/deny requests from certain IP addresses and subnets -* Basic rate limiting for IP addresses and subnets -* Middleware and Sessions on web servers, with Flash message and CSRF support -* Authentication on requests, such as Basic, Windows and Azure AD -* Authorisation support on requests, using Roles, Groups, Scopes, etc. -* Support for dynamically building Routes from Functions and Modules -* Generate/bind self-signed certificates -* Secret management support to load secrets from vaults -* Support for File Watchers -* In-memory caching, with optional support for external providers (such as Redis) -* (Windows) Open the hosted server as a desktop application -* FileBrowsing support -* Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese, and Chinese +- ✅ Cross-platform using PowerShell Core (with support for PS5) +- ✅ Docker support, including images for ARM/Raspberry Pi +- ✅ Azure Functions, AWS Lambda, and IIS support +- ✅ OpenAPI specification version 3.0.x and 3.1.0 +- ✅ OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf +- ✅ Listen on a single or multiple IP(v4/v6) addresses/hostnames +- ✅ Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) +- ✅ Host REST APIs, Web Pages, and Static Content (with caching) +- ✅ Support for custom error pages +- ✅ Request and Response compression using GZip/Deflate +- ✅ Multi-thread support for incoming requests +- ✅ Inbuilt template engine, with support for third-parties +- ✅ Async timers for short-running repeatable processes +- ✅ Async scheduled tasks using cron expressions for short/long-running processes +- ✅ Supports logging to CLI, Files, and custom logic for other services like LogStash +- ✅ Cross-state variable access across multiple runspaces +- ✅ Restart the server via file monitoring, or defined periods/times +- ✅ Ability to allow/deny requests from certain IP addresses and subnets +- ✅ Basic rate limiting for IP addresses and subnets +- ✅ Middleware and Sessions on web servers, with Flash message and CSRF support +- ✅ Authentication on requests, such as Basic, Windows and Azure AD +- ✅ Authorisation support on requests, using Roles, Groups, Scopes, etc. +- ✅ Enhanced authentication support, including Basic, Bearer (with JWT), Certificate, Digest, Form, OAuth2, and ApiKey (with JWT). +- ✅ Support for dynamically building Routes from Functions and Modules +- ✅ Generate/bind self-signed certificates +- ✅ Secret management support to load secrets from vaults +- ✅ Support for File Watchers +- ✅ In-memory caching, with optional support for external providers (such as Redis) +- ✅ (Windows) Open the hosted server as a desktop application +- ✅ FileBrowsing support +- ✅ Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese,Dutch and Chinese ## 📦 Install diff --git a/docs/Tutorials/Authentication/Methods/ApiKey.md b/docs/Tutorials/Authentication/Methods/ApiKey.md index 2167867cf..6d754daa0 100644 --- a/docs/Tutorials/Authentication/Methods/ApiKey.md +++ b/docs/Tutorials/Authentication/Methods/ApiKey.md @@ -26,13 +26,13 @@ Start-PodeServer { } ``` -By default, Pode will look for an `X-API-KEY` header in the request. You can change this to Cookie or Query by using the `-Location` parameter. To change the name of what Pode looks for, you can use `-LocationName`. +By default, Pode will look for an `X-API-KEY` header in the request. You can change this to Cookie or Query by using the `-ApiKeyLocation` parameter. To change the name of what Pode looks for, you can use `-LocationName`. For example, to look for an `appId` query value: ```powershell Start-PodeServer { - New-PodeAuthScheme -ApiKey -Location Query -LocationName 'appId' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthScheme -ApiKey -ApiKeyLocation Query -LocationName 'appId' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($key) # check if the key is valid, and get user diff --git a/docs/Tutorials/Authentication/Methods/Bearer.md b/docs/Tutorials/Authentication/Methods/Bearer.md index 77f92abbe..be388f8ae 100644 --- a/docs/Tutorials/Authentication/Methods/Bearer.md +++ b/docs/Tutorials/Authentication/Methods/Bearer.md @@ -6,13 +6,30 @@ Bearer authentication lets you authenticate a user based on a token, with option Authorization: Bearer ``` +!!! note + **`New-PodeAuthScheme -Bearer` is deprecated.** Please use **`New-PodeAuthBearerScheme`**. + ## Setup -To start using Bearer authentication in Pode you can use `New-PodeAuthScheme -Bearer`, and then pipe the returned object into [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth). The parameter supplied to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's ScriptBlock is the `$token` from the Authorization token: +To start using Bearer authentication in Pode, call **`New-PodeAuthBearerScheme`**, and then pipe the returned object into [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth). The parameter supplied to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's **ScriptBlock** is the `$token` from the Authorization header. + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # check if the token is valid, and get user + + return @{ User = $user } + } +} +``` + +By default, Pode will look for a token in the **`Authorization`** header, verifying that it starts with the **`Bearer`** tag. You can customize this tag via **`-HeaderTag`**. You can also change the token extraction location to the **query string** using **`-Location Query`**. For the **`-Location query`** the standard tag is **`access_token`**: ```powershell Start-PodeServer { - New-PodeAuthScheme -Bearer | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthBearerScheme -Location Query | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($token) # check if the token is valid, and get user @@ -22,13 +39,129 @@ Start-PodeServer { } ``` -By default, Pode will check if the request's header contains an `Authorization` key, and whether the value of that key starts with `Bearer` tag. The `New-PodeAuthScheme -Bearer` function can be supplied parameters to customise the tag using `-HeaderTag`. +**Note:** Per [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750), using the Authorization header is recommended for sending bearer tokens. Query parameters should only be used when headers are not feasible, as query strings may be logged in URLs, potentially exposing sensitive information. + +## JWT Support + +`New-PodeAuthBearerScheme` supports **JWT authentication** with various security levels and algorithms. Set **`-AsJWT`** to enable JWT validation. Depending on the chosen algorithm, you can specify: + +- **HMAC**-based secret keys (`-Secret`) +- **Certificate**-based parameters (`-Certificate`, `-CertificateThumbprint`, `-CertificateName`, `-X509Certificate`, `-SelfSigned`) +- The **RSA padding scheme** (`-RsaPaddingScheme`) +- The **JWT verification mode** (`-JwtVerificationMode`) + +### JwtVerificationMode + +Defines how aggressively JWT claims should be checked: + +- **Strict**: Requires all standard claims: + - `exp` (Expiration Time) + - `nbf` (Not Before) + - `iat` (Issued At) + - `iss` (Issuer) + - `aud` (Audience) + - `jti` (JWT ID) + +- **Moderate**: Allows missing `iss` (Issuer) and `aud` (Audience) but still checks expiration (`exp`). +- **Lenient**: Ignores missing `iss` and `aud`, only verifies expiration (`exp`), not-before (`nbf`), and issued-at (`iat`). + +### HMAC Example + +Here’s an example using **HMAC** (HS256) JWT validation: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -Algorithm 'HS256' ` + -Secret (ConvertTo-SecureString "MySecretKey" -AsPlainText -Force) ` + -JwtVerificationMode 'Strict' | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate and decode JWT, then extract user details + + return @{ User = $user } + } +} +``` + +### Certificate-Based Example + +For **RSA/ECDSA** JWT validation, you can specify a **certificate** or **thumbprint** instead of a secret key. Pode will infer the appropriate signing algorithms (e.g., RS256, ES256) from the certificate. For instance, using a local **PFX** certificate file: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -Algorithm 'RS256' ` + -Certificate "C:\path\to\cert.pfx" ` + -CertificatePassword (ConvertTo-SecureString "CertPass" -AsPlainText -Force) ` + -JwtVerificationMode 'Moderate' | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate JWT and extract user + + return @{ User = $user } + } +} +``` + +### Self-Signed Certificate Example + +For testing purposes or internal deployments, you can use the **`-SelfSigned`** parameter, which automatically generates an **ephemeral self-signed ECDSA certificate** (ES384) for JWT signing. This avoids the need to manually create and manage certificate files. + +#### Example: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -SelfSigned | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate JWT and extract user + + return @{ User = $user } + } +} +``` + +This is equivalent to manually generating a self-signed ECDSA certificate and passing it via `-X509Certificate`: + +```powershell +Start-PodeServer { + $x509Certificate = New-PodeSelfSignedCertificate ` + -CommonName 'JWT Signing Certificate' ` + -KeyType ECDSA ` + -KeyLength 384 ` + -CertificatePurpose CodeSigning ` + -Ephemeral + + New-PodeAuthBearerScheme ` + -AsJWT ` + -X509Certificate $x509Certificate | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate JWT and extract user + + return @{ User = $user } + } +} +``` + +Using `-SelfSigned` simplifies setup by automatically handling certificate creation and disposal, making it a convenient choice for local development and testing scenarios. + +## Scope Validation -You can also optionally return a `Scope` property alongside the `User`. If you specify any scopes with [`New-PodeAuthScheme`](../../../../Functions/Authentication/New-PodeAuthScheme) then it will be validated in the Bearer's post validator - a 403 will be returned if the scope is invalid. +You can optionally include `-Scope` when creating the scheme. Pode will validate any returned `Scope` from your auth **ScriptBlock** against the scheme’s required scopes. If the scope is invalid, Pode will return 403 (Forbidden). ```powershell Start-PodeServer { - New-PodeAuthScheme -Bearer -Scope 'write' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthBearerScheme -Scope 'write' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($token) # check if the token is valid, and get user @@ -38,11 +171,13 @@ Start-PodeServer { } ``` + + ## Middleware -Once configured you can start using Bearer authentication to validate incoming requests. You can either configure the validation to happen on every Route as global Middleware, or as custom Route Middleware. +Once configured, you can instruct Pode to validate every request with Bearer authentication by using **Global Middleware**, or you can require it on individual Routes. -The following will use Bearer authentication to validate every request on every Route: +**Global Middleware Example** – Validate **every** incoming request: ```powershell Start-PodeServer { @@ -50,7 +185,7 @@ Start-PodeServer { } ``` -Whereas the following example will use Bearer authentication to only validate requests on specific a Route: +**Route-Specific Example** – Validate only on a certain Route: ```powershell Start-PodeServer { @@ -60,46 +195,42 @@ Start-PodeServer { } ``` -## JWT - -You can supply a JWT using Bearer authentication, for more details [see here](../JWT). - ## Full Example -The following full example of Bearer authentication will setup and configure authentication, validate the token, and then validate on a specific Route: +Below is a complete example demonstrating Bearer authentication with JWT. It configures a server, sets up JWT validation with a shared secret, and validates requests on one route (`/cpu`) while leaving another (`/memory`) open: ```powershell Start-PodeServer { Add-PodeEndpoint -Address * -Port 8080 -Protocol Http - # setup bearer authentication to validate a user - New-PodeAuthScheme -Bearer | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { - param($token) - - # here you'd check a real storage, this is just for example - if ($token -eq 'test-token') { - return @{ - User = @{ - 'ID' ='M0R7Y302' - 'Name' = 'Morty' - 'Type' = 'Human' + # Setup Bearer authentication to validate a user via JWT + New-PodeAuthBearerScheme -AsJWT -Algorithm 'HS256' -Secret (ConvertTo-SecureString "MySecretKey" -AsPlainText -Force) -JwtVerificationMode 'Lenient' | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # Example: in real usage, you would decode/verify the JWT fully + if ($token -eq 'test-token') { + return @{ + User = @{ + 'ID' = 'M0R7Y302' + 'Name' = 'Morty' + 'Type' = 'Human' + } + # Scope = 'read' } - # Scope = 'read' } - } - # authentication failed - return $null - } + # authentication failed + return $null + } - # check the request on this route against the authentication + # Validate against the authentication on this route Add-PodeRoute -Method Get -Path '/cpu' -Authentication 'Authenticate' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'cpu' = 82 } } - # this route will not be validated against the authentication + # Open route, no auth required Add-PodeRoute -Method Get -Path '/memory' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'memory' = 14 } } } -``` diff --git a/docs/Tutorials/Authentication/Methods/Digest.md b/docs/Tutorials/Authentication/Methods/Digest.md index 95e1e9570..c9cfca84e 100644 --- a/docs/Tutorials/Authentication/Methods/Digest.md +++ b/docs/Tutorials/Authentication/Methods/Digest.md @@ -1,14 +1,16 @@ # Digest -Digest authentication lets you authenticate a user without actually sending the password to the server. Instead the a request is made to the server, and a challenge issued back for credentials. The authentication is then done by comparing hashes generated by the client and server using the user's password as a secret key. +Digest authentication allows secure user authentication without sending the password to the server. Instead, the client receives a challenge from the server and responds with a hash-based authentication response. The server then verifies the hash using the stored password as a secret key. + +**Pode's Digest Authentication is compliant with [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616)**, ensuring compatibility with standard authentication mechanisms. ## Setup -To setup and start using Digest authentication in Pode you use the `New-PodeAuthScheme -Digest` function, and then pipe this into the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function. The parameters supplied to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's ScriptBlock are the `$username`, and a HashTable containing the parameters from the Authorization header: +To configure Digest authentication in Pode, use the `New-PodeAuthScheme -Digest` function and pass it to [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth). The parameters supplied to the `Add-PodeAuth` function's ScriptBlock include the `$username` and a hashtable containing the authentication parameters extracted from the `Authorization` header: ```powershell Start-PodeServer { - New-PodeAuthScheme -Digest | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthScheme -Digest -Algorithm "SHA-256" -QualityOfProtection "auth-int" | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($username, $params) # check if the user is valid @@ -18,28 +20,70 @@ Start-PodeServer { } ``` -Unlike other forms of authentication where you only need return the User on success. Digest requires you to also return the Password of the user as a separate property. This password is what is used as the secret key to generate the client's response hash, and allows the server to re-generate the hash for validation. (Not returning the password will result in an HTTP 401 challenge response). +Unlike other authentication methods, where only a user object is returned on success, Digest authentication **requires returning the password** (or hash) as a separate property. The password acts as the secret key to regenerate the client’s hash response for verification. Not returning the password results in an HTTP `401 Unauthorized` challenge response. + +### RFC 7616 Compliance + +Pode’s Digest authentication implementation adheres to **RFC 7616**, ensuring: + +- Use of **nonce-based challenge-response authentication** +- Support for **multiple hashing algorithms** beyond MD5 +- Support for **Quality of Protection (QoP)**, including `auth` and `auth-int` +- Correct formatting of **WWW-Authenticate** headers on authentication failure + +!!! note + SHA-384 is **not** part of RFC 7616 but has been added for consistency with other modern cryptographic algorithms and to provide additional security options. + +### Supported Algorithms + +Pode now supports multiple algorithms for Digest authentication. The `-Algorithm` parameter allows selecting one or more of the following: + +- `MD5` +- `SHA-1` +- `SHA-256` +- `SHA-384` +- `SHA-512` +- `SHA-512/256` + +Pode automatically includes all supported algorithms in the `WWW-Authenticate` challenge header, allowing clients to select the strongest available option. + +### Quality of Protection (QoP) + +The `-QualityOfProtection` parameter (`-qop`) allows choosing between: + +- `"auth"` (authentication only) +- `"auth-int"` (authentication with message integrity protection) + +If `auth-int` is used, the client includes a hash of the request body in the authentication response, ensuring the request content has not been altered. -By default, Pode will check if the Request's header contains an `Authorization` key, and whether the value of that key starts with `Digest` tag. The `New-PodeAuthScheme -Digest` function can be supplied parameters to customise the tag using `-HeaderTag`. Pode will also gather the rest of the parameters in the header such as the Nonce, NonceCount, etc. An HTTP 401 challenge will be sent back if the Authorization header is invalid. +## Handling Authentication Requests -The HashTable of parameters sent to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's ScriptBlock are the following: +By default, Pode checks if the request contains an `Authorization` header with the `Digest` scheme. The `New-PodeAuthScheme -Digest` function can be customized using the `-HeaderTag` parameter to modify the tag used in the request header. Pode also extracts all required parameters from the header, including the nonce, nonce count, and QoP options. -| Parameter | Description | -| --------- | ----------- | -| cnonce | A nonce value generated by the client | -| nc | The count of time the client has used the server nonce | -| nonce | A nonce value generated by the server | -| qop | Fixed to 'auth' | -| realm | The realm description from the server's HTTP 401 challenge | -| response | The generated hash value of all these parameters from the client | -| uri | The URI path that needs authentication | -| username | The username of the user that needs authenticating | +If the `Authorization` header is missing or invalid, Pode returns an HTTP `401 Unauthorized` response with a `WWW-Authenticate` challenge. + +### Digest Authentication Parameters + +The hashtable of parameters passed to the `Add-PodeAuth` function’s ScriptBlock includes the following: + +| Parameter | Description | +|------------|--------------| +| `cnonce` | A nonce value generated by the client. | +| `nc` | The count of times the client has used the server nonce. | +| `nonce` | A nonce value generated by the server. | +| `qop` | The quality of protection requested (`auth` or `auth-int`). | +| `realm` | The authentication realm from the server's challenge. | +| `response` | The hash generated by the client for authentication. | +| `uri` | The URI path that requires authentication. | +| `username` | The username provided for authentication. | ## Middleware -Once configured you can start using Digest authentication to validate incoming Requests. You can either configure the validation to happen on every Route as global Middleware, or as custom Route Middleware. +Digest authentication can be applied globally to all requests using `Add-PodeAuthMiddleware` or to specific routes via the `-Authentication` parameter. -The following will use Digest authentication to validate every request on every Route: +### Global Middleware + +To apply Digest authentication globally to all routes: ```powershell Start-PodeServer { @@ -47,7 +91,9 @@ Start-PodeServer { } ``` -Whereas the following example will use Digest authentication to only validate requests on specific a Route: +### Per-Route Middleware + +To enforce Digest authentication only on specific routes: ```powershell Start-PodeServer { @@ -59,40 +105,116 @@ Start-PodeServer { ## Full Example -The following full example of Digest authentication will setup and configure authentication, validate that a user's username is valid, and then validate on a specific Route: +The following example sets up Digest authentication with SHA-256 and `auth-int`, validates a user, and applies authentication to a specific route: ```powershell Start-PodeServer { Add-PodeEndpoint -Address * -Port 8080 -Protocol Http - # setup digest authentication to validate a user - New-PodeAuthScheme -Digest | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + # Setup Digest authentication with SHA-256 and auth-int + New-PodeAuthScheme -Digest -Algorithm "SHA-256" -QualityOfProtection "auth-int" | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($username, $params) - # here you'd check a real user storage, this is just for example + # Example user validation if ($username -eq 'morty') { return @{ User = @{ - 'ID' ='M0R7Y302' - 'Name' = 'Morty'; - 'Type' = 'Human'; + 'ID' = 'M0R7Y302' + 'Name' = 'Morty' + 'Type' = 'Human' } Password = 'pickle' } } - # authentication failed + # Authentication failed return $null } - # check the request on this route against the authentication + # Protect the /cpu route with Digest authentication Add-PodeRoute -Method Get -Path '/cpu' -Authentication 'Authenticate' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'cpu' = 82 } } - # this route will not be validated against the authentication + # The /memory route is accessible without authentication Add-PodeRoute -Method Get -Path '/memory' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'memory' = 14 } } } ``` + +### **Windows-Specific Limitations and the Pode Client Module** + +Windows' built-in Digest authentication has several **critical limitations** that restrict its compatibility with modern security practices: + +- **Limited to MD5:** Windows does not support stronger hashing algorithms like SHA-256 or SHA-512. +- **No Support for `auth-int`:** Integrity protection (`auth-int`) is not available, making it less secure. +- **Fails with Multiple Algorithms:** If the `WWW-Authenticate` header lists multiple algorithms, Windows' built-in implementation fails to negotiate properly. +- **Lack of Algorithm Negotiation:** Windows cannot automatically select the strongest supported algorithm from a list. + +### **Overcoming Windows Limitations with the Pode Client Module** + +To bypass these **Windows client limitations**, Pode provides a custom **client module** that supports full RFC 7616-compliant Digest authentication. This module allows PowerShell scripts to authenticate using modern algorithms, multiple QoP modes, and cross-platform compatibility. + +The **client module** is available under: + +```powershell +Import-Module ./examples/Authentication/Modules/Invoke-Digest.psm1 +``` + +By using this module, you can perform **secure Digest authentication** in PowerShell, even on Windows, without being restricted to **MD5-only authentication**. + +The module includes the following functions: + +### **Invoke-WebRequestDigest** + +A replacement for `Invoke-WebRequest` that supports Digest authentication. + +#### **Example Usage** + +```powershell +Import-Module './examples/Authentication/Modules/Invoke-Digest.psm1' + +# Define the URI and credentials +$uri = 'http://localhost:8081/users' +$username = 'morty' +$password = 'pickle' + +# Convert the password to a SecureString and create a credential object +$securePassword = ConvertTo-SecureString $password -AsPlainText -Force +$credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + +# Make a GET request using Digest authentication +$response = Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential + +# Display response headers and content +$response.Headers | Format-List +Write-Output $response.Content +``` + +--- + +### **Invoke-RestMethodDigest** + +A replacement for `Invoke-RestMethod` that supports Digest authentication. + +#### **Example Usage** + +```powershell +Import-Module './examples/Authentication/Modules/Invoke-Digest.psm1' + +# Define the URI and credentials +$uri = 'http://localhost:8081/users' +$username = 'morty' +$password = 'pickle' + +# Convert the password to a SecureString and create a credential object +$securePassword = ConvertTo-SecureString $password -AsPlainText -Force +$credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + +# Make a GET request and automatically parse JSON response +$response = Invoke-RestMethodDigest -Uri $uri -Method 'GET' -Credential $credential + +# Output the parsed response +$response +``` \ No newline at end of file diff --git a/docs/Tutorials/Authentication/Methods/JWT.md b/docs/Tutorials/Authentication/Methods/JWT.md index 9a24416ff..3e2453921 100644 --- a/docs/Tutorials/Authentication/Methods/JWT.md +++ b/docs/Tutorials/Authentication/Methods/JWT.md @@ -1,112 +1,299 @@ -# JWT +# Create a JWT -Pode has inbuilt JWT parsing for either [Bearer](../Bearer) or [API Key](../ApiKey) authentications. Pode will attempt to validate and parse the token/key as a JWT, and if successful the JWT's payload will be passed as the parameter to [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth), instead of the token/key. +Pode provides a [`ConvertTo-PodeJwt`](../../../../Functions/Authentication/ConvertTo-PodeJwt) command that builds and signs a JWT for you. You can provide: -For more information on JWTs, see the [official website](https://jwt.io). +- **`-Header`**: A hashtable defining fields like `alg`, `typ`, etc. +- **`-Payload`**: A hashtable for JWT claims (e.g., `sub`, `exp`, `nbf`, and other custom claims). +- **`-Secret`**/**`-Certificate`**/**`-CertificateThumbprint`**, etc.: If you want to sign the JWT (for HS*, RS*, ES*, PS* algorithms). +- **`-IgnoreSignature`**: If you want a token with no signature (alg = none). +- **`-Authentication`**: To reference an existing named authentication scheme, automatically pulling its parameters (algorithm, secret, certificate, etc.) so the generated JWT is recognized by that scheme. -## Setup +## Customizing the Header/Payload -To start using JWT authentication, you can supply the `-AsJWT` switch with either the `-Bearer` or `-ApiKey` switch on [`New-PodeAuthScheme`](../../../../Functions/Authentication/New-PodeAuthScheme). You can also supply an optional `-Secret` that the JWT signature uses so Pode can validate the JWT: +When generating a JWT using **`ConvertTo-PodeJwt`**, you can specify parameters that either: + +1. **Manually** define the header/payload using `-Header` and `-Payload`, or +2. **Automatically** set standard claims via shortcut parameters like `-Expiration`, `-Issuer`, `-Audience`, etc. + +You can also combine these approaches—Pode merges everything into the final token unless you use **`-NoStandardClaims`** to disable automatic claims. + +Below are the **primary parameters** you can pass to **`ConvertTo-PodeJwt`**: + +### Header and Payload + +- **`-Header`**: A hashtable for JWT header fields (e.g., `alg`, `typ`). +- **`-Payload`**: A hashtable for arbitrary/custom claims (e.g., `role`, `scope`, etc.). +- **`-NoStandardClaims`**: If specified, **no** standard claims are auto-generated (e.g., no `exp`, `nbf`, `iat`, etc.). This is useful if you want full control over claims in `-Payload`. + +#### Standard Claims Parameters + +These automatically populate or override common JWT claims: + +- **`-Expiration`** (`int`, default 3600) + - Sets the `exp` (expiration time) to the current time + `Expiration` (in seconds). + - For example, **3600** means `exp` = now + 1 hour. + +- **`-NotBefore`** (`int`, default 0) + - Sets the `nbf` (not-before) to current time + `NotBefore` (in seconds). + - **0** = immediate validity; **60** = valid 1 minute from now, etc. + +- **`-IssuedAt`** (`int`, default 0) + - Sets the `iat` (issued-at) time. + - **0** means “use current time.” Any other integer is added to the current time as seconds. + +- **`-Issuer`** (`string`) + - Sets the `iss` (issuer) claim, e.g. `"auth.example.com"`. + +- **`-Subject`** (`string`) + - Sets the `sub` (subject) claim, e.g. `"user123"`. + +- **`-Audience`** (`string`) + - Sets the `aud` (audience) claim, e.g. `"myapi.example.com"`. + +- **`-JwtId`** (`string`) + - Sets the `jti` (JWT ID) claim, a unique identifier for the token. + +If you **also** supply the same claims in your `-Payload` hashtable, Pode typically defers to your explicit claim unless **`-NoStandardClaims`** is omitted, in which case these parameters can overwrite the payload-based claims. + +--- + +### Example Usage + +Below is an example that **automatically** sets standard claims for expiration (1 hour from now), not-before (starts immediately), and an issuer, while also providing a custom header/payload: ```powershell -# jwt with no signature: -New-PodeAuthScheme -Bearer -AsJWT | Add-PodeAuth -Name 'Example' -Sessionless -ScriptBlock { - param($payload) +$header = @{ + alg = 'HS256' + typ = 'JWT' } -# jwt with signature, signed with secret "abc": -New-PodeAuthScheme -ApiKey -AsJWT -Secret 'abc' | Add-PodeAuth -Name 'Example' -Sessionless -ScriptBlock { - param($payload) +$payload = @{ + role = 'admin' + customClaim = 'someValue' } + +$jwt = ConvertTo-PodeJwt ` + -Header $header ` + -Payload $payload ` + -Secret 'SuperSecretKey' ` + -Expiration 3600 ` + -NotBefore 0 ` + -Issuer 'auth.example.com' ` + -Subject 'user123' ` + -Audience 'myapi.example.com' ` + -JwtId 'unique-token-id' + +Write-PodeJsonResponse -Value @{ token = $jwt } ``` -The `$payload` will be a PSCustomObject of the converted JSON payload. For example, sending the following unsigned JWT in a request: +This produces a JWT that includes: -```plain -eyJhbGciOiJub25lIn0.eyJ1c2VybmFtZSI6Im1vcnR5Iiwic3ViIjoiMTIzIn0. -``` +- A header with `alg = HS256`, `typ = JWT`. +- Standard claims: `exp`, `nbf`, `iat`, `iss`, `sub`, `aud`, `jti`. +- Custom claims: `role`, `customClaim`. -would produce a payload of: +If you **don’t** want Pode to generate any standard claims at all (perhaps you want to define everything in `-Payload` yourself), include **`-NoStandardClaims`**: -```plain -sub: 123 -username: morty +```powershell +$jwt = ConvertTo-PodeJwt -NoStandardClaims -Payload @{ sub='user123'; customKey='abc' } -Secret 'SuperSecretKey' ``` -### Algorithms +No `exp`, `nbf`, or `iat` will be automatically added. -Pode supports the following algorithms for JWT signatures: +Similarly, if you have a named scheme: -* None -* HS256 -* HS384 -* HS512 +```powershell +New-PodeAuthBearerScheme -AsJWT -Algorithm 'RS256' -Certificate 'C:\cert.pfx' -CertificatePassword (ConvertTo-SecureString "CertPass" -AsPlainText -Force) | + Add-PodeAuth -Name 'ExampleApiKeyCert' -For `none`, Pode expects there to be no signature with the JWT. For other algorithms, a `-Secret` is required, and a signature must be supplied with the JWT in requests. +Add-PodeRoute -Method Post -Path '/login' -ScriptBlock { + $jwt = ConvertTo-PodeJwt ` + -Authentication 'ExampleApiKeyCert' ` + -Issuer 'auth.example.com' ` + -Expiration 3600 ` + -Subject 'user123' -### Payload + Write-PodeJsonResponse -Value @{ token = $jwt } +} +``` -If the payload of the JWT contains a expiry (`exp`) or a not before (`nbf`) timestamp, Pode will validate it and return a 400 if the JWT is expired/not started. +Here, Pode automatically applies the RS256 certificate from **`ExampleApiKeyCert`** and merges your standard-claims parameters, producing a token recognized by that same scheme upon verification. -## Usage +## Using `-Authentication` -To send the JWT in a request, the JWT should be sent in place of where the usual bearer token/API key would have been. For example, for bearer it would be in the Authorization header: +If you have already set up an authentication scheme, for instance: -```plain -Authorization: Bearer +```powershell +New-PodeAuthBearerScheme -AsJWT -Algorithm 'RS256' -Certificate 'C:\path\to\cert.pfx' -CertificatePassword (ConvertTo-SecureString "CertPass" -AsPlainText -Force) | + Add-PodeAuth -Name 'ExampleApiKeyCert' ``` -and for API keys, it would be in the location defined (header, cookie, or query string). For example, in the X-API-KEY header: +then you can **reuse** this scheme’s configuration when creating a token by calling: -```plain -X-API-KEY: -``` +```powershell +$jwt = ConvertTo-PodeJwt -Authentication 'ExampleApiKeyCert' -## Create JWT +# e.g., return the new JWT to a client +Write-PodeJsonResponse -StatusCode 200 -Value @{ jwt_token = $jwt } +``` -Pode has a simple [`ConvertTo-PodeJwt`](../../../../Functions/Authentication/ConvertTo-PodeJwt) that will build a JWT for you. It accepts a hashtable for `-Header` and `-Payload`, as well as an optional `-Secret`. +Pode automatically looks up the **`ExampleApiKeyCert`** auth scheme, retrieves its signing algorithm and key/certificate, and uses those to generate a valid JWT. This ensures that the JWT you create can later be **decoded and verified** by the same auth scheme without having to re-specify all parameters (secret, certificate, etc.). -The function will run some simple validation, and them build the JWT for you. +### Example -For example: +Below is a short example of how you might implement a **login** route that returns a signed JWT: ```powershell -$header = @{ - alg = 'hs256' - typ = 'JWT' +Add-PodeRoute -Method Post -Path '/user/login' -ScriptBlock { + param() + + # In a real scenario, you'd validate the incoming credentials from $WebEvent.data + $username = $WebEvent.Data['username'] + $password = $WebEvent.Data['password'] + + # If valid, generate a JWT that matches the 'ExampleApiKeyCert' scheme + $jwt = ConvertTo-PodeJwt -Authentication 'ExampleApiKeyCert' + + Write-PodeJsonResponse -StatusCode 200 -Value @{ jwt_token = $jwt } } +``` -$payload = @{ - sub = '123' - name = 'John Doe' - exp = ([System.DateTimeOffset]::Now.AddDays(1).ToUnixTimeSeconds()) +In this example, the **`-Authentication`** parameter ensures Pode uses the RS256 certificate-based configuration already defined by the `ExampleApiKeyCert` auth scheme, producing a token that is verifiable by that same scheme on future requests. + + +Below is an **updated JWT Lifecycle guide** for Pode, clarifying that **Pode automatically validates the token** when you attach `-Authentication` to a route, and that **`ConvertFrom-PodeJwt`** is generally used for **inspecting** or **debugging** token contents. + + +## Managing the JWT Lifecycle in Pode + +In many scenarios, you need more than just generating JWTs—you also need endpoints or logic for **renewing** and **inspecting** tokens. Pode’s built-in commands and authentication features enable these patterns quickly: + +1. **Creating a JWT**: Use [`ConvertTo-PodeJwt`](https://github.com/Badgerati/Pode/blob/develop/Functions/Authentication/ConvertTo-PodeJwt.ps1) to build and sign a JWT. +2. **Automatic Validation**: Rely on Pode’s bearer auth if a route uses `-Authentication 'YourBearerScheme'`. +3. **Decoding/Inspecting a JWT**: Use `ConvertFrom-PodeJwt` if you want to explicitly decode the JWT for debugging or extracting claims. +4. **Renewing/Extending a JWT**: Use `Update-PodeJwt` to reissue a token with a new expiration. + +## 1. Creating a JWT + +See the [“Create a JWT” guide](#create-a-jwt) for details on using `ConvertTo-PodeJwt`. You can: + +- Define a scheme in Pode (e.g., `Bearer_JWT_ES512`) that holds your algorithm and certificates/secrets. +- Generate tokens by referencing `-Authentication 'Bearer_JWT_ES512'`. +- Optionally set custom claims, expiration, issuer, etc. + +This creation step often happens inside a **login** route, as shown in the example below: + +```powershell +function Test-User { + param($username, $password) + if ($username -eq 'morty' -and $password -eq 'pickle') { + return @{ + Id = 'M0R7Y302' + Username = 'morty.smith' + Name = 'Morty Smith' + Groups = 'Domain Users' + } + } + throw 'Invalid credentials' } -ConvertTo-PodeJwt -Header $header -Payload $payload -Secret 'abc' +Add-PodeRoute -Method Post -Path '/auth/login' -ScriptBlock { + try { + $username = $WebEvent.Data.username + $password = $WebEvent.Data.password + $user = Test-User $username $password # Validate credentials in some real store + + $payload = @{ + sub = $user.Id + name = $user.Name + # ... more custom claims ... + } + + # Generate JWT recognized by the scheme 'Bearer_JWT_ES512' + $jwt = ConvertTo-PodeJwt -Payload $payload -Authentication 'Bearer_JWT_ES512' -Expiration 600 + + Write-PodeJsonResponse -StatusCode 200 -Value @{ + success = $true + user = $user + jwt = $jwt + } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid credentials' } + } +} ``` -This return the following JWT: +## 2. Automatic Validation + +Once you have a named bearer scheme (e.g., `Bearer_JWT_ES512`), **any** route that includes `-Authentication 'Bearer_JWT_ES512'` is automatically protected. Pode will: + +- Extract the JWT from the HTTP `Authorization` header (or another location if specified). +- Decode and verify the signature based on the scheme’s configuration. +- Reject the request if invalid; otherwise, set `$WebEvent.Auth.User` with any relevant user/claims data. -```plain -eyJ0eXAiOiJKV1QiLCJhbGciOiJoczI1NiJ9.eyJleHAiOjE2MjI1NTMyMTQsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMyJ9.LP-O8OKwix91a-SZwVK35gEClLZQmsORbW0un2Z4RkY +```powershell +Add-PodeRoute -Method Get -Path '/secure' -Authentication 'Bearer_JWT_ES512' -ScriptBlock { + # If we get here, the token is valid + $user = $WebEvent.Auth.User + Write-PodeJsonResponse -Value @{ user = $user; message = 'Welcome!' } +} ``` -## Parse JWT +No need to manually call `ConvertFrom-PodeJwt`—Pode handles validation behind the scenes. -Pode has a [`ConvertFrom-PodeJwt`](../../../../Functions/Authentication/ConvertFrom-PodeJwt) that can be used to parse a valid JWT. Only the algorithms at the top of this page are supported for verifying the signature. You can skip signature verification by passing `-IgnoreSignature`. On success, the payload of the JWT is returned. +## 3. Decoding/Inspecting a JWT -For example, if the created JWT was supplied: +Sometimes you want to **inspect** a token or decode it for debugging. That’s where `ConvertFrom-PodeJwt` is handy. For example, you might have a route that **also** includes `-Authentication 'Bearer_JWT_ES512'` (so the user needs a valid token to get in), but within the route you call `ConvertFrom-PodeJwt` to see the raw contents or claims: ```powershell -ConvertFrom-PodeJwt -Token 'eyJ0eXAiOiJKV1QiLCJhbGciOiJoczI1NiJ9.eyJleHAiOjE2MjI1NTMyMTQsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMyJ9.LP-O8OKwix91a-SZwVK35gEClLZQmsORbW0un2Z4RkY' -Secret 'abc' +Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/info' -Authentication 'Bearer_JWT_ES512' -ScriptBlock { + try { + # Although Pode already validated the token, we can decode it ourselves for debugging + $decoded = ConvertFrom-PodeJwt -Outputs 'Header,Payload,Signature' -HumanReadable + Write-PodeJsonResponse -Value $decoded + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } +} ``` -then the following would be returned: +This route returns the **header, payload, and signature** in JSON, with timestamps (like `exp`, `nbf`, `iat`) converted to human-readable dates. + +## 4. Renewing/Extending a JWT with `Update-PodeJwt` + +Use `Update-PodeJwt` to **extend** an existing token’s lifetime. Typically, you create a `/renew` endpoint: ```powershell -@{ - sub = '123' - name = 'John Doe' - exp = 1636657408 +Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/renew' -Authentication 'Bearer_JWT_ES512' -ScriptBlock { + try { + # Reads the current valid JWT, reissues it with a fresh 'exp' claim + $newToken = Update-PodeJwt + Write-PodeJsonResponse -StatusCode 200 -Value @{ success = $true; jwt = $newToken } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } } ``` + +Pode fetches the token from `$WebEvent`, checks the original scheme (here, `Bearer_JWT_ES512`), and re-signs with updated expiration. The rest of the claims stay the same. The client can then discard the old token and use the newly returned token moving forward. + +--- + +## Full Lifecycle Example + +**1.** **Login** (create token) +**2.** **Make Authenticated Requests** (Pode automatically validates) +**3.** **Renew** (use `Update-PodeJwt` if needed) +**4.** **Debug** (optionally decode token with `ConvertFrom-PodeJwt`) + +This covers a typical JWT flow in Pode: + +- The user logs in at `/auth/login`, gets a JWT. +- They pass that JWT in subsequent requests, which are auto-validated by `-Authentication 'Bearer_JWT_ES512'`. +- If the token is about to expire, they can call `/auth/bearer/jwt/renew` to get a fresh one. +- If you need to debug claims, you can build an endpoint that calls `ConvertFrom-PodeJwt` or look at `$WebEvent.Auth.User`. + +For more details, see the [Pode GitHub examples](https://github.com/Badgerati/Pode/tree/develop/examples/Authentication) or the relevant [`ConvertTo-PodeJwt`](https://github.com/Badgerati/Pode/blob/develop/Functions/Authentication/ConvertTo-PodeJwt.ps1) and [`Update-PodeJwt`](https://github.com/Badgerati/Pode/blob/develop/Functions/Authentication/Update-PodeJwt.ps1) source files. \ No newline at end of file diff --git a/docs/Tutorials/Basics.md b/docs/Tutorials/Basics.md index 137aa8b29..f887da2e5 100644 --- a/docs/Tutorials/Basics.md +++ b/docs/Tutorials/Basics.md @@ -1,4 +1,5 @@ # Basics + !!! Warning You can initiate only one server per PowerShell instance. To run multiple servers, start additional PowerShell, or pwsh, sessions. Each session can run its own server. This is fundamental to how Pode operates, so consider it when designing your scripts and infrastructure. @@ -59,6 +60,7 @@ When you call [`Start-PodeServer`](../../Functions/Core/Start-PodeServer) direct For example, the following is a file that contains the same scriptblock for the server at the top of this page. Following that are the two ways to run the server - the first is via another script, and the second is directly from the CLI: * File.ps1 + ```powershell { # attach to port 8080 for http @@ -70,12 +72,15 @@ For example, the following is a file that contains the same scriptblock for the ``` * Server.ps1 (start via script) + ```powershell Start-PodeServer -FilePath './File.ps1' ``` + then use `./Server.ps1` on the CLI. * CLI (start from CLI) + ```powershell PS> Start-PodeServer -FilePath './File.ps1' ``` diff --git a/docs/Tutorials/Certificates.md b/docs/Tutorials/Certificates.md index be3a9e606..2aff12dae 100644 --- a/docs/Tutorials/Certificates.md +++ b/docs/Tutorials/Certificates.md @@ -1,121 +1,313 @@ # Certificates -Pode has the ability to generate and bind self-signed certificates (for dev/testing), as well as the ability to bind existing certificates for HTTPS. +Pode has the ability to generate and bind self-signed certificates (for dev/testing), as well as the ability to bind existing certificates for HTTPS or JWT. -There are 8 ways to setup HTTPS on [`Add-PodeEndpoint`](../../Functions/Core/Add-PodeEndpoint): +## Setting Up HTTPS in Pode -1. Supplying just the `-Certificate`, which is the path to files such as a `.cer` or `.pem` file. -2. Supplying both the `-Certificate` and `-CertificatePassword`, which is the path to a `.pfx` file and its password. -3. Supplying both the `-Certificate` and `-CertificateKey`, which is the paths to certificate/key PEM file pairs. -4. Supplying all of `-Certificate`, `-CertificateKey`, and `-CertificatePassword`, which is the paths to certificate/key PEM file pairs and the password for an encrypted key. -5. Supplying a `-CertificateThumbprint` for a certificate installed at `Cert:\CurrentUser\My` on Windows. -6. Supplying a `-CertificateName` for a certificate installed at `Cert:\CurrentUser\My` on Windows. -7. Supplying `-X509Certificate` of type `X509Certificate`. -8. Supplying the `-SelfSigned` switch, to generate a quick self-signed `X509Certificate`. +Pode provides multiple ways to configure HTTPS on [`Add-PodeEndpoint`](../../Functions/Core/Add-PodeEndpoint): -Note: for 5. and 6. you can change the certificate store used by supplying `-CertificateStoreName` and/or `-CertificateStoreLocation`. +- **File-based certificates:** + - `-Certificate`: Path to a `.cer` or `.pem` file. + - `-Certificate` with `-CertificatePassword`: Path to a `.pfx` file and its password. + - `-Certificate` with `-CertificateKey`: Paths to a certificate/key PEM file pair. + - `-Certificate`, `-CertificateKey`, and `-CertificatePassword`: Paths to an encrypted PEM file pair and its password. -## Usage +- **Windows Certificate Store:** + - `-CertificateThumbprint`: Uses a certificate installed at `Cert:\CurrentUser\My`. + - `-CertificateName`: Uses a certificate installed at `Cert:\CurrentUser\My` by name. -### File +- **X.509 Certificates:** + - `-X509Certificate`: Provides a certificate object of type `X509Certificate2`. + - `-SelfSigned`: Generates a quick self-signed `X509Certificate` for development. -#### PFX +- **Custom Certificate Management:** + - Pode’s built-in functions allow better control over certificate creation, import, and export. -To bind a certificate PFX file, you use the `-Certificate` parameter, along with the `-CertificatePassword` parameter for the PFX certificate. The following example supplies the path to some `.pfx` to enable HTTPS support: +## Generating a Self-Signed Certificate + +Pode provides the **`New-PodeSelfSignedCertificate`** function for creating self-signed X.509 certificates for development and testing purposes. + +### Features of `New-PodeSelfSignedCertificate` + +- ✅ Creates a **self-signed certificate** for HTTPS, JWT, or other use cases. +- ✅ Supports **RSA** and **ECDSA** keys with configurable key sizes. +- ✅ Can include **multiple Subject Alternative Names (SANs)** (e.g., `localhost`, IP addresses). +- ✅ Allows setting **certificate purposes (ServerAuth, ClientAuth, etc.).** +- ✅ Provides **ephemeral certificates** (in-memory only, not stored on disk). +- ✅ Supports **exportable certificates** that can be saved for later use. + +### Usage Examples + +#### 1️⃣ Generate a Self-Signed Certificate for HTTPS ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -Certificate './cert.pfx' -CertificatePassword 'Hunter2' -} +$cert = New-PodeSelfSignedCertificate -DnsName "example.com" -CertificatePurpose ServerAuth ``` -#### PEM +- Creates a **self-signed RSA certificate** for `example.com`. +- The certificate is valid for HTTPS (`ServerAuth`). -Pode has support for binding certificate/key PEM file pairs, on PowerShell 7+ this works out-of-the-box. However, for PowerShell 5/6 you are required to have OpenSSL installed. +#### 2️⃣ Generate a Self-Signed Certificate for Local Development -To bind a certificate/key PEM file pairs generated via LetsEncrypt or OpenSSL, you supply their paths to the `-Certificate` and `-CertificateKey` parameters. +```powershell +$cert = New-PodeSelfSignedCertificate -Loopback +``` + +- Automatically includes common loopback addresses: + - `127.0.0.1` + - `::1` + - `localhost` + - The machine’s hostname + +#### 3️⃣ Generate an ECDSA Certificate -For example, if you generate the certificate/key using the following: -```bash -openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "test.local" -KeyType "ECDSA" -KeyLength 384 ``` -Then your endpoint would be created as: +- Creates a **self-signed ECDSA certificate** with a **384-bit** key. + +#### 4️⃣ Generate a Certificate That Exists Only in Memory (Ephemeral) + +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "temp.local" -Ephemeral +``` + +- The private key is **not stored on disk**, and the certificate only exists **in-memory**. + +#### 5️⃣ Generate an Exportable Certificate + +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "secureapp.local" -Exportable +``` + +- The certificate is **exportable** and can be saved as a `.pfx` or `.pem` file later. + +#### 6️⃣ Bind a Self-Signed Certificate to an HTTPS Endpoint + ```powershell Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -Certificate './cert.pem' -CertificateKey './key.pem' + $cert = New-PodeSelfSignedCertificate -DnsName "example.com" -CertificatePurpose ServerAuth + Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -X509Certificate $cert } ``` -However, if you generate the certificate/key and encrypt the key with a passphrase: -```bash -openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 +- Creates an HTTPS endpoint using a self-signed certificate. + +--- + +## Generating a Certificate Signing Request (CSR) + +To generate a Certificate Signing Request (CSR) along with a private key, use the **`New-PodeCertificateRequest`** function: + +```powershell +$csr = New-PodeCertificateRequest -DnsName "example.com" -CommonName "example.com" -KeyType "RSA" -KeyLength 2048 ``` -Then the endpoint is created as follows: +This will create a CSR file and a private key file in the current directory. You can specify additional parameters such as organization details and certificate purposes. + +### Using a CSR to Obtain a Certificate + +Once you have generated a CSR, you need to submit it to a **Certificate Authority (CA)** (such as Let's Encrypt, DigiCert, or a private CA) to receive a signed certificate. The process typically involves: + +1. Uploading or providing the `.csr` file to the CA. +2. Completing domain validation steps (if required). +3. Receiving the signed certificate (`.cer`, `.pem`, or `.pfx`) from the CA. +4. Importing the signed certificate into Pode for use. + +Example: Importing the signed certificate after receiving it from the CA: + ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -Certificate './cert.pem' -CertificateKey './key.pem' -CertificatePassword '' +$cert = Import-PodeCertificate -Path "C:\Certs\signed-cert.pfx" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) +if (-not (Test-PodeCertificate -Certificate $cert -ErrorAction Stop)) { + throw 'Certificate not valid' } ``` -Depending on how you generated the certificate, especially if you used the above openssl, you might have to install the certificate to your local certificate store for it to be trusted. If you're using `Invoke-WebRequest` or `Invoke-RestMethod` on PowerShell 6+ you can supply the `-SkipCertificateCheck` switch. +Alternatively, you can use: + +```powershell +Test-PodeCertificate -Certificate $cert -ErrorAction Stop | Out-Null +``` + +to force an exception if the certificate fails validation. +**Refer to the `Test-PodeCertificate` documentation for any parameter details.** + +--- + +## Exporting a Certificate + +Pode allows exporting certificates in various formats such as PFX and PEM. To export a certificate: + +```powershell +Export-PodeCertificate -Certificate $cert -FilePath "C:\Certs\mycert" -Format "PFX" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) +``` + +or as a PEM file with a separate private key: -### Thumbprint +```powershell +Export-PodeCertificate -Certificate $cert -FilePath "C:\Certs\mycert" -Format "PEM" -IncludePrivateKey +``` + +## Checking a Certificate’s Purpose + +A certificate's **purpose** is defined by its **Enhanced Key Usage (EKU)** attributes, which specify what the certificate is allowed to be used for. Common EKU values include: -On Windows only, you can use a certificate that is installed at `Cert:\CurrentUser\My` using its thumbprint: +- `ServerAuth` – Used for server authentication in HTTPS. +- `ClientAuth` – Used for client authentication in mutual TLS setups. +- `CodeSigning` – Used for digitally signing software and scripts. +- `EmailSecurity` – Used for securing email communication. + +Pode can extract the EKU of a certificate to determine its intended purposes: ```powershell +$purposes = Get-PodeCertificatePurpose -Certificate $cert +$purposes +``` + +### Enforcing Certificate Purpose + +When Pode validates a certificate, it ensures that the certificate’s EKU matches the expected usage. If a certificate is used for an endpoint but lacks the required EKU (e.g., using a `CodeSigning` certificate for `ServerAuth`), Pode will reject the certificate and fail to bind it to the endpoint. + +For example, if an HTTPS endpoint is created, the certificate **must** include `ServerAuth`: + +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "example.com" -CertificatePurpose ServerAuth + Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -CertificateThumbprint '2A623A8DC46ED42A13B27DD045BFC91FDDAEB957' + Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -X509Certificate $cert } ``` -Note: You can change the certificate store used by supplying `-CertificateStoreName` and/or `-CertificateStoreLocation`. +If the certificate lacks the correct EKU, Pode will return an error when attempting to bind it. -### Name +## Importing an Existing Certificate -On Windows only, you can use a certificate that is installed at `Cert:\CurrentUser\My` using its subject name: +To import a certificate from a file or the Windows certificate store: ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -CertificateName '*.example.com' +$cert = Import-PodeCertificate -Path "C:\Certs\mycert.pfx" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) +``` + +If you import a certificate without validating it, you should then call **`Test-PodeCertificate`** to verify that the certificate is valid: + +```powershell +if (-not (Test-PodeCertificate -Certificate $cert -ErrorAction Stop)) { + throw 'Certificate not valid' } ``` -Note: You can change the certificate store used by supplying `-CertificateStoreName` and/or `-CertificateStoreLocation`. +Alternatively, you can force an exception by piping the output: + +```powershell +Test-PodeCertificate -Certificate $cert -ErrorAction Stop | Out-Null +``` + +Refer to the **Test-PodeCertificate** section below for more details on all available parameters. + +--- + +## Testing a Certificate’s Validity + +Pode provides the **`Test-PodeCertificate`** function to validate an **X.509 certificate** and ensure it meets security and usage requirements. -### X509 +### Features of `Test-PodeCertificate` -The following will instead create an X509Certificate, and pass that to the endpoint instead: +- ✅ Checks if the certificate is **within its validity period** (`NotBefore` and `NotAfter`). +- ✅ **Builds the certificate chain** to verify its trust. +- ✅ Supports **online and offline revocation checking** (OCSP/CRL). +- ✅ Allows **optional enforcement of strong cryptographic algorithms**. +- ✅ Provides an option to **reject self-signed certificates**. +- ✅ Optionally checks that the certificate’s Enhanced Key Usage (EKU) matches an **ExpectedPurpose** (with an optional **Strict** mode). + +### Usage Examples + +#### Basic Certificate Validation ```powershell -$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new('./certs/example.cer') -Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -X509Certificate $cert +Test-PodeCertificate -Certificate $cert ``` -### Self-Signed +- Checks if the certificate is currently valid. +- Does **not** check revocation status. -If you are developing/testing a site on HTTPS then Pode can generate and bind quick self-signed certificates. To do this you can pass the `-SelfSigned` switch: +#### Validate Certificate with Online Revocation Checking ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -SelfSigned -} +Test-PodeCertificate -Certificate $cert -CheckRevocation +``` + +- Uses **OCSP/CRL lookup** to check if the certificate is revoked. + +#### Validate Certificate with Offline (Cached CRL) Revocation Check + +```powershell +Test-PodeCertificate -Certificate $cert -CheckRevocation -OfflineRevocation ``` -You might get a warning in the browser about the certificate, and this is fine. If you're using `Invoke-WebRequest` or `Invoke-RestMethod` on PowerShell 6+ you can supply the `-SkipCertificateCheck` switch. +- Uses **only locally cached CRLs**, making it suitable for air-gapped environments. -## SSL Protocols +#### Allow Certificates with Weak Algorithms -The default allowed SSL protocols are SSL3 and TLS1.2 (or just TLS1.2 on MacOS), but you can change these to any of: SSL2, SSL3, TLS, TLS11, TLS12, TLS13. This is specified in your `server.psd1` configuration file: +```powershell +Test-PodeCertificate -Certificate $cert -AllowWeakAlgorithms +``` + +- Allows the use of certificates with **SHA1, MD5, or RSA-1024**. + +#### Reject Self-Signed Certificates + +```powershell +Test-PodeCertificate -Certificate $cert -DenySelfSigned +``` + +- Fails validation if the certificate **is self-signed**. + +#### Enforce Expected Certificate Purpose + +```powershell +Test-PodeCertificate -Certificate $cert -ExpectedPurpose CodeSigning -Strict +``` + +- Validates that the certificate is explicitly authorized for **CodeSigning**. +- In strict mode, if any unknown EKUs are present, the validation fails. + +--- + +## Using Certificates for JWT Authentication + +Pode supports using X.509 certificates for JWT authentication. You can specify a certificate for signing and verifying JWTs by providing `-X509Certificate` when creating a bearer authentication scheme: ```powershell -@{ - Server = @{ - Ssl= @{ - Protocols = @('TLS', 'TLS11', 'TLS12') - } +$cert = Import-PodeCertificate -Path "C:\Certs\jwt-signing-cert.pfx" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) + +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -X509Certificate $cert | + Add-PodeAuth -Name 'JWTAuth' -Sessionless -ScriptBlock { + param($token) + + # Validate and extract user details + return @{ User = $user } } } ``` + +Alternatively, you can use a self-signed certificate for development and testing: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -SelfSigned | + Add-PodeAuth -Name 'JWTAuth' -Sessionless -ScriptBlock { + param($token) + + # Validate and extract user details + return @{ User = $user } + } +} +``` + +Using certificates for JWT authentication provides enhanced security by enabling asymmetric signing (RSA/ECDSA) rather than using a shared secret. diff --git a/docs/Tutorials/Ssl.md b/docs/Tutorials/Ssl.md new file mode 100644 index 000000000..1d65ad098 --- /dev/null +++ b/docs/Tutorials/Ssl.md @@ -0,0 +1,85 @@ +# SSL Protocols + +By default, the server chooses the allowed SSL/TLS protocols based on the operating system’s native support. + +For example, on Windows 11 and Windows Server 2022 only TLS 1.2 and TLS 1.3 are enabled, while older systems (such as Windows Vista/Server 2008) allow SSL 2.0 and SSL 3.0. This behavior follows the table below: + +| Operating System | SSL 2.0 | SSL 3.0 | TLS 1.0 | TLS 1.1 | TLS 1.2 | TLS 1.3 | +|------------------------------------|---------------------|---------------------|---------------------|---------------------|--------------------|--------------------| +| Windows Vista / Server 2008 | Enabled | Enabled | Not Supported | Not Supported | Not Supported | Not Supported | +| Windows 7 / Server 2008 R2 | Enabled | Enabled | Disabled | Disabled | Disabled | Not Supported | +| Windows 8 / Server 2012 | Disabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | Not Supported | +| Windows 10 (Build 20170 and later) | No | Disabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | +| Windows 11 / Server 2022 | No | Disabled by Default | Disabled by Default | Disabled by Default | Enabled by Default | Enabled by Default | +| macOS 10.8 - 10.10 | No | Yes | Yes | Yes | Yes | No | +| macOS 10.11 | No | No | Yes | Yes | Yes | No | +| macOS 10.13 and later | No | No | Yes | Yes | Yes | Yes | +| Linux (OpenSSL 1.0.1 - 1.0.1f) | No | Yes | Yes | Yes | Yes | No | +| Linux (OpenSSL 1.0.1g and later) | No | No | Yes | Yes | Yes | No | +| Linux (OpenSSL 1.1.1 and later) | No | No | Yes | Yes | Yes | Yes | + +**Notes:** + +- **Windows Operating Systems:** + - TLS 1.3 is supported starting from Windows 10 Build 20170 and Windows Server 2022. + - Earlier versions (like Windows 7 and Windows Server 2008 R2) support up to TLS 1.2, but may require manual configuration to enable it. + +- **macOS:** + - TLS 1.3 support begins with macOS 10.13. + +- **Linux:** + - The supported SSL/TLS protocols on Linux systems depend on the version of OpenSSL installed: + - OpenSSL versions 1.0.1 to 1.0.1f support up to TLS 1.2, with SSL 3.0 enabled by default. + - OpenSSL version 1.0.1g and later disable SSL 3.0 by default. + - OpenSSL version 1.1.1 and later add support for TLS 1.3. + +## Override the Default Values + +If you wish to override the defaults, you can customize the allowed protocols in your `server.psd1` configuration file. For example, if you want to allow only TLS protocols (excluding the deprecated SSL versions), you can configure it as follows: + +```powershell +@{ + Server = @{ + Ssl = @{ + Protocols = @('Tls', 'Tls11', 'Tls12') + } + } +} +``` + +Or, to include TLS 1.3 where supported: + +```powershell +@{ + Server = @{ + Ssl = @{ + Protocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + } +} +``` + +This configuration allows you to explicitly set the protocols from the following list of supported values: `'Ssl2'`, `'Ssl3'`, `'Tls'`, `'Tls11'`, `'Tls12'`, and `'Tls13'`. + +> **Important:** Overriding these default values in your configuration file does **not** automatically enable the corresponding protocols at the operating system level. The OS may block a protocol unless its native settings are also changed. In other words, even if you add `'Ssl3'` to your allowed protocols, Windows 11 will still reject SSLv3 connections unless you modify the OS settings. + +### Example: Enabling SSLv3 on Windows 11 + +By default, Windows 11 disables SSLv3 in its Schannel settings. To enable SSLv3, you need to change the registry settings. **Proceed with caution** as enabling SSLv3 can expose your system to known vulnerabilities (such as the POODLE attack). + +You can enable SSLv3 on Windows 11 using PowerShell as follows: + +```powershell +# Create the registry keys for SSL 3.0 if they don't already exist +New-Item -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0" -Force | Out-Null +New-Item -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Client" -Force | Out-Null +New-Item -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Server" -Force | Out-Null + +# Enable SSLv3 for both client and server by setting the Enabled DWORD to 1 +Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Client" -Name "Enabled" -Value 1 -Type DWord +Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Server" -Name "Enabled" -Value 1 -Type DWord + +Write-Output "SSLv3 has been enabled. A system restart may be required for the changes to take effect." +``` + +After making these changes, your Windows 11 system will accept SSLv3 connections. Remember that this registry modification is an OS-level change, and overriding the configuration in `server.psd1` alone will not suffice. diff --git a/docs/index.md b/docs/index.md index 25d0d9dd9..22b75db5a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,36 +18,37 @@ Pode is a Cross-Platform framework to create web servers that host REST APIs, We ## 🚀 Features -* Cross-platform using PowerShell Core (with support for PS5) -* Docker support, including images for ARM/Raspberry Pi -* Azure Functions, AWS Lambda, and IIS support -* OpenAPI specification version 3.0.x and 3.1.0 -* OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf -* Listen on a single or multiple IP(v4/v6) addresses/hostnames -* Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) -* Host REST APIs, Web Pages, and Static Content (with caching) -* Support for custom error pages -* Request and Response compression using GZip/Deflate -* Multi-thread support for incoming requests -* Inbuilt template engine, with support for third-parties -* Async timers for short-running repeatable processes -* Async scheduled tasks using cron expressions for short/long-running processes -* Supports logging to CLI, Files, and custom logic for other services like LogStash -* Cross-state variable access across multiple runspaces -* Restart the server via file monitoring, or defined periods/times -* Ability to allow/deny requests from certain IP addresses and subnets -* Basic rate limiting for IP addresses and subnets -* Middleware and Sessions on web servers, with Flash message and CSRF support -* Authentication on requests, such as Basic, Windows and Azure AD -* Authorisation support on requests, using Roles, Groups, Scopes, etc. -* Support for dynamically building Routes from Functions and Modules -* Generate/bind self-signed certificates -* Secret management support to load secrets from vaults -* Support for File Watchers -* In-memory caching, with optional support for external providers (such as Redis) -* (Windows) Open the hosted server as a desktop application -* FileBrowsing support -* Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese, and Chinese +* ✅ Cross-platform using PowerShell Core (with support for PS5) +* ✅ Docker support, including images for ARM/Raspberry Pi +* ✅ Azure Functions, AWS Lambda, and IIS support +* ✅ OpenAPI specification version 3.0.x and 3.1.0 +* ✅ OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf +* ✅ Listen on a single or multiple IP(v4/v6) addresses/hostnames +* ✅ Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) +* ✅ Host REST APIs, Web Pages, and Static Content (with caching) +* ✅ Support for custom error pages +* ✅ Request and Response compression using GZip/Deflate +* ✅ Multi-thread support for incoming requests +* ✅ Inbuilt template engine, with support for third-parties +* ✅ Async timers for short-running repeatable processes +* ✅ Async scheduled tasks using cron expressions for short/long-running processes +* ✅ Supports logging to CLI, Files, and custom logic for other services like LogStash +* ✅ Cross-state variable access across multiple runspaces +* ✅ Restart the server via file monitoring, or defined periods/times +* ✅ Ability to allow/deny requests from certain IP addresses and subnets +* ✅ Basic rate limiting for IP addresses and subnets +* ✅ Middleware and Sessions on web servers, with Flash message and CSRF support +* ✅ Authentication on requests, such as Basic, Windows and Azure AD +* ✅ Authorisation support on requests, using Roles, Groups, Scopes, etc. +* ✅ Enhanced authentication support, including Basic, Bearer (with JWT), Certificate, Digest, Form, OAuth2, and ApiKey (with JWT). +* ✅ Support for dynamically building Routes from Functions and Modules +* ✅ Generate/bind self-signed certificates +* ✅ Secret management support to load secrets from vaults +* ✅ Support for File Watchers +* ✅ In-memory caching, with optional support for external providers (such as Redis) +* ✅ (Windows) Open the hosted server as a desktop application +* ✅ FileBrowsing support +* ✅ Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese,Dutch and Chinese ## 🏢 Companies using Pode diff --git a/examples/Authentication/Modules/Invoke-Digest.psm1 b/examples/Authentication/Modules/Invoke-Digest.psm1 new file mode 100644 index 000000000..07672527f --- /dev/null +++ b/examples/Authentication/Modules/Invoke-Digest.psm1 @@ -0,0 +1,662 @@ +function ConvertTo-Hash { + param ( + [string]$Value, + [string]$Algorithm + ) + + $crypto = switch ($Algorithm) { + 'MD5' { [System.Security.Cryptography.MD5]::Create() } + 'SHA-1' { [System.Security.Cryptography.SHA1]::Create() } + 'SHA-256' { [System.Security.Cryptography.SHA256]::Create() } + 'SHA-384' { [System.Security.Cryptography.SHA384]::Create() } + 'SHA-512' { [System.Security.Cryptography.SHA512]::Create() } + 'SHA-512/256' { + # Compute SHA-512 and take first 32 bytes (256 bits) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $fullHash = $sha512.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value)) + return [System.BitConverter]::ToString($fullHash[0..31]).Replace('-', '').ToLowerInvariant() + } + } + + return [System.BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))).Replace('-', '').ToLowerInvariant() +} + +function ChallengeDigest { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Connect', 'Delete', 'Get', 'Head', 'Merge', 'Options', 'Patch', 'Post', 'Put', 'Trace')] + [string]$Method, + + [Parameter(Mandatory = $true)] + [string]$Uri + ) + # Create an HTTP client + $handler = [System.Net.Http.HttpClientHandler]::new() + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + # Step 1: Send an initial request to get the challenge + $initialRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$Method, $Uri) + $initialResponse = $httpClient.SendAsync($initialRequest).Result + if ($null -eq $initialResponse) { + Throw "Server $Uri is not responding" + } + + # Extract WWW-Authenticate headers safely + $wwwAuthHeaders = $initialResponse.Headers.GetValues('WWW-Authenticate') + # Filter to get only the Digest authentication scheme + $wwwAuthHeader = $wwwAuthHeaders | Where-Object { $_ -match '^Digest' } + + Write-Verbose 'Extracted WWW-Authenticate headers:' + $wwwAuthHeaders | ForEach-Object { Write-Verbose " - $_" } + + if (-not $wwwAuthHeader) { + Throw 'Digest authentication not supported by server!' + } + + # Extract Digest Authentication challenge values + $challenge = @{} + + if ($wwwAuthHeader -match '^Digest ') { + $headerContent = $wwwAuthHeader -replace '^Digest ', '' + Write-Verbose "RAW HEADER: $headerContent" + + # 1) CAPTURE supported algorithms + if ($headerContent -match 'algorithm=((?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*)') { + $algorithms = ($matches[1] -split '\s*,\s*') + Write-Verbose "Supported Algorithms: $algorithms" + $challenge['algorithm'] = $algorithms + } + + # 2) REMOVE algorithm parameter + $headerContent = $headerContent -replace 'algorithm=(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*\s*,?', '' + # 3) CLEAN UP extra commas/whitespace + $headerContent = $headerContent -replace ',\s*,', ',' + $headerContent = $headerContent -replace '^\s*,', '' + + # Split remaining parameters safely + $headerContent -split ', ' | ForEach-Object { + $key, $value = $_ -split '=', 2 + if ($key -and $value) { + $challenge[$key.Trim()] = $value.Trim('"') + } + } + } + + Write-Verbose 'Extracted Digest Authentication Challenge:' + $challenge.GetEnumerator() | ForEach-Object { Write-Verbose "$($_.Key) = $($_.Value)" } + + $realm = $challenge['realm'] + $nonce = $challenge['nonce'] + $qop = $challenge['qop'] + $algorithm = $challenge['algorithm'] + + if (('Post', 'Put', 'Patch') -contains $Method) { + if ($qop -eq 'auth-int' -or $qop -eq 'auth,auth-int') { + $qop = 'auth-int' + } + else { + $qop = 'auth' + } + } + else { + if ($qop -eq 'auth' -or $qop -eq 'auth,auth-int') { + $qop = 'auth' + } + else { + throw "$Method doesn't support QualityOfProtection 'auth-int'" + } + } + + Write-Verbose "Selected QOP: $qop" + + $preferredAlgorithms = @('SHA-512/256', 'SHA-512', 'SHA-384', 'SHA-256', 'SHA-1', 'MD5') + if ($algorithm -isnot [System.Array]) { + $algorithm = @($algorithm) + } + $algorithm = ($preferredAlgorithms | Where-Object { $algorithm -contains $_ } | Select-Object -First 1) + if (-not $algorithm) { + Throw "No supported algorithms found! Server supports: $algorithm" + } + return [PSCustomObject]@{ + realm = $realm + nonce = $nonce + qop = $qop + algorithm = $algorithm + wwwAuthHeader = $wwwAuthHeader + uri = $Uri + httpClient = $httpClient + method = $Method + } +} + +<# +.SYNOPSIS + Sends an HTTP request using Digest authentication and returns a web response. + +.DESCRIPTION + The Invoke-WebRequestDigest function performs an HTTP request with Digest authentication, + handling HTTP headers, authentication challenges, retries, and timeouts. + It returns a BasicHtmlWebResponseObject similar to Invoke-WebRequest. + +.PARAMETER Uri + The target URI for the request. + +.PARAMETER Method + The HTTP method to use for the request. Default is 'GET'. + +.PARAMETER Body + The request body, required for methods like POST, PUT, and PATCH. + +.PARAMETER Credential + The PSCredential object containing the username and password for Digest authentication. + +.PARAMETER Headers + A hashtable of additional headers to include in the request. + +.PARAMETER ContentType + The Content-Type of the request body. Default is 'application/json'. + +.PARAMETER OperationTimeoutSeconds + The maximum time in seconds before the request times out. Default is 100. + +.PARAMETER ConnectionTimeoutSeconds + The timeout in seconds for establishing a connection. Default is 100. + +.PARAMETER DisableKeepAlive + If specified, disables persistent connections by adding the 'Connection: close' header. + +.PARAMETER HttpVersion + The HTTP version to use, such as '1.1' or '2.0'. Default is '1.1'. + +.PARAMETER MaximumRetryCount + The number of times to retry the request in case of failure. Default is 1. + +.PARAMETER RetryIntervalSec + The interval in seconds between retry attempts. Default is 1. + +.PARAMETER OutFile + If specified, writes the response body to the specified file instead of returning content. + +.PARAMETER PassThru + If specified, returns the response object even if OutFile is used. + +.PARAMETER SkipCertificateCheck + If specified, disables SSL certificate validation (useful for self-signed certificates). + +.PARAMETER SslProtocol + Specifies the allowed SSL/TLS protocol(s) to use (e.g., 'Tls12'). + +.PARAMETER TransferEncoding + The value for the 'Transfer-Encoding' header. + +.PARAMETER UserAgent + The User-Agent string to use in the request. + +.OUTPUTS + - Returns a [Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject]. + - If OutFile is specified, writes response data to the specified file. + +.EXAMPLE + $cred = Get-Credential + $response = Invoke-WebRequestDigest -Uri 'https://example.com/data' -Method 'GET' -Credential $cred + Write-Output $response.Content + +.EXAMPLE + $body = @{ "name" = "John Doe"; "email" = "john@example.com" } + $cred = Get-Credential + $response = Invoke-WebRequestDigest -Uri 'https://example.com/users' -Method 'POST' -Credential $cred -Body $body -ContentType 'application/json' + Write-Output $response.Content + +.EXAMPLE + # Download file + Invoke-WebRequestDigest -Uri 'https://example.com/file.zip' -Method 'GET' -Credential $cred -OutFile 'C:\Downloads\file.zip' + +.NOTES + - This function provides full control over HTTP requests with Digest authentication. + - Supports custom headers, connection options, timeouts, and retries. + - Unlike Invoke-RestMethodDigest, this function does not automatically parse JSON/XML. +#> +function Invoke-WebRequestDigest { + [CmdletBinding(DefaultParameterSetName = 'Uri')] + [OutputType([Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject])] + param( + # URI of the request (required) + [Parameter(Mandatory = $true, Position = 0)] + [Uri]$Uri, + + # HTTP method (default GET) + [Parameter(Position = 1)] + [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'PATCH', 'MERGE', 'CONNECT')] + [string]$Method = 'GET', + + # Request body (for POST/PUT/PATCH, etc.) + [Parameter()] + $Body, + + # Credential for Digest authentication (required) + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential]$Credential, + + # Additional headers (as a hashtable) + [Parameter()] + [hashtable]$Headers, + + # Content type for the request body (default application/json) + [Parameter()] + [string]$ContentType = 'application/json', + + # Timeout (for the overall operation) in seconds + [Parameter()] + [int]$OperationTimeoutSeconds = 100, + + # Connection timeout in seconds + [Parameter()] + [int]$ConnectionTimeoutSeconds = 100, + + # Disable persistent connections (KeepAlive) + [Parameter()] + [switch]$DisableKeepAlive, + + # Specify the HTTP version (e.g. '1.1' or '2.0') + [Parameter()] + [string]$HttpVersion = '1.1', + + # Maximum number of retries (if request fails) + [Parameter()] + [int]$MaximumRetryCount = 1, + + # Interval between retries (seconds) + [Parameter()] + [int]$RetryIntervalSec = 1, + + # If provided, write response body to this file + [Parameter()] + [string]$OutFile, + + # If specified, output the response object even if OutFile is used + [Parameter()] + [switch]$PassThru, + + # Skip certificate validation (useful for self-signed certs) + [Parameter()] + [switch]$SkipCertificateCheck, + + # Specify allowed SSL/TLS protocol(s) (e.g. 'Tls12') + [Parameter()] + [string]$SslProtocol, + + # Transfer-Encoding header value to set on the request + [Parameter()] + [string]$TransferEncoding, + + # User-Agent string to use on the request + [Parameter()] + [string]$UserAgent + ) + + # Validate that we have a credential + if (-not $Credential) { + Throw 'A credential is required for Digest authentication.' + } + + # Use HttpClientHandler + $handler = [System.Net.Http.HttpClientHandler]::new() + if ($SkipCertificateCheck) { + $handler.ServerCertificateCustomValidationCallback = { return $true } + } + if ($SslProtocol) { + $handler.SslProtocols = [System.Enum]::Parse( + [System.Security.Authentication.SslProtocols], $SslProtocol) + } + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + $httpClient.Timeout = [TimeSpan]::FromSeconds($ConnectionTimeoutSeconds) + + # If DisableKeepAlive is specified, add a header to close the connection. + if ($DisableKeepAlive) { + if (-not $Headers) { $Headers = @{} } + $Headers['Connection'] = 'close' + } + + # Use the challenge function to get the digest details. + try { + $challenge = ChallengeDigest -Uri $Uri -Method $Method + } + catch { + Throw "Error retrieving Digest authentication challenge: $_" + } + + try { + # If a body is provided and content type is JSON, convert it if necessary. + if ($Body -and ($ContentType -match 'application/json')) { + if ($Body -isnot [string]) { + $Body = $Body | ConvertTo-Json -Compress + } + } + + # Build the digest response parameters. + $nc = '00000001' + $cnonce = (New-Guid).Guid.Substring(0, 8) + $Method = $challenge.Method.ToUpper() + Write-Verbose "Using method: $Method" + $uriPath = ([System.Uri]$challenge.uri).AbsolutePath + + # Compute HA1 + $HA1 = ConvertTo-Hash -Value "$($Credential.UserName):$($challenge.realm):$($Credential.GetNetworkCredential().Password)" -Algorithm $challenge.algorithm + + if ($challenge.qop -eq 'auth-int') { + if (('Post', 'Put', 'Patch') -notcontains $Method) { + Throw "'auth-int' doesn't support $Method" + } + $requestBody = $Body | ConvertTo-Json + $entityBodyHash = ConvertTo-Hash -Value $requestBody -Algorithm $challenge.algorithm + $HA2 = ConvertTo-Hash -Value "$($Method):$($uriPath):$($entityBodyHash)" -Algorithm $challenge.algorithm + } + else { + $HA2 = ConvertTo-Hash -Value "$($Method):$($uriPath)" -Algorithm $challenge.algorithm + } + + $responseHash = ConvertTo-Hash -Value "$($HA1):$($challenge.nonce):$($nc):$($cnonce):$($challenge.qop):$HA2" -Algorithm $challenge.algorithm + + # Build the Authorization header using StringBuilder. + $sb = [System.Text.StringBuilder]::new() + [void]$sb.Append('Digest username="').Append($Credential.UserName).Append('"') + [void]$sb.Append(', realm="').Append($challenge.realm).Append('"') + [void]$sb.Append(', nonce="').Append($challenge.nonce).Append('"') + [void]$sb.Append(', uri="').Append($uriPath).Append('"') + [void]$sb.Append(', algorithm=').Append($challenge.algorithm) + [void]$sb.Append(', response="').Append($responseHash).Append('"') + [void]$sb.Append(', qop="').Append($challenge.qop).Append('"') + [void]$sb.Append(', nc=').Append($nc) + [void]$sb.Append(', cnonce="').Append($cnonce).Append('"') + + # Create the HttpRequestMessage. + $authRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$Method, $challenge.uri) + $authRequest.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Digest', $sb.ToString()) + + # Set the HTTP version if provided. + if ($HttpVersion) { + $authRequest.Version = [System.Version]$HttpVersion + } + + # Add additional headers (if any) to the request. + if ($Headers) { + foreach ($key in $Headers.Keys) { + $authRequest.Headers.TryAddWithoutValidation($key, $Headers[$key]) | Out-Null + } + } + + # Set Transfer-Encoding if provided. + if ($TransferEncoding) { + $authRequest.Headers.TryAddWithoutValidation('Transfer-Encoding', $TransferEncoding) | Out-Null + } + + # Set User-Agent if provided. + if ($UserAgent) { + $authRequest.Headers.UserAgent.Clear() + $authRequest.Headers.UserAgent.ParseAdd($UserAgent) + } + + if ($challenge.qop -eq 'auth-int') { + $authRequest.Content = [System.Net.Http.StringContent]::new($requestBody, [System.Text.Encoding]::UTF8, $ContentType) + } + + # Implement a simple retry loop. + $retryCount = 0 + do { + try { + $rawResponse = $challenge.httpClient.SendAsync($authRequest).Result + break + } + catch { + if (++$retryCount -ge $MaximumRetryCount) { + Throw "Error sending the authenticated request after $MaximumRetryCount attempts: $_" + } + else { + Write-Verbose "Retrying in $RetryIntervalSec seconds..." + Start-Sleep -Seconds $RetryIntervalSec + } + } + } while ($true) + + # Optionally write response to file. + if ($OutFile) { + $mediaType = $rawResponse.Content.Headers.ContentType.MediaType + if ($mediaType -match '^(text|application/json|application/xml)') { + $contentString = $rawResponse.Content.ReadAsStringAsync().Result + Set-Content -Path $OutFile -Value $contentString -Encoding UTF8 + } + else { + $rawResponse.Content.ReadAsByteArrayAsync().Result | Set-Content -Path $OutFile -Encoding Byte + } + if (-not $PassThru) { return } + } + + # Wrap the response in a BasicHtmlWebResponseObject using the OperationTimeoutSeconds value. + $contentStream = $rawResponse.Content.ReadAsStream() + $timeout = [TimeSpan]::FromSeconds($OperationTimeoutSeconds) + $cancellationToken = [System.Threading.CancellationToken]::None + return [Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject]::new($rawResponse, $contentStream, $timeout, $cancellationToken) + } + catch { + Throw "Error sending Digest authenticated request: $_" + } +} + + +<# +.SYNOPSIS + Sends an HTTP or REST request using Digest authentication and returns parsed data. + +.DESCRIPTION + The Invoke-RestMethodDigest function performs an HTTP request with Digest authentication, + leveraging Invoke-WebRequestDigest under the hood. It automatically parses the response + content into an object, supporting JSON and XML formats. + +.PARAMETER Uri + The target URI for the request. + +.PARAMETER Method + The HTTP method to use for the request. Default is 'GET'. + +.PARAMETER Body + The request body, required for methods like POST, PUT, and PATCH. + +.PARAMETER Credential + The PSCredential object containing the username and password for Digest authentication. + +.PARAMETER Headers + A hashtable of additional headers to include in the request. + +.PARAMETER ContentType + The Content-Type of the request body. Default is 'application/json'. + +.PARAMETER OperationTimeoutSeconds + The maximum time in seconds before the request times out. Default is 100. + +.PARAMETER ConnectionTimeoutSeconds + The timeout in seconds for establishing a connection. Default is 100. + +.PARAMETER DisableKeepAlive + If specified, disables persistent connections by adding the 'Connection: close' header. + +.PARAMETER HttpVersion + The HTTP version to use, such as '1.1' or '2.0'. Default is '1.1'. + +.PARAMETER MaximumRetryCount + The number of times to retry the request in case of failure. Default is 1. + +.PARAMETER RetryIntervalSec + The interval in seconds between retry attempts. Default is 1. + +.PARAMETER OutFile + If specified, writes the response body to the specified file instead of returning content. + +.PARAMETER PassThru + If specified, returns the response object even if OutFile is used. + +.PARAMETER SkipCertificateCheck + If specified, disables SSL certificate validation (useful for self-signed certificates). + +.PARAMETER SslProtocol + Specifies the allowed SSL/TLS protocol(s) to use (e.g., 'Tls12'). + +.PARAMETER TransferEncoding + The value for the 'Transfer-Encoding' header. + +.PARAMETER UserAgent + The User-Agent string to use in the request. + +.OUTPUTS + - JSON responses are converted to PowerShell objects. + - XML responses are parsed into XML objects. + - Plain text or other data is returned as-is. + +.EXAMPLE + $cred = Get-Credential + $response = Invoke-RestMethodDigest -Uri 'https://example.com/api/data' -Method 'GET' -Credential $cred + Write-Output $response + +.EXAMPLE + $body = @{ "name" = "John Doe"; "email" = "john@example.com" } + $cred = Get-Credential + $response = Invoke-RestMethodDigest -Uri 'https://example.com/api/users' -Method 'POST' -Credential $cred -Body $body -ContentType 'application/json' + Write-Output $response + +.NOTES + - This function is a wrapper around Invoke-WebRequestDigest and provides an easier way + to work with REST APIs by automatically parsing the response content. + - Use Invoke-WebRequestDigest if you need full access to response headers and raw content. +#> +function Invoke-RestMethodDigest { + [CmdletBinding(DefaultParameterSetName = 'Uri')] + [OutputType([xml])] + [OutputType([psobject])] + param( + # URI of the request (required) + [Parameter(Mandatory = $true, Position = 0)] + [Uri]$Uri, + + # HTTP method (default GET) + [Parameter(Position = 1)] + [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'PATCH', 'MERGE', 'CONNECT')] + [string]$Method = 'GET', + + # Request body (for POST/PUT/PATCH, etc.) + [Parameter()] + $Body, + + # Credential for Digest authentication (required) + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential]$Credential, + + # Additional headers (as a hashtable) + [Parameter()] + [hashtable]$Headers, + + # Content type for the request body (default application/json) + [Parameter()] + [string]$ContentType = 'application/json', + + # Timeout (for the overall operation) in seconds + [Parameter()] + [int]$OperationTimeoutSeconds = 100, + + # Connection timeout in seconds + [Parameter()] + [int]$ConnectionTimeoutSeconds = 100, + + # Disable persistent connections (KeepAlive) + [Parameter()] + [switch]$DisableKeepAlive, + + # Specify the HTTP version (e.g. '1.1' or '2.0') + [Parameter()] + [string]$HttpVersion = '1.1', + + # Maximum number of retries (if request fails) + [Parameter()] + [int]$MaximumRetryCount = 1, + + # Interval between retries (seconds) + [Parameter()] + [int]$RetryIntervalSec = 1, + + # If provided, write response body to this file + [Parameter()] + [string]$OutFile, + + # If specified, output the response object even if OutFile is used + [Parameter()] + [switch]$PassThru, + + # Skip certificate validation (useful for self-signed certs) + [Parameter()] + [switch]$SkipCertificateCheck, + + # Specify allowed SSL/TLS protocol(s) (e.g. 'Tls12') + [Parameter()] + [string]$SslProtocol, + + + # Transfer-Encoding header value + [Parameter()] + [string]$TransferEncoding, + + # User-Agent string to use on the request + [Parameter()] + [string]$UserAgent + ) + + # Build a parameter hashtable for Invoke-WebRequestDigest + $params = @{ + Uri = $Uri + Method = $Method + Body = $Body + Credential = $Credential + Headers = $Headers + ContentType = $ContentType + OperationTimeoutSeconds = $OperationTimeoutSeconds + ConnectionTimeoutSeconds = $ConnectionTimeoutSeconds + DisableKeepAlive = $DisableKeepAlive + HttpVersion = $HttpVersion + MaximumRetryCount = $MaximumRetryCount + RetryIntervalSec = $RetryIntervalSec + OutFile = $OutFile + PassThru = $PassThru + SkipCertificateCheck = $SkipCertificateCheck + SslProtocol = $SslProtocol + TransferEncoding = $TransferEncoding + UserAgent = $UserAgent + } + + # Call the digest-enabled web request function + $webResponse = Invoke-WebRequestDigest @params + + if ($null -eq $webResponse) { + return $null + } + + # Parse the response content based on its media type + $content = $webResponse.Content + if ($content) { + # Get Content-Type header if available + $mediaType = $webResponse.Headers.'Content-Type' + if ($mediaType -match 'application/json') { + return $content | ConvertFrom-Json + } + elseif ($mediaType -match 'application/xml' -or $mediaType -match 'text/xml') { + return [xml]$content + } + else { + # For non-parsed content (plain text or other formats) + return $content + } + } + else { + return $null + } +} + +Export-ModuleMember -Function Invoke-WebRequestDigest +Export-ModuleMember -Function Invoke-RestMethodDigest diff --git a/examples/WebAuth-ApikeyJWT.ps1 b/examples/Authentication/Web-AuthApiKey.ps1 similarity index 94% rename from examples/WebAuth-ApikeyJWT.ps1 rename to examples/Authentication/Web-AuthApiKey.ps1 index dcf242d54..e7b88e135 100644 --- a/examples/WebAuth-ApikeyJWT.ps1 +++ b/examples/Authentication/Web-AuthApiKey.ps1 @@ -29,7 +29,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Get .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/WebAuth-ApikeyJWT.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/WebAuth-ApikeyJWT.ps1 .NOTES Author: Pode Team @@ -44,7 +44,7 @@ param( try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasic.ps1 b/examples/Authentication/Web-AuthBasic.ps1 similarity index 93% rename from examples/Web-AuthBasic.ps1 rename to examples/Authentication/Web-AuthBasic.ps1 index e0a886a02..ef795df87 100644 --- a/examples/Web-AuthBasic.ps1 +++ b/examples/Authentication/Web-AuthBasic.ps1 @@ -23,7 +23,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasic.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasic.ps1 .NOTES Author: Pode Team @@ -31,7 +31,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -75,7 +75,7 @@ Start-PodeServer -Threads 2 { return @{ Message = 'Invalid details supplied' } } - + # POST request to get current user (since there's no session, authentication will always happen) Add-PodeRoute -Method Post -Path '/users' -Authentication 'Validate' -ScriptBlock { Write-PodeJsonResponse -Value @{ diff --git a/examples/Web-AuthBasicAccess.ps1 b/examples/Authentication/Web-AuthBasicAccess.ps1 similarity index 96% rename from examples/Web-AuthBasicAccess.ps1 rename to examples/Authentication/Web-AuthBasicAccess.ps1 index 1bd7d36c5..0c07afe5d 100644 --- a/examples/Web-AuthBasicAccess.ps1 +++ b/examples/Authentication/Web-AuthBasicAccess.ps1 @@ -27,7 +27,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users-all -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Dot-SourceScript.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Dot-SourceScript.ps1 .NOTES Author: Pode Team @@ -35,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicAdhoc.ps1 b/examples/Authentication/Web-AuthBasicAdhoc.ps1 similarity index 94% rename from examples/Web-AuthBasicAdhoc.ps1 rename to examples/Authentication/Web-AuthBasicAdhoc.ps1 index c4eea0b73..b7b905ec5 100644 --- a/examples/Web-AuthBasicAdhoc.ps1 +++ b/examples/Authentication/Web-AuthBasicAdhoc.ps1 @@ -27,7 +27,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicAdhoc.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicAdhoc.ps1 .NOTES Author: Pode Team @@ -35,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicAnon.ps1 b/examples/Authentication/Web-AuthBasicAnon.ps1 similarity index 94% rename from examples/Web-AuthBasicAnon.ps1 rename to examples/Authentication/Web-AuthBasicAnon.ps1 index 597c3cf9c..2f6648779 100644 --- a/examples/Web-AuthBasicAnon.ps1 +++ b/examples/Authentication/Web-AuthBasicAnon.ps1 @@ -27,7 +27,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicAnon.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicAnon.ps1 .NOTES Author: Pode Team @@ -35,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicBearer.ps1 b/examples/Authentication/Web-AuthBasicBearer.ps1 similarity index 80% rename from examples/Web-AuthBasicBearer.ps1 rename to examples/Authentication/Web-AuthBasicBearer.ps1 index f6c73820d..5ccec6948 100644 --- a/examples/Web-AuthBasicBearer.ps1 +++ b/examples/Authentication/Web-AuthBasicBearer.ps1 @@ -9,10 +9,16 @@ .EXAMPLE To run the sample: ./Web-AuthBasicBearer.ps1 - Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -Headers @{ Authorization = 'Bearer test-token' } + Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -Headers @{ Authorization = 'Bearer test-token' } -ResponseHeadersVariable headers + +.EXAMPLE + "No Authorization header found" + + Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -ResponseHeadersVariable headers -Verbose -SkipHttpErrorCheck + $headers | Format-List .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicBearer.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicBearer.ps1 .NOTES Author: Pode Team @@ -20,7 +26,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -64,7 +70,7 @@ Start-PodeServer -Threads 2 { } # GET request to get list of users (since there's no session, authentication will always happen) - Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ScriptBlock { + Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ErrorContentType 'application/json' -ScriptBlock { Write-PodeJsonResponse -Value @{ Users = @( @{ diff --git a/examples/Web-AuthBasicClientcert.ps1 b/examples/Authentication/Web-AuthBasicClientcert.ps1 similarity index 92% rename from examples/Web-AuthBasicClientcert.ps1 rename to examples/Authentication/Web-AuthBasicClientcert.ps1 index 489b37b72..d99470183 100644 --- a/examples/Web-AuthBasicClientcert.ps1 +++ b/examples/Authentication/Web-AuthBasicClientcert.ps1 @@ -10,7 +10,7 @@ To run the sample: ./Web-AuthBasicClientcert.ps1 .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicClientcert.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicClientcert.ps1 .NOTES Author: Pode Team @@ -18,7 +18,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicHeader.ps1 b/examples/Authentication/Web-AuthBasicHeader.ps1 similarity index 84% rename from examples/Web-AuthBasicHeader.ps1 rename to examples/Authentication/Web-AuthBasicHeader.ps1 index 0fb0f8d27..169d3f445 100644 --- a/examples/Web-AuthBasicHeader.ps1 +++ b/examples/Authentication/Web-AuthBasicHeader.ps1 @@ -17,7 +17,8 @@ The example used here is Basic authentication. Login: - $session = (Invoke-WebRequest -Uri http://localhost:8081/login -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cGlja2xl' }).Headers['pode.sid'] + Invoke-RestMethod -Uri http://localhost:8081/login -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cGlja2xl' } -ResponseHeadersVariable headers -SkipHttpErrorCheck + $session = $headers['pode.sid'] Users: Invoke-RestMethod -Uri http://localhost:8081/users -Method Post -Headers @{ 'pode.sid' = "$session" } @@ -26,7 +27,7 @@ Invoke-WebRequest -Uri http://localhost:8081/logout -Method Post -Headers @{ 'pode.sid' = "$session" } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicHeader.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicHeader.ps1 .NOTES Author: Pode Team @@ -34,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -81,13 +82,13 @@ Start-PodeServer -Threads 2 { } # POST request to login - Add-PodeRoute -Method Post -Path '/login' -Authentication 'Login' + Add-PodeRoute -Method Post -Path '/login' -Authentication 'Login' -ErrorContentType 'application/json' # POST request to logout - Add-PodeRoute -Method Post -Path '/logout' -Authentication 'Login' -Logout + Add-PodeRoute -Method Post -Path '/logout' -Authentication 'Login' -Logout -ErrorContentType 'application/json' # POST request to get list of users - the "pode.sid" header is expected - Add-PodeRoute -Method Post -Path '/users' -Authentication 'Login' -ScriptBlock { + Add-PodeRoute -Method Post -Path '/users' -Authentication 'Login' -ErrorContentType 'application/json' -ScriptBlock { Write-PodeJsonResponse -Value @{ Users = @( @{ diff --git a/examples/Authentication/Web-AuthBearerJWT.ps1 b/examples/Authentication/Web-AuthBearerJWT.ps1 new file mode 100644 index 000000000..1ae85402f --- /dev/null +++ b/examples/Authentication/Web-AuthBearerJWT.ps1 @@ -0,0 +1,248 @@ +<# +.SYNOPSIS + A PowerShell script to set up a Pode server with JWT authentication and various route configurations. + +.DESCRIPTION + This script initializes a Pode server that listens on a specified port, enables request and error logging, + and configures JWT authentication using either the request header or query parameters. It also defines + a protected route to fetch a list of users, requiring authentication. + +.PARAMETER Location + Specifies where the API key (JWT token) is expected. + Valid values: 'Header', 'Query'. + Default: 'Header'. + +.EXAMPLE + # Run the sample + ./WebAuth-bearerJWT.ps1 + + JWT payload: + { + "sub": "1234567890", + "name": "morty", + "username":"morty", + "type": "Human", + "id" : "M0R7Y302", + "admin": true, + "iat": 1516239022, + "exp": 2634234231, + "iss": "auth.example.com", + "sub": "1234567890", + "aud": "myapi.example.com", + "nbf": 1690000000, + "jti": "unique-token-id", + "role": "admin" + } + +.EXAMPLE + # Example request using PS512 JWT authentication + $jwt = ConvertTo-PodeJwt -PfxPath ./cert.pfx -RsaPaddingScheme Pss -PfxPassword (ConvertTo-SecureString 'mySecret' -AsPlainText -Force) + $headers = @{ 'Authorization' = "Bearer $jwt" } + $response = Invoke-RestMethod -Uri 'http://localhost:8081/auth/bearer/jwt/PS512' -Method Get -Headers $headers + +.EXAMPLE + # Example request using RS384 JWT authentication + $headers = @{ 'Authorization' = 'Bearer ' } + $response = Invoke-RestMethod -Uri 'http://localhost:8081/users' -Method Get -Headers $headers + +.EXAMPLE + # Example request using HS256 JWT authentication + $jwt = ConvertTo-PodeJwt -Algorithm HS256 -Secret (ConvertTo-SecureString 'secret' -AsPlainText -Force) -Payload @{id='id';name='Morty'} + $headers = @{ 'Authorization' = "Bearer $jwt" } + $response = Invoke-RestMethod -Uri 'http://localhost:8081/users' -Method Get -Headers $headers + + .LINK + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthbearerJWT.ps1 + + .NOTES + - This script uses Pode to create a lightweight web server with authentication. + - JWT authentication is handled via Bearer tokens passed in either the header or query. + - Ensure the private key is securely stored and managed for RS256-based JWT signing. + - Using query parameters for authentication is **discouraged** due to security risks. + - Always use HTTPS in production to protect sensitive authentication data. + + Author: Pode Team + License: MIT License +#> + +param( + [Parameter()] + [ValidateSet('Header', 'Query' )] + [string] + $Location = 'Header' +) + +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } + +# or just: +# Import-Module Pode + +# create a server, and start listening on port 8081 +Start-PodeServer -Threads 2 -ApplicationName 'webauth' { + + # listen on localhost:8081 + Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http + + New-PodeLoggingMethod -File -Name 'requests' | Enable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + + + $JwtVerificationMode = 'Lenient' # Set your desired verification mode (Lenient or Strict) + + $certificateTypes = @{ + 'RS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + } + 'RS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + } + 'RS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + } + 'PS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + } + 'PS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + } + 'PS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + } + 'ES256' = @{ + KeyType = 'ECDSA' + KeyLength = 256 + } + 'ES384' = @{ + KeyType = 'ECDSA' + KeyLength = 384 + } + 'ES512' = @{ + KeyType = 'ECDSA' + KeyLength = 521 + } + } + + $CertsPath = Join-Path -Path (Get-PodeServerPath) -ChildPath "certs" + if (!(Test-Path -Path $CertsPath -PathType Container)) { + New-Item -Path $CertsPath -ItemType Directory + } + foreach ($alg in $certificateTypes.Keys) { + $x509Certificate = New-PodeSelfSignedCertificate -Loopback -KeyType $certificateTypes[$alg].KeyType -KeyLength $certificateTypes[$alg].KeyLength -CertificatePurpose CodeSigning -Ephemeral -Exportable + + Export-PodeCertificate -Certificate $x509Certificate -Format PFX -Path (join-path -path $CertsPath -ChildPath $alg) + + # Define the authentication location dynamically (e.g., `/auth/bearer/jwt/{algorithm}`) + $pathRoute = "/auth/bearer/jwt/$alg" + # Register Pode Bearer Authentication + Write-PodeHost "🔹 Registering JWT Authentication for: $alg ($Location)" + + $rsaPaddingScheme = if ($alg.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + $param = @{ + Location = $Location + AsJWT = $true + RsaPaddingScheme = $rsaPaddingScheme + JwtVerificationMode = $JwtVerificationMode + X509Certificate = $x509Certificate + } + + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path $pathRoute -Authentication "Bearer_JWT_$alg" -ScriptBlock { + + Write-PodeJsonResponse -Value @{ + Users = @( + @{ + Name = 'Deep Thought' + Age = 42 + }, + @{ + Name = 'Leeroy Jenkins' + Age = 1337 + } + ) + } + } + } + + + + # setup bearer auth + New-PodeAuthBearerScheme -Location $Location -AsJWT -Secret (ConvertTo-SecureString 'your-256-bit-secret' -AsPlainText -Force) -JwtVerificationMode Lenient | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ScriptBlock { + + Write-PodeJsonResponse -Value @{ + Users = @( + @{ + Name = 'Deep Thought' + Age = 42 + }, + @{ + Name = 'Leeroy Jenkins' + Age = 1337 + } + ) + } + } + + + Register-PodeEvent -Type Stop -Name 'CleanCerts' -ScriptBlock { + if ( (Test-Path -Path "$(Get-PodeServerPath)/cert" -PathType Container)) { + Remove-Item -Path "$(Get-PodeServerPath)/cert" -Recurse -Force + Write-PodeHost "$(Get-PodeServerPath)/cert removed." + } + } +} \ No newline at end of file diff --git a/examples/Authentication/Web-AuthBearerJWTLifecycle.ps1 b/examples/Authentication/Web-AuthBearerJWTLifecycle.ps1 new file mode 100644 index 000000000..90fe58d93 --- /dev/null +++ b/examples/Authentication/Web-AuthBearerJWTLifecycle.ps1 @@ -0,0 +1,274 @@ +<# +.SYNOPSIS + A PowerShell script demonstrating the full lifecycle of JWT authentication using X.509 certificates in Pode. + +.DESCRIPTION + This script sets up a Pode server that listens on a specified port and enables JWT authentication using X.509 certificates. + It showcases the full JWT authentication lifecycle, including login, renewal, validation, and retrieval of user information. + Authentication is performed using JWT tokens signed with the selected cryptographic algorithm. + +.PARAMETER Location + Specifies where the API key (JWT token) is expected. + Valid values: 'Header', 'Query'. + Default: 'Header'. + +.PARAMETER Algorithm + Specifies the cryptographic algorithm used for JWT signing and verification. + Valid values: 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512'. + Default: 'ES512'. + +.EXAMPLE + # Run the sample + ./WebAuth-bearerJWT.ps1 + + JWT payload example: + { + "sub": "1234567890", + "name": "morty", + "username": "morty", + "type": "Human", + "id": "M0R7Y302", + "admin": true, + "iat": 1516239022, + "exp": 2634234231, + "iss": "auth.example.com", + "aud": "myapi.example.com", + "nbf": 1690000000, + "jti": "unique-token-id", + "role": "admin" + } + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthbearerJWTLifecycle.ps1 + +.NOTES + - This script uses Pode to create a lightweight web server with authentication. + - JWT authentication is handled via Bearer tokens passed in either the header or query parameters. + - JWTs are signed using X.509 certificates and verified based on the selected algorithm. + - The script implements endpoints for login, token renewal, and token validation. + - Ensure the private key is securely stored and managed for RS256-based JWT signing. + - Using query parameters for authentication is **discouraged** due to security risks. + - Always use HTTPS in production to protect sensitive authentication data. + + Author: Pode Team + License: MIT License +#> + +param( + [Parameter()] + [ValidateSet('Header', 'Query' )] + [string] + $Location = 'Header', + + [Parameter()] + [ValidateSet( 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')] + [string] + $Algorithm = 'ES512' +) + +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } + +# Define a function to autenticate user credentials +function Test-User { + param ( + [string]$username, + [string]$password + ) + if ($username -eq 'morty' -and $password -eq 'pickle') { + return @{ + Id = 'M0R7Y302' + Username = 'morty.smith' + Name = 'Morty Smith' + Groups = 'Domain Users' + } + } + throw 'Invalid credentials' +} + +# or just: +# Import-Module Pode + +# create a server, and start listening on port 8081 +Start-PodeServer -Threads 2 -ApplicationName 'webauth' { + + # listen on localhost:8081 + Add-PodeEndpoint -Address localhost -Port 8043 -Protocol Https -SelfSigned + + New-PodeLoggingMethod -File -Name 'requests' | Enable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + # Configure CORS + Set-PodeSecurityAccessControl -Origin '*' -Duration 7200 -WithOptions -AuthorizationHeader -autoMethods -AutoHeader -Credentials -CrossDomainXhrRequests + + + # Enable OpenAPI documentation + + Enable-PodeOpenApi -Path '/docs/openapi' -OpenApiVersion '3.0.3' -EnableSchemaValidation:($PSVersionTable.PSEdition -eq 'Core') -DisableMinimalDefinitions -NoDefaultResponses + Add-PodeOAInfo -Title 'JWT Test' -Version 1.0.17 -Description 'test' + Add-PodeOAServerEndpoint -url '/auth/bearer/jwt' -Description 'default endpoint' + # Enable OpenAPI viewers + Enable-PodeOAViewer -Type Swagger -Path '/docs/swagger' + Enable-PodeOAViewer -Type ReDoc -Path '/docs/redoc' -DarkMode + Enable-PodeOAViewer -Type RapiDoc -Path '/docs/rapidoc' -DarkMode + Enable-PodeOAViewer -Type StopLight -Path '/docs/stoplight' -DarkMode + Enable-PodeOAViewer -Type Explorer -Path '/docs/explorer' -DarkMode + Enable-PodeOAViewer -Type RapiPdf -Path '/docs/rapipdf' -DarkMode + + # Enable OpenAPI editor and bookmarks + Enable-PodeOAViewer -Editor -Path '/docs/swagger-editor' + Enable-PodeOAViewer -Bookmarks -Path '/docs' + + + $JwtVerificationMode = 'Strict' # Set your desired verification mode (Lenient or Strict) + # $SecurePassword = ConvertTo-SecureString 'MySecurePassword' -AsPlainText -Force + + $param = @{ + Location = $Location + AsJWT = $true + JwtVerificationMode = $JwtVerificationMode + SelfSigned = $true + } + # Register Pode Bearer Authentication + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_$Algorithm" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.id -ieq 'M0R7Y302') { + return @{ + User = @{ + ID = $jWt.id + Name = $jWt.name + Type = $jWt.type + sub = $jWt.Id + username = $jWt.Username + groups = $jWt.Groups + } + } + } + else { + write-podehost $jwt -Explode + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -PassThru -Method Get -Path "/auth/bearer/jwt/$Algorithm" -Authentication "Bearer_JWT_$Algorithm" -ScriptBlock { + Write-PodeJsonResponse -Value $WebEvent.auth.User + } | Set-PodeOARouteInfo -Summary 'Get my info.' -Tags 'user' -OperationId "myinfo_$Algorithm" + + + + Add-PodeRoute -PassThru -Method Post -Path '/auth/bearer/jwt/login' -ArgumentList $Algorithm -ScriptBlock { + param( + [string] + $Algorithm + ) + try { + # In a real scenario, you'd validate the incoming credentials from $WebEvent.data + $username = $WebEvent.Data.username + $password = $WebEvent.Data.password + $user = Test-User -username $username -password $password + + + $payload = @{ + sub = $user.Id + name = $user.Name + username = $user.Username + id = $user.Id + groups = $user.Groups + type = 'human' + } + + # If valid, generate a JWT that matches the 'ExampleApiKeyCert' scheme + $jwt = ConvertTo-PodeJwt -Payload $payload -Authentication "Bearer_JWT_$Algorithm" -Expiration 600 + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'user' = $user + 'token' = $jwt + } + + } + catch { + write-podehost $_.Exception.Message + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid credentials' } + } + } | Set-PodeOARouteInfo -Summary 'Logs user into the system.' -Tags 'user' -OperationId 'loginUser' -PassThru | + Set-PodeOARequest -RequestBody ( + New-PodeOARequestBody -Description 'Update an existent pet in the store' -Required -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOAStringProperty -Name 'username' -Description 'The user name for login' -Default 'morty' | + New-PodeOAStringProperty -Name 'password' -Description 'The password for login in clear text' -Format Password -Default 'pickle' | + New-PodeOAObjectProperty) + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOABoolProperty -Name 'success' -Description 'Operation success' -Example $true | + New-PodeOAStringProperty -Name 'user' -Description 'The user for login' -Example 'morty' | + New-PodeOAStringProperty -Name 'token' -Description 'Bearen JWT token' -Example '6656565' | + New-PodeOAObjectProperty + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 400 -Description 'Invalid username/password supplied' + + Add-PodeRoute -PassThru -Method Post -Path '/auth/bearer/jwt/renew' -Authentication "Bearer_JWT_$Algorithm" -ScriptBlock { + try { + + $jwt = Update-PodeJwt + + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'token' = $jwt + } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } | Set-PodeOARouteInfo -Summary 'Extend JWT Token.' -Tags 'JWT' -OperationId 'renewToken' -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOABoolProperty -Name 'success' -Description 'Operation success' -Example $true | + New-PodeOAStringProperty -Name 'user' -Description 'The user for login' -Example 'morty' | + New-PodeOAStringProperty -Name 'token' -Description 'Bearen JWT token' -Example 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6Ik0wUjdZMzAyIi ... UG9kZSJ9.hhU1fmykkSyZhUCr1NSZto-dGyt50r5OUlYj5SgL88EFlnulSOtsM-61tht-X5lEZVP7TCwG2q6ZELiA-4zey7BTIEecKg8zQ4NasZQi6eq9scSL0WJPNHNiGf91F1BsSAQmTxmtJz9-R9l7dxxonFlgLhq9ZwToPuAEK76lYuEQ45ERH-LoO5En9nRnar5N8SLe244To_T7UPKKBgd_DQNSuW4pShMbeK1_TTwELxroV2-d7bPyhUKIwrP61DDsGxgYCzsJ_8XG4YOfFg_u3bHp_JEplCFPoc5KUVNOQHFCzYR0WMZDhRDMnAF6J8Xn0RKTsFB7q1QNC0NF1-7TGQ' | + New-PodeOAObjectProperty + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 401 -Description 'Invalid JWT token supplied' + + + Add-PodeRoute -PassThru -Method Get -Path '/auth/bearer/jwt/info' -Authentication "Bearer_JWT_$Algorithm" -ScriptBlock { + try { + $jwtInfo = ConvertFrom-PodeJwt -Outputs 'Header,Payload,Signature' -HumanReadable + $jwtInfo.success = $true + Write-PodeJsonResponse -StatusCode 200 -Value $jwtInfo + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } | Set-PodeOARouteInfo -Summary 'return JWT Token info.' -Tags 'JWT' -OperationId 'getInfoToken' -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOAObjectProperty -Properties ( + ( New-PodeOABoolProperty -Name 'success' -Description 'Operation success' -Example $true), + (New-PodeOAObjectProperty -Name Header ), + ( New-PodeOAObjectProperty -Name Payload), ( New-PodeOAStringProperty -Name Signature) + ) + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 401 -Description 'Invalid JWT token supplied' + +} \ No newline at end of file diff --git a/examples/Authentication/Web-AuthDigest.ps1 b/examples/Authentication/Web-AuthDigest.ps1 new file mode 100644 index 000000000..779df40cc --- /dev/null +++ b/examples/Authentication/Web-AuthDigest.ps1 @@ -0,0 +1,215 @@ +<# +.SYNOPSIS + PowerShell script to set up a Pode server with Digest authentication or make client requests. + +.DESCRIPTION + This script can either: + - Start a Pode server that listens on a specified port and uses Digest authentication to secure access. + - Act as a client to send requests with Digest authentication. + + The authentication details are checked against predefined user data. + For non-MD5 algorithms, use ./utility/DigestClient.ps1. + +.PARAMETER Client + If specified, the script runs in client mode instead of starting a server. + +.PARAMETER Algorithm + The Digest authentication algorithm(s) to use. Supported values: MD5, SHA-1, SHA-256, SHA-512, SHA-384, SHA-512/256. + Defaults to all supported algorithms. + +.PARAMETER QualityOfProtection + Specifies the Quality of Protection (qop) to use in Digest authentication. + Valid options: + - 'auth': Authentication only. + - 'auth-int': Authentication with integrity protection. + - 'auth,auth-int': Support both modes. + +.EXAMPLE + To start the Pode server with default settings: + ```powershell + ./Web-AuthDigest.ps1 + ``` + +.EXAMPLE + To start the Pode server with SHA-256 authentication only: + ```powershell + ./Web-AuthDigest.ps1 -Algorithm SHA-256 + ``` + +.EXAMPLE + To run in client mode and send a Digest-authenticated request: + ```powershell + ./Web-AuthDigest.ps1 -Client + ``` + +.EXAMPLE + Client request example using default .Net Digest support: + + ```powershell + # Define the URI and credentials + $uri = [System.Uri]::new("http://localhost:8081/users") + $username = "morty" + $password = "pickle" + + # Create a credential cache and add Digest authentication + $credentialCache = [System.Net.CredentialCache]::new() + $networkCredential = [System.Net.NetworkCredential]::new($username, $password) + $credentialCache.Add($uri, "Digest", $networkCredential) + + # Create the HTTP client handler with the credential cache + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.Credentials = $credentialCache + + # Create the HTTP client + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + # Send the GET request + $requestMessage = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri) + $response = $httpClient.SendAsync($requestMessage).Result + + # Display response headers and content + $response.Headers | ForEach-Object { "$($_.Key): $($_.Value)" } + $content = $response.Content.ReadAsStringAsync().Result + $content + ``` +.EXAMPLE + Client request example using `Invoke-WebRequestDigest`: + + ```powershell + Import-Module './client/Invoke-Digest.psm1' + + # Define the URI and credentials + $uri = 'http://localhost:8081/users' + $username = 'morty' + $password = 'pickle' + + # Convert the password to a SecureString and create a credential object + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + + # Make a GET request using Digest authentication + $response = Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential + + # Display response headers and content + $response.Headers | Format-List + Write-Output $response.Content + ``` + +.EXAMPLE + Running the server with `auth-int` quality of protection: + ```powershell + ./Web-AuthDigest.ps1 -QualityOfProtection auth-int + ``` + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthDigest.ps1 + +.NOTES + Author: Pode Team + License: MIT License +#> + +[CmdletBinding(DefaultParameterSetName = 'Server')] +param( + [Parameter(ParameterSetName = 'Client')] + [switch] + $Client, + + [Parameter(ParameterSetName = 'Server')] + [string[]] + $Algorithm = @('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256'), + + [Parameter(ParameterSetName = 'Server')] + [ValidateSet('auth', 'auth-int', 'auth,auth-int' )] + [string[]] + $QualityOfProtection = 'auth,auth-int' +) +if ($Client) { + Import-Module './Modules/Invoke-Digest.psm1' + $uri = 'http://localhost:8081/users' + $username = 'morty' + $password = 'pickle' + + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + + $response = Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential + $response | Format-List * + + Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential -OutFile 'outfile.json' + + $response = Invoke-RestMethodDigest -Uri $uri -Method 'GET' -Credential $credential + $response + return +} +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } + +# or just: +# Import-Module Pode + +# create a server, and start listening on port 8081 +Start-PodeServer -Threads 2 { + + # listen on localhost:8081 + Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http + + # setup digest auth + New-PodeAuthDigestScheme -Algorithm $Algorithm -QualityOfProtection $QualityOfProtection | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { + param($username, $params) + + # here you'd check a real user storage, this is just for example + if ($username -ieq 'morty') { + return @{ + User = @{ + ID = 'M0R7Y302' + Name = 'Morty' + Type = 'Human' + } + Password = 'pickle' + } + } + + return $null + } + # If QualityOfProtection is 'auth-int' skip GET because it is not supported + if ($QualityOfProtection -ne 'auth-int') { + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ErrorContentType 'application/json' -ScriptBlock { + Write-PodeJsonResponse -Value @{ + Users = @( + @{ + Name = 'Deep Thought' + Age = 42 + }, + @{ + Name = 'Leeroy Jenkins' + Age = 1337 + } + ) + } + } + } + + Add-PodeRoute -Method Post -Path '/users' -Authentication 'Validate' -ErrorContentType 'application/json' -ScriptBlock { + if ($WebEvent.data) { + Write-PodeJsonResponse -Value $WebEvent.data -StatusCode 200 + } + else { + Write-PodeJsonResponse -Value @{success = $false } -StatusCode 400 + } + } + +} \ No newline at end of file diff --git a/examples/Web-AuthForm.ps1 b/examples/Authentication/Web-AuthForm.ps1 similarity index 92% rename from examples/Web-AuthForm.ps1 rename to examples/Authentication/Web-AuthForm.ps1 index 45fad7a19..095f6539d 100644 --- a/examples/Web-AuthForm.ps1 +++ b/examples/Authentication/Web-AuthForm.ps1 @@ -22,7 +22,7 @@ You will be redirected to the login page, where you can log in with the credentials provided above. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthForm.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthForm.ps1 .NOTES Author: Pode Team @@ -31,7 +31,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -63,7 +63,7 @@ Start-PodeServer -Threads 2 { Enable-PodeSessionMiddleware -Duration 120 -Extend # setup form auth (
in HTML) - New-PodeAuthScheme -Form | Add-PodeAuth -Name 'Login' -FailureUrl '/login' -SuccessUrl '/' -ScriptBlock { + New-PodeAuthScheme -Form | Add-PodeAuth -Name 'Login' -FailureUrl '/login' -SuccessUrl '/' -ScriptBlock { param($username, $password) # here you'd check a real user storage, this is just for example diff --git a/examples/Web-AuthFormAccess.ps1 b/examples/Authentication/Web-AuthFormAccess.ps1 similarity index 95% rename from examples/Web-AuthFormAccess.ps1 rename to examples/Authentication/Web-AuthFormAccess.ps1 index 3bf19c479..af5e82327 100644 --- a/examples/Web-AuthFormAccess.ps1 +++ b/examples/Authentication/Web-AuthFormAccess.ps1 @@ -23,7 +23,7 @@ - The Register page is only accessible by QAs (for morty this will 403) .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormAccess.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormAccess.ps1 .NOTES Author: Pode Team @@ -31,7 +31,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormAd.ps1 b/examples/Authentication/Web-AuthFormAd.ps1 similarity index 94% rename from examples/Web-AuthFormAd.ps1 rename to examples/Authentication/Web-AuthFormAd.ps1 index 78bf5eedd..bc87990dd 100644 --- a/examples/Web-AuthFormAd.ps1 +++ b/examples/Authentication/Web-AuthFormAd.ps1 @@ -19,7 +19,7 @@ the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormAd.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormAd.ps1 .NOTES Author: Pode Team @@ -27,7 +27,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormAnon.ps1 b/examples/Authentication/Web-AuthFormAnon.ps1 similarity index 95% rename from examples/Web-AuthFormAnon.ps1 rename to examples/Authentication/Web-AuthFormAnon.ps1 index 3860d5ed1..488734389 100644 --- a/examples/Web-AuthFormAnon.ps1 +++ b/examples/Authentication/Web-AuthFormAnon.ps1 @@ -24,7 +24,7 @@ Invoke-RestMethod -Uri http://localhost:8081/ -Method Get .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormAnon.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormAnon.ps1 .NOTES Author: Pode Team @@ -32,7 +32,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormCreds.ps1 b/examples/Authentication/Web-AuthFormCreds.ps1 similarity index 95% rename from examples/Web-AuthFormCreds.ps1 rename to examples/Authentication/Web-AuthFormCreds.ps1 index 282d7a2bc..ba1eb2f8f 100644 --- a/examples/Web-AuthFormCreds.ps1 +++ b/examples/Authentication/Web-AuthFormCreds.ps1 @@ -23,7 +23,7 @@ #logout url .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormCreds.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormCreds.ps1 .NOTES Author: Pode Team @@ -32,7 +32,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormFile.ps1 b/examples/Authentication/Web-AuthFormFile.ps1 similarity index 94% rename from examples/Web-AuthFormFile.ps1 rename to examples/Authentication/Web-AuthFormFile.ps1 index 70f55c320..dac8ce0dc 100644 --- a/examples/Web-AuthFormFile.ps1 +++ b/examples/Authentication/Web-AuthFormFile.ps1 @@ -21,7 +21,7 @@ password = pickle .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormFile.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormFile.ps1 .NOTES Author: Pode Team @@ -30,7 +30,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormLocal.ps1 b/examples/Authentication/Web-AuthFormLocal.ps1 similarity index 94% rename from examples/Web-AuthFormLocal.ps1 rename to examples/Authentication/Web-AuthFormLocal.ps1 index 3c55cafbd..9d2ef3373 100644 --- a/examples/Web-AuthFormLocal.ps1 +++ b/examples/Authentication/Web-AuthFormLocal.ps1 @@ -17,7 +17,7 @@ Clicking 'Logout' will purge the session and take you back to the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormLocal.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormLocal.ps1 .NOTES Author: Pode Team @@ -25,7 +25,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormMerged.ps1 b/examples/Authentication/Web-AuthFormMerged.ps1 similarity index 95% rename from examples/Web-AuthFormMerged.ps1 rename to examples/Authentication/Web-AuthFormMerged.ps1 index 8345b7101..11f03d2b3 100644 --- a/examples/Web-AuthFormMerged.ps1 +++ b/examples/Authentication/Web-AuthFormMerged.ps1 @@ -18,7 +18,7 @@ take you back to the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormMerged.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormMerged.ps1 .NOTES Author: Pode Team @@ -26,7 +26,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormSessionAuth.ps1 b/examples/Authentication/Web-AuthFormSessionAuth.ps1 similarity index 95% rename from examples/Web-AuthFormSessionAuth.ps1 rename to examples/Authentication/Web-AuthFormSessionAuth.ps1 index 6545ad9bb..440939700 100644 --- a/examples/Web-AuthFormSessionAuth.ps1 +++ b/examples/Authentication/Web-AuthFormSessionAuth.ps1 @@ -19,7 +19,7 @@ take you back to the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormSessionAuth.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormSessionAuth.ps1 .NOTES Author: Pode Team @@ -28,7 +28,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthMerged.ps1 b/examples/Authentication/Web-AuthMerged.ps1 similarity index 95% rename from examples/Web-AuthMerged.ps1 rename to examples/Authentication/Web-AuthMerged.ps1 index c349487c3..1d7b51651 100644 --- a/examples/Web-AuthMerged.ps1 +++ b/examples/Authentication/Web-AuthMerged.ps1 @@ -17,15 +17,15 @@ Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -Headers @{ 'X-API-KEY' = 'test-api-key'; Authorization = 'Basic bW9ydHk6cmljaw==' .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthMerged.ps1 - + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthMerged.ps1 + .NOTES Author: Pode Team License: MIT License #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthNegotiate.ps1 b/examples/Authentication/Web-AuthNegotiate.ps1 similarity index 89% rename from examples/Web-AuthNegotiate.ps1 rename to examples/Authentication/Web-AuthNegotiate.ps1 index d63a9ba6c..1e1361c56 100644 --- a/examples/Web-AuthNegotiate.ps1 +++ b/examples/Authentication/Web-AuthNegotiate.ps1 @@ -13,7 +13,7 @@ Invoke-RestMethod -Uri 'http://pode.example.com:8080' -UseDefaultCredentials .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthNegotiate.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthNegotiate.ps1 .NOTES Author: Pode Team @@ -22,7 +22,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthOauth2.ps1 b/examples/Authentication/Web-AuthOauth2.ps1 similarity index 93% rename from examples/Web-AuthOauth2.ps1 rename to examples/Authentication/Web-AuthOauth2.ps1 index befdf7f68..9ab1001c3 100644 --- a/examples/Web-AuthOauth2.ps1 +++ b/examples/Authentication/Web-AuthOauth2.ps1 @@ -13,7 +13,7 @@ There, login to Azure and you'll be redirected back to the home page .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthOauth2.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthOauth2.ps1 .NOTES Author: Pode Team @@ -23,7 +23,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthOauth2Form.ps1 b/examples/Authentication/Web-AuthOauth2Form.ps1 similarity index 94% rename from examples/Web-AuthOauth2Form.ps1 rename to examples/Authentication/Web-AuthOauth2Form.ps1 index a2f546c3a..5b79a8317 100644 --- a/examples/Web-AuthOauth2Form.ps1 +++ b/examples/Authentication/Web-AuthOauth2Form.ps1 @@ -13,7 +13,7 @@ There, enter you Azure AD email/password, Pode with authenticate and then take you to the home page .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormCreds.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormCreds.ps1 .NOTES Author: Pode Team @@ -23,7 +23,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthOauth2Oidc.ps1 b/examples/Authentication/Web-AuthOauth2Oidc.ps1 similarity index 93% rename from examples/Web-AuthOauth2Oidc.ps1 rename to examples/Authentication/Web-AuthOauth2Oidc.ps1 index c090be8da..51f62f7c6 100644 --- a/examples/Web-AuthOauth2Oidc.ps1 +++ b/examples/Authentication/Web-AuthOauth2Oidc.ps1 @@ -13,8 +13,8 @@ There, login to Google account and you'll be redirected back to the home page .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthOauth2Oidc.ps1 - + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthOauth2Oidc.ps1 + .NOTES Author: Pode Team License: MIT License @@ -23,7 +23,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-UsePodeAuth.ps1 b/examples/Authentication/Web-UsePodeAuth.ps1 similarity index 93% rename from examples/Web-UsePodeAuth.ps1 rename to examples/Authentication/Web-UsePodeAuth.ps1 index d72c8ff20..bfcee91f8 100644 --- a/examples/Web-UsePodeAuth.ps1 +++ b/examples/Authentication/Web-UsePodeAuth.ps1 @@ -25,7 +25,7 @@ ``` .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-UsePodeAuth.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-UsePodeAuth.ps1 .NOTES The `Use-PodeAuth` function is used to load the authentication script located at `./auth/SampleAuth.ps1`. The @@ -45,7 +45,7 @@ param( try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthApiKey.ps1 b/examples/Authentication/WebAuth-ApikeyJWT.ps1 similarity index 93% rename from examples/Web-AuthApiKey.ps1 rename to examples/Authentication/WebAuth-ApikeyJWT.ps1 index 6addc1971..025efdf9e 100644 --- a/examples/Web-AuthApiKey.ps1 +++ b/examples/Authentication/WebAuth-ApikeyJWT.ps1 @@ -15,7 +15,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Get .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthApiKey.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthApiKey.ps1 .NOTES Use: @@ -34,7 +34,7 @@ param( try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/auth/SampleAuth.ps1 b/examples/Authentication/auth/SampleAuth.ps1 similarity index 100% rename from examples/auth/SampleAuth.ps1 rename to examples/Authentication/auth/SampleAuth.ps1 diff --git a/examples/Authentication/client/New-JwtKeyPair.ps1 b/examples/Authentication/client/New-JwtKeyPair.ps1 new file mode 100644 index 000000000..6dc27f843 --- /dev/null +++ b/examples/Authentication/client/New-JwtKeyPair.ps1 @@ -0,0 +1,150 @@ +<# +.SYNOPSIS + Generates JWT key pairs for testing and example purposes. + +.DESCRIPTION + This utility generates RSA and ECDSA key pairs based on the specified mode: + - "Test" mode: Keys are created under "./tests/certs" + - "Example" mode: Keys are created under "./examples/certs" + +.PARAMETER Mode + Specifies the mode of key generation. Accepts "Test" or "Example". + +.PARAMETER Algorithm + Specifies the algorithms to generate keys for. Accepts an array of values (e.g., "RS256", "ES256") or "ALL". + +.OUTPUTS + PEM-encoded private and public key files. + +.EXAMPLE + # Generate all keys for testing + .\New-JwtKeyPair.ps1 -Mode Test + +.EXAMPLE + # Generate only RS256 and ES256 keys for examples + .\New-JwtKeyPair.ps1 -Mode Example -Algorithm RS256,ES256 + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/utilities/New-JwtKeyPair.ps1 + +.NOTES + - Keys are stored in the respective directories: "./tests/certs" or "./examples/certs" + - Requires PowerShell 7+ + +.NOTES + Author: Pode Team + License: MIT License +#> + +param ( + [Parameter(Mandatory = $true)] + [ValidateSet('Test', 'Example')] + [string]$Mode, + + [string[]]$Algorithm = @('ALL') +) + + + +### Helper Functions for Key Export ### +function Export-RsaPrivateKeyPem { + param ( + [System.Security.Cryptography.RSA]$RsaKey + ) + $pemHeader = '-----BEGIN RSA PRIVATE KEY-----' + $pemFooter = '-----END RSA PRIVATE KEY-----' + $base64 = [Convert]::ToBase64String($RsaKey.ExportRSAPrivateKey(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + + +function Export-RsaPublicKeyPem { + param ([System.Security.Cryptography.RSA]$RsaKey) + $pemHeader = '-----BEGIN RSA PUBLIC KEY-----' + $pemFooter = '-----END RSA PUBLIC KEY-----' + $base64 = [Convert]::ToBase64String($RsaKey.ExportRSAPublicKey(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + +function Export-EcdsaPrivateKeyPem { + param ([System.Security.Cryptography.ECDsa]$EcdsaKey) + $pemHeader = '-----BEGIN EC PRIVATE KEY-----' + $pemFooter = '-----END EC PRIVATE KEY-----' + $base64 = [Convert]::ToBase64String($EcdsaKey.ExportECPrivateKey(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + +function Export-EcdsaPublicKeyPem { + param ([System.Security.Cryptography.ECDsa]$EcdsaKey) + $pemHeader = '-----BEGIN PUBLIC KEY-----' + $pemFooter = '-----END PUBLIC KEY-----' + $base64 = [Convert]::ToBase64String($EcdsaKey.ExportSubjectPublicKeyInfo(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + +# Determine output directory based on mode +$RootPath = Split-Path -Parent $MyInvocation.MyCommand.Path +$BaseOutputDirectory = if ($Mode -eq 'Test') { "$RootPath/../../tests/certs" } else { "$RootPath/../../examples/certs" } + +if (Test-Path -Path $BaseOutputDirectory) { + Remove-Item -Path "$BaseOutputDirectory/*.pem" +} +else { + New-Item -Path $BaseOutputDirectory -ItemType Directory +} + +# Key settings mapping +$keySettings = @{ + 'RS256' = 2048 + 'RS384' = 3072 + 'RS512' = 4096 + 'ES256' = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP256') + 'ES384' = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP384') + 'ES512' = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP521') +} + +# Ensure output directory exists +if (-Not (Test-Path $BaseOutputDirectory)) { + New-Item -ItemType Directory -Path $BaseOutputDirectory -Force | Out-Null +} + +# Determine algorithms to generate +$algorithmsToGenerate = if ($Algorithm -contains 'ALL') { $keySettings.Keys } else { $Algorithm } + +foreach ($alg in $algorithmsToGenerate) { + if (-Not $keySettings.ContainsKey($alg)) { + Write-Output "❌ Unsupported algorithm: $alg. Skipping..." + Continue + } + + $privateKeyPath = "$BaseOutputDirectory/$alg-private.pem" + $publicKeyPath = "$BaseOutputDirectory/$alg-public.pem" + + Write-Output "🔹 Generating keys for: $alg..." + + if ($alg -match '^RS') { + $rsa = [System.Security.Cryptography.RSA]::Create($keySettings[$alg]) + + $privatePem = Export-RsaPrivateKeyPem $rsa + Set-Content -Path $privateKeyPath -Value $privatePem + + $publicPem = Export-RsaPublicKeyPem $rsa + Set-Content -Path $publicKeyPath -Value $publicPem + } + elseif ($alg -match '^ES') { + $ec = [System.Security.Cryptography.ECDsa]::Create($keySettings[$alg]) + if ($null -eq $ec) { + throw "Failed to create ECDSA key for $alg. Ensure your system supports ECC." + } + + $privatePem = Export-EcdsaPrivateKeyPem $ec + Set-Content -Path $privateKeyPath -Value $privatePem + + $publicPem = Export-EcdsaPublicKeyPem $ec + Set-Content -Path $publicKeyPath -Value $publicPem + } + + Write-Output "✅ Keys generated: $privateKeyPath & $publicKeyPath" +} + +Write-Output "🎉 All requested keys generated successfully in: $BaseOutputDirectory" diff --git a/examples/Authentication/client/Test-BearerClient.ps1 b/examples/Authentication/client/Test-BearerClient.ps1 new file mode 100644 index 000000000..57c4ff76a --- /dev/null +++ b/examples/Authentication/client/Test-BearerClient.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS + PowerShell script to test JWT authentication against a Pode server. + +.DESCRIPTION + This script performs authentication tests against a Pode server using JWT bearer tokens. + It iterates over multiple JWT signing algorithms, generates tokens, and sends authenticated + requests to verify the implementation. + + - Supports RSA (`RS256`, `RS384`, `RS512`), PSS (`PS256`, `PS384`, `PS512`), and EC (`ES256`, `ES384`, `ES512`) algorithms. + - Checks for the availability of private keys before attempting authentication. + - Uses `ConvertTo-PodeJwt` for JWT generation. + - Sends requests to the Pode authentication API and validates responses. + +.PARAMETER ApiBaseUrl + The base URL of the Pode authentication endpoint. + +.EXAMPLE + # Run the script to test JWT authentication + ./Test-BearerClient.ps1 + +.EXAMPLE + # Manually specify the authentication API URL + $uri = "http://localhost:8081/auth/bearer/jwt" + ./Test-BearerClient.ps1 -ApiBaseUrl $uri + + .LINK + https://github.com/Badgerati/Pode/blob/develop/examples/utilities/Test-BearerClient.ps1 + +.NOTES + - **JWT Authentication Overview:** + - The script loads private keys for multiple algorithms. + - It generates JWTs using `ConvertTo-PodeJwt` with a test payload. + - Each JWT is used to authenticate a request against the Pode API. + - Responses are validated and displayed in JSON format. + + - **Pode Compatibility:** + - Pode supports various JWT signing algorithms. + - Ensure Pode is configured with `New-PodeAuthScheme -BearerJwt` for JWT authentication. + + - **Security Considerations:** + - Keep private key files secure. + - Use strong signing algorithms (e.g., `RS512`, `PS512`, `ES512`). + - Ensure HTTPS is used in production environments. + +.NOTES + Author: Pode Team + License: MIT License +#> +param ( + [string] + $ApiBaseUrl = 'http://localhost:8081/auth/bearer/jwt' +) + +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path))) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } + + # Define the key storage path + $certsPath = Join-Path -Path $podePath -ChildPath 'examples/Authentication/certs' +} +catch { throw } + +$ApiBaseUrl = 'http://localhost:8081/auth/bearer/jwt' + +Write-Output 'Starting JWT Authentication Tests...' +Write-Output "Checking if certificates directory exists: $certsPath" + +if (-Not (Test-Path $certsPath)) { + Write-Error "Certificate directory does not exist: $certsPath" + Exit +} + + +$algorithms = 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512' +# $algorithms = 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512' +foreach ($alg in $algorithms) { + Write-Output '-----------------------------------------------' + Write-Output "Testing Algorithm: $alg" + + + #$securePassword = ConvertTo-SecureString 'MySecurePassword' -AsPlainText -Force + $privateKeyPath = "$certsPath/$alg.pfx" + $rsaPaddingScheme = if ($alg.StartsWith('PS')) { + 'Pss' + } + else { + 'Pkcs1V15' + } + + + + if (-Not (Test-Path $privateKeyPath)) { + Write-Warning "Skipping $($alg): Private key file not found ($privateKeyPath)" + Continue + } + + + Write-Output "Generating JWT for $alg..." + + try { + $jwt = ConvertTo-PodeJwt -Certificate $privateKeyPath -RsaPaddingScheme $rsaPaddingScheme -Payload @{ + id = 'id' + name = 'Morty' + Type = 'Human' + username = 'morty' + } + ConvertFrom-PodeJwt -Token $jwt -Certificate $privateKeyPath -RsaPaddingScheme $rsaPaddingScheme + $apiUrl = "$ApiBaseUrl/$alg" + + } + catch { + Write-Error "JWT generation failed for $($alg): $_" + Continue + } + Write-Output "JWT successfully generated for $alg" + + $headers = @{ + 'Authorization' = "Bearer $jwt" + 'Accept' = 'application/json' + } + + Write-Output "Sending request to: $apiUrl" + + try { + $response = Invoke-RestMethod -Uri $apiUrl -Method Get -Headers $headers + Write-Output "Response for $($alg): $($response | ConvertTo-Json -Depth 2)" + } + catch { + Write-Error "API request failed for $($alg): $_" + } + + Write-Output 'Waiting 3 seconds before next test...' + Start-Sleep 3 +} + +Write-Output 'All JWT authentication tests completed!' diff --git a/examples/Authentication/outfile.json b/examples/Authentication/outfile.json new file mode 100644 index 000000000..552651dfe --- /dev/null +++ b/examples/Authentication/outfile.json @@ -0,0 +1 @@ +{"Users":[{"Age":42,"Name":"Deep Thought"},{"Age":1337,"Name":"Leeroy Jenkins"}]} diff --git a/examples/OpenApi-TuttiFrutti.ps1 b/examples/OpenApi-TuttiFrutti.ps1 index d5c739e60..99e0c1f90 100644 --- a/examples/OpenApi-TuttiFrutti.ps1 +++ b/examples/OpenApi-TuttiFrutti.ps1 @@ -20,6 +20,9 @@ Ignores the server.psd1 configuration file when starting the server. This parameter ensures the server does not load or apply any settings defined in the server.psd1 file, allowing for a fully manual configuration at runtime. +.PARAMETER ShowOpenAPI + Show the OpenAPI definition on console + .EXAMPLE To run the sample: ./OpenApi-TuttiFrutti.ps1 @@ -49,7 +52,10 @@ param( $Daemon, [switch] - $IgnoreServerConfig + $IgnoreServerConfig, + + [switch] + $ShowOpenAPI ) try { @@ -371,7 +377,7 @@ Some useful links: return @{ User = $user } } # jwt with no signature: - New-PodeAuthScheme -Bearer -AsJWT | Add-PodeAuth -Name 'Jwt' -Sessionless -ScriptBlock { + New-PodeAuthBearerScheme -AsJWT | Add-PodeAuth -Name 'Jwt' -Sessionless -ScriptBlock { param($payload) return ConvertFrom-PodeJwt -Token $payload @@ -1028,11 +1034,12 @@ Some useful links: } } + if ($ShowOpenAPI) { + $yaml = Get-PodeOADefinition -Format Yaml -DefinitionTag 'v3.1' + $json = Get-PodeOADefinition -Format Json -DefinitionTag 'v3' - $yaml = Get-PodeOADefinition -Format Yaml -DefinitionTag 'v3.1' - $json = Get-PodeOADefinition -Format Json -DefinitionTag 'v3' + Write-PodeHost "`rYAML Tag: v3.1 Output:`r $yaml" - Write-PodeHost "`rYAML Tag: v3.1 Output:`r $yaml" - - Write-PodeHost "`rJSON Tag: v3 Output:`r $json" + Write-PodeHost "`rJSON Tag: v3 Output:`r $json" + } } \ No newline at end of file diff --git a/examples/Web-AuthDigest.ps1 b/examples/Web-AuthDigest.ps1 deleted file mode 100644 index 7c26eef53..000000000 --- a/examples/Web-AuthDigest.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -<# -.SYNOPSIS - PowerShell script to set up a Pode server with Digest authentication. - -.DESCRIPTION - This script sets up a Pode server that listens on a specified port and uses Digest authentication - for securing access to the server. The authentication details are checked against predefined user data. - -.EXAMPLE - To run the sample: ./Web-AuthDigest.ps1 - - Invoke-RestMethod -Uri http://localhost:8081/users -Method Get - -.LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthDigest.ps1 - -.NOTES - Author: Pode Team - License: MIT License -#> -try { - # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) - $podePath = Split-Path -Parent -Path $ScriptPath - - # Import the Pode module from the source path if it exists, otherwise from installed modules - if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { - Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop - } - else { - Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop - } -} -catch { throw } - -# or just: -# Import-Module Pode - -# create a server, and start listening on port 8081 -Start-PodeServer -Threads 2 { - - # listen on localhost:8081 - Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http - - # setup digest auth - New-PodeAuthScheme -Digest | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { - param($username, $params) - - # here you'd check a real user storage, this is just for example - if ($username -ieq 'morty') { - return @{ - User = @{ - ID ='M0R7Y302' - Name = 'Morty' - Type = 'Human' - } - Password = 'pickle' - } - } - - return $null - } - - # GET request to get list of users (since there's no session, authentication will always happen) - Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ScriptBlock { - Write-PodeJsonResponse -Value @{ - Users = @( - @{ - Name = 'Deep Thought' - Age = 42 - }, - @{ - Name = 'Leeroy Jenkins' - Age = 1337 - } - ) - } - } - -} \ No newline at end of file diff --git a/examples/certs/cert.pem b/examples/certs/cert.pem deleted file mode 100644 index fe7f9249a..000000000 --- a/examples/certs/cert.pem +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDYDCCAkigAwIBAgIJAKe/1qpK2+6+MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMjEwODA1MTUzODE1WhcNMjIwODA1MTUzODE1WjBF -MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 -ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEA4JoZM7i3KJHC8EOzBRO8bmSAVoRIjvLD2O1B0Axv9+2wQlHByZZZP2Pp -bY3wahczUvDqihGV0iQDeKDUvETnh4MrrJaeXqyt/t9/5wVeuH10p5tfaYe4Mqwd -tVxyAsjLB41445fTGkwvmJi0Ka19GFx232DAT28p5kc8VLb/XXAjuEzeccBOvw53 -ZOSbANg3g2G7m/GYeehtK9vh8FQTDtnMXer6WZ0QNBSF9898KpC1WQsF89t24Ox7 -SJs3uWjFqBwKMbEl6fUMg7I26DY7pYIgKu5QAfUgdF8LZcPv5Fu8c0Np/XdcpuVI -1S4jVmo4xf7/Tr3fFPNJ/r5im19UhwIDAQABo1MwUTAdBgNVHQ4EFgQUbHM52jhC -bekyuSuu+R6hwpCrB10wHwYDVR0jBBgwFoAUbHM52jhCbekyuSuu+R6hwpCrB10w -DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAy9mnBTkRWHVwK/t8 -E2nYkoDqwwa+dO852JGrl39vzJR02iQUMKF7OFGPf33VmsgJFyznNzZWnOnqg7l4 -6RH5vSymdC7MShKp/jKzScdN1iGS0OFn6ZzJLLVilxj3WMfX3ivv0VdOq46OmRhl -3ymVdqWNXN9Mkz8pPeS2IcluMsy4YAjd76vTLeVpcruKjHT/zYv+mvLilzmCUNO7 -ZEgv8Z/rCGahaGstBnXecLv/fYhAtciEsjVZuwQO74YWSckNnFCxltMFEp4isskU -KA452y1/mdHc/GADPry9Fitjus2cjBIKuw0vwOZlx9nzk/oE4K41SNk41rxEVhYt -azEiUw== ------END CERTIFICATE----- diff --git a/examples/certs/cert_nodes.pem b/examples/certs/cert_nodes.pem deleted file mode 100644 index 23d81a518..000000000 --- a/examples/certs/cert_nodes.pem +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDYDCCAkigAwIBAgIJAJc72KSCiuAkMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMjEwODA1MTUzODAwWhcNMjIwODA1MTUzODAwWjBF -MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 -ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEArvYj+XMKYRfU3cKXfeDeX0Tv8zPnZ9hc/lTBc3UNifBjzcReLKbTDz76 -IxI6rmNFcRXJbVDOTE03pIBsqs4+haaGYtwd4tyvTex4iUfURd887WaOwjCtsMsM -w3Pgll8SpZaXvuWboZUWNTtgWNWQ5BhYFQhxcI+iuJecy2WqHkbG9UPjsF7slyAT -eGnuB9O2xAV9qapVMWz10CwqW0HWeaHcLqqM1XGyNxTtU+RkUV16+Qb1rW901Ck+ -mDXoUs4O4UW9jVHKw03DKlv/9v1lnOszveKXvtriOBS3nhFeCpej0TYFvExoE2G1 -dOEvDxBvDkRT0TXVH/VuZ5qBp2qp1wIDAQABo1MwUTAdBgNVHQ4EFgQUpUllOx5o -M+22/y06FYvhKf/ZGyAwHwYDVR0jBBgwFoAUpUllOx5oM+22/y06FYvhKf/ZGyAw -DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAfi/dniGTslG4MF7d -p2KxF2/VsXYVnjp4EfpIUBfQwF/6yn3jUJSunWc/iisLp0GXvmyAk56eDBQEWdx+ -cogIKbKAhrMk0JbmRNRJN+6DrKlstgqcP3bVMDYKRD82VrzfcdtMeI70PwH55wPU -V3UHmlni4Gs2js48TjRwypCvTTozNrkDiC89FrlQ7+PhQCcG26GZZpSMXP54allO -U2aQrQv0zCYi1gZwyQ0px/2vPkOD4HEmbEk7hGFkaM88+XutyDxfYG21glz4JzsQ -TPicqsfAItoaOPZ3wQ2wEMhjyum6/V5VoXRJ17lFtG1Vy8GfTt8zTkZyGkGx/zTN -xBJkHQ== ------END CERTIFICATE----- diff --git a/examples/certs/key.pem b/examples/certs/key.pem deleted file mode 100644 index 3cf77f1f9..000000000 --- a/examples/certs/key.pem +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN ENCRYPTED PRIVATE KEY----- -MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIXZcbs4qNi2kCAggA -MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECIpXQr1lKOXJBIIEyBnrPhHod9qi -aVphPgF0IBTKrRJfWlzkGg/FuUvhxRfJd9xeozt6eCYWYV4TWAJj8mGbDr2pORyd -4loI6Bo3HtpW1WHZmYP4X7OLkCCjNYo/LoE7AX325bkmhs6AWOggWkK072oygRds -qnHXdyzQV0u74mYu1u4yBXhTxzllfgwvKZ+ChrloMcXAgz5+7cS06Jzw97WDjoJG -lTAHpBlFptP+SEfiG/PP78NA+zspKs9yijtRD5aD/pOHsN943AfbxGimd1vK+Xma -6HpCqrdljrgg+/On5jKl9ffOdczyhqZFJyaqgEcZgQiVQwUilz8JK5Y1qqv29pm7 -zvxebdt/PkMlzGr5E4X/Nu3iKk2BhnwSS6W9B7u/bGaH97uHE04CTi9U3SV9H+g9 -JUYTxMfkJ5sdnlxfEo1jjS8YKg/+V2P5bXd5bEp7SgtmoUz/IcDfctiLDUV9Xh/E -JXlEcPq2cx+wpc3fN+bC42ZLoEbbxEVoWRktqJo6ZA4VltEujlbl3WA/qphoFixL -uE+O9s5ekTi+Yk9lIvFd3Tv7tMSCzp3Qkwxu1S2AgiOIReWeC1tp9X6qY/srOrtV -S6hAPclKgGMhyreBdcXk+CrL/vQVo0B8J80xRgz9thC1KY9NNWR1MiBbLxTxXdHJ -kAwsHrm31nvBiV3WDu3ShXQjBRDgCq5b1Di1KRN0z2w7Ht4MMB4hVmgeeWjVG3Ia -jAwxvXrjIXs7D7nzYUpMVxbMyeYkERIQ5czxR7R7rNp9jCCGuy2GGoZjOdZ8Kd4N -NQj/ZLYKIBQGIOkvtQ8UL/Kxe91VM8QpwszvwJXIQgmidDsOcZw7NiGqLJX3mRzD -qRKYl1TXAJnYuBYUNaPbzeNbRE/RvTqz55LNZ0juL39WYnE0gwwP5V3G122d7Msm -KRpE8X1Mv7E8EqtBzdoK3O6YgoTm/njsecUCZfU/c7FKg180Q/z18uVMxQ3nSxEv -wR0rpc5CW9c4MlnomVrlimob5uG0MnkANLKJNtm6+KDyt110g5xUdgF1Jqb5AkeU -HzNWXQlmOw9AhYshTKDwtZVlChxvSC9IwbcxDORkjiiC7TWIiXa8lwyUHoCzS4jb -ACnvbWN+J2a08AXExUJSDmsP6+3mplwFAz2rn38xp0HYQOoRm+Rw5AMtSwN6KYn1 -P9CKxDB8lK8/GF2iELzudiraiDc61l8ELkmFACyt2Fo70tfj4sVQIu7KJhp1jBAU -/CXQAKaY7otZ5fcmwby2rp4eMuNv8aInGZfEderZoz6UivErhr2Rydep+VA9Sbgq -316/z/hGrzwWWa2I1qbuX19bN1jILhsa7YTa2CL/6KB1ghh9lqkWfY6Po8rVN/YP -Kw0R+7+MK2KHXES+NDP5dF5d0KiDJDvGkS//8ZUUGwbJNi+ymrUvhKhTmJoPG3xf -sV0ELNQlkFQt28YrfFSHyB+hVCNnTcy1sMWGUt2X1Thsr6GL717Q+YhnOQZEDd2M -Jhs/4LJaCvrdXIJHQXgtsS2UBAqd4+frJh1pp+SWRT+Xn+uAqfxCjhrJ73bLPcx/ -lOfBtkpjZGaqOIr6kFYDsf+eVXimtkF2fYyCeU7WoS+vML52Cz7+hWjouq53kS3m -2XbYMLV6Z1Bcjbvb8i6uEA== ------END ENCRYPTED PRIVATE KEY----- diff --git a/examples/certs/key_nodes.pem b/examples/certs/key_nodes.pem deleted file mode 100644 index c046b9ff4..000000000 --- a/examples/certs/key_nodes.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCu9iP5cwphF9Td -wpd94N5fRO/zM+dn2Fz+VMFzdQ2J8GPNxF4sptMPPvojEjquY0VxFcltUM5MTTek -gGyqzj6FpoZi3B3i3K9N7HiJR9RF3zztZo7CMK2wywzDc+CWXxKllpe+5ZuhlRY1 -O2BY1ZDkGFgVCHFwj6K4l5zLZaoeRsb1Q+OwXuyXIBN4ae4H07bEBX2pqlUxbPXQ -LCpbQdZ5odwuqozVcbI3FO1T5GRRXXr5BvWtb3TUKT6YNehSzg7hRb2NUcrDTcMq -W//2/WWc6zO94pe+2uI4FLeeEV4Kl6PRNgW8TGgTYbV04S8PEG8ORFPRNdUf9W5n -moGnaqnXAgMBAAECggEAFTy1YysOoHh3Ey/ymYn5FBFXGus69ITzzL9W9//GU+8E -/k4OrFbXmasoS6eDzfUo0bA2UfmUAPkCfwpDpnwAZNKwz0Eus4HcGZZRj0BTyONv -DtX7ECE+hA4xj2v6X+ZMaiMcakSOno9tMaryZ/YMb1NxJaRvuJ0GwGdO1fWSL7hu -NXB2jRkrHAEzb4zudsMwJhOVmPGfN6PZ2ktONfnP06Nz8YcyTipF+QHbxrJ91nM7 -+udY534LTBXjIHJ8Fm8yGbFTJRUmAH+OpTzpmB54HI1b25liN8cZyr6NZHjr0+zT -Uk9V4Cnq8rDIyXnDkfVg3nZSDw3wVPQPsPTmv6U1eQKBgQDb7A9Zs7xW/KFldmgO -gS2cfXqfprvTpl8IebsHYGQX6sYarVHE5awhe1wV2V6v6LUWzta3BFpLer/l4ASk -LpNlTa5+QIFbe5zuWu55bgPKZRPRbdWLzhHN12y+3KWAYEvioxHHizXMDIqubZ1v -JaiF9UGmNWdX7NeYEPIr9R0ghQKBgQDLqeVHCELvizsmVzX5R9e6h0ybgisZAsP9 -z0bNZszzxV0zji65zCkxgCkQW1SMDkJMmpYSWjVp5nR5b9RwXGA7jZ+m2HAttNzq -JyKlTJG39IDmg4Ap/hDjqRWj5La9z5rY/WX7l9lVTjGiQuDbHRccC533jGyU/COD -NuA14lF9qwKBgHLTO+iQCaQ5X2OEgSwhklkEwwOcoLEPSss4E8j0MQ6zzB+dovX1 -HPyWViwqRGAAVpzD/iOsqCCExLEXWBUJJHheKN9OervzPKrO23iXUm9YexJ8EGVg -gLdC5Up6FgeDP9vjXKMdMkeJvNb58JtZxDW9KjvH4l9sD90b6/W7kyupAoGBAIwj -SF97IMvBax7zrXDs7VUtGhp7E/quu3uer6JQVUB7kqkR8bbo84NbI2Zc4a1JdndN -e2v/ZHeNGqIgv/Xcql7wEWX10iKxK7121lEVgcMpW7TB0WOTrb1pMDnI+7FZ87vR -iOX405PuLRrwl9ZNiwRCPh0DJAfUAv+bt+V76ATnAoGBAKJ0jKIqf4eVFyqxIZNp -+tdLQ5aQQQuCNGoY0flD5kAq8lCSLxt+76VMlCxG3gqAYIEz697fjDMsGaLDkmt3 -QZql5UcJrYoppSa9otqRNftdRzfyDZ+7aEYQ5qimrPRFGtHPf9CVj81AI7vkbpBe -4QhebC89xSeGKpd9rAdJXDYz ------END PRIVATE KEY----- diff --git a/examples/certs/pode-cert.cer b/examples/certs/pode-cert.cer deleted file mode 100644 index a548811d6209a5b02680d04813de5a3ae29a8e2d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 796 zcmXqLVwNyyVq#dp%*4pVBw*fo|EJQ0cg-&0U($Cu?F=yBW#iOp^Jx3d%gD&h%3vU3 zC}beO#vIDR%)^h&tTBR$i>ve$jESp!@lHd=B}d4i%<40%B;JV(BId*UR1wq(=Q%B7N=y# zz?jUvccR-jvH!f*(tMyks6nJ$DR9A>PSHNf78-3r)!5w%$8Vq9n>#n$?0h^yew(@3gY2@@V>8 z)Dv;^q=j_US!nieuuUhHz^G~gohg+9z z;bE!tx~R6ZVV;5dwB_rVdpBBKv*XgOpR-xnp=v|P_t{MKF_mJjEzOHP{_T4kWlf7a-WsiXHvPIZUm6G;lEO0H*~ hzZ7vOdHa*YXI`$b<22c4Ygqbs%QYcOl{R_VO#r&3Hv9kp diff --git a/examples/certs/pode-cert.pfx b/examples/certs/pode-cert.pfx deleted file mode 100644 index c4b2e0a4f6f44b3fcde133b3a63dd10cc8692661..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2597 zcmZXVc|6qH8^^!1$~G!wjbRj$F(i~V+_DX#TX8AV*d+|vZYoTG5g6H!J{EM5=? zsDQ}%0ZQb2$HuJ~BAoAkBsc&i!geu4*f(};z`6hK;^6{;3y9D?3=z765r=dAgW2W? z=z(Cl7ifXYtD;~q2Lnok4xUrfznijF@}22AXQKXMOv%nd*&Bs7TVbj9=KfaL9U5%p z8_E}K9Nx~|<&dv+ccH`bm{*syRi)bzVOTp8@O~*5!P-xi7?=N2U6-A?ge2xRqsFj> z)A@#pt$x9Wrnq5F-Gn!e;vWa+^rPNql2WrFehbc(uY4~$Lovt7-J|>LNK$v5$&1g0 zBR=tbu;{HE!9Y0r+&*slZ=)7L;kf#i-ksHY~tSaF+aLXgC>O>Ap zM=Z34ks|nBZ*0wP`)P@)ZqlymGY?eWQm_8=hGW#8qQJsf$#PTsk+AkzTwZD!XB5Sh zhOAOy?DSBYK7AE8dWjc3 zjLP^m+ABS$i9hXm&qhkQF6nzc*r_cmLVUc+hw; zLW^HJabFhNDTtM)(jQyr);6o0*gVRzn)NJ!pN_^GF0c~h`ZlEZ>?+6H$zJyBfeC@G zuZQ?5hQz-44JP0WBW;hu$Ftg>)Ar`LGM8^g3d_{ENcTG}u3Vvp&h(*Se;wwxR{vg|3t_swY+cfE1DMH(d6)smGebb6Pqyn)O z6}SsRim$+NjOs8Qlx*9N-fXqi%Wb~xYVkJkJ%lD4ov=G^MnDjiGM9+-+B37+A+{91 znb)P7-aMO-q}Zx*RuqSpsfZq&Mt5;JlVzSRB}~vl#kq*YyW6n?W_DU^P`_8OCM@HSsnJKKCwQqIV!A; zBoxATr9V9X)XMho{FQU}Pb??a1pQ*bRj8xE@tDDScoN9*zrS9TzH`fGO0nqC@)p3Y zPW0R)pi4d|)a1&R$02|$7ry1#7nL0-SKN~j7i*|((CRmOo<^h`pLjcDNA^c-Hr36l zuZWjS6|f+K)qPf}6HUkGQkpMprFYSHs49jPm-b%on=bgO>-B2MJ@ik$!&!H;evIfl zz!b_!ootR6Zb2xQ%|W%J$`&E1ZSKp9Ca%N!%1{4sDBt36 zia%?8@m9mUa>OKt7tW~w2SGtZ^+RA~0K@<9>&Ypo42MEN003a@|APX`mJ;TGAK(Ni zvO@#F1Ihpna0bo*>TGhFJ-_j@Qe|fqK$XpKZ0(;4&(<)&DR!q5J8Q7l-~rXY?Eg6e z=%+mvaAOMuzzgtYhckOSBw+n_6~Hzmu-lP{n;Ll>^T8L zgjD={ehzp65mLZLxnK~$P8t6xp#T2|P`2mgIQLhF{`v>Pi4a#~j!;zlW)wlWH?a7~ z(ANoVWQV|EJx z9d=vr6eNV#4kZq}{(7d}r>E$N7IfjWvgU1LWg0edH2<7lg|K_R#Kj)^?T)q(CD{b& zk#3*UCJWR3N>{mMOLwjvuk&-kjG$;iCcYF+38akqQ5_>|eIeod`;uZ&1&N^eDN#EuOM~{6*uBmHMu&Vg@`6;uSa3zEVo7=-caMG8X7zPg zNseEcK}dg~Rmhpn%}@K(Xzw7>lA)5~m6kOc(W6p3?!dfoYPvG$jcCvkK^(GhK*=^}+5+2xw#%Vum}5TE~~Z>3m#tfhAIs%ZmOPAfg&nUMJnYARW` zPaBasqk?YQov=QvdekRZ{PNSG_C&Jg@xJo9whN@sBmRrFWFCMd*H}Ym`CL(-Z{F0J}SVHnwFRy&<=7nj@B8m@!yW`9HVC9K2+8y1f zNj~0He^YP%begL;BYTf2vl05SEJ(~!raU1Qk8tlbg>~g;9BZH`6mUhEXQZmFyp=-F z@23cUMi?qR@=rxvPo8jvl+}e%2X{g+po>0!)1-KXa6{HFO?7*sNm9kp?UUUKUZK#M!>qT73VBq3C1{x(JvBZ=XF!xRt@uqagEJ8Fos^J;#z~+3Hz?!Y$u1A||GKDu0tQ>08~^|S diff --git a/pode.build.ps1 b/pode.build.ps1 index 658146921..e6eb172af 100644 --- a/pode.build.ps1 +++ b/pode.build.ps1 @@ -1287,6 +1287,20 @@ Add-BuildTask TestNoBuild TestDeps, { $configuration.TestResult.OutputFormat = 'NUnitXml' $configuration.Output.Verbosity = $PesterVerbosity $configuration.TestResult.OutputPath = $Script:TestResultFile + $excludeTag = @() + if ( $PSEdition -ne 'Core') { + $excludeTag += 'Exclude_DesktopEdition' + } + if ($IsLinux) { + $excludeTag += 'Exclude_Linux' + } + if ($IsMacOS) { + $excludeTag += 'Exclude_MacOs' + } + if ($IsWindows) { + $excludeTag += 'Exclude_Windows' + } + $configuration.Filter.ExcludeTag = $excludeTag # if run code coverage if enabled if (Test-PodeBuildCanCodeCoverage) { diff --git a/src/Locales/ar/Pode.psd1 b/src/Locales/ar/Pode.psd1 index 0cdf650f9..5d5b3f9ba 100644 --- a/src/Locales/ar/Pode.psd1 +++ b/src/Locales/ar/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'بصمات الإبهام/الاسم للشهادة مدعومة فقط على Windows.' sseConnectionNameRequiredExceptionMessage = "مطلوب اسم اتصال SSE، إما من -Name أو `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'أحد مكونات Middleware المقدمة من نوع غير صالح. كان المتوقع إما ScriptBlock أو Hashtable، ولكن تم الحصول عليه: {0}' - noSecretForJwtSignatureExceptionMessage = 'لم يتم تقديم أي سر لتوقيع JWT.' modulePathDoesNotExistExceptionMessage = 'مسار الوحدة غير موجود: {0}' taskAlreadyDefinedExceptionMessage = '[المهمة] {0}: المهمة معرفة بالفعل.' verbAlreadyDefinedExceptionMessage = '[الفعل] {0}: تم التعريف بالفعل' @@ -331,4 +330,36 @@ invalidPodeStateDataExceptionMessage = 'البيانات المقدمة لا تمثل حالة Pode صالحة.' podeStateVersionMismatchExceptionMessage = 'بيانات الحالة المقدمة تأتي من إصدار أحدث من Pode: {0}.' podeStateApplicationMismatchExceptionMessage = 'بيانات الحالة المقدمة تنتمي إلى تطبيق آخر: {0}.' + missingKeyForAlgorithmExceptionMessage = 'مفتاح {0} مطلوب لخوارزميات {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "الطابع الزمني 'iat' (وقت الإصدار) لرمز JWT مضبوط في المستقبل. الرمز غير صالح بعد." + jwtInvalidIssuerExceptionMessage = "الادعاء 'iss' (المصدر) في JWT غير صالح أو مفقود. المصدر المتوقع: '{0}'." + jwtMissingIssuerExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'iss' (المصدر). مطلوب مصدر صالح." + jwtInvalidAudienceExceptionMessage = "الادعاء 'aud' (الجمهور) في JWT غير صالح أو مفقود. الجمهور المتوقع: '{0}'." + jwtMissingAudienceExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'aud' (الجمهور). مطلوب جمهور صالح." + jwtInvalidSubjectExceptionMessage = "الادعاء 'sub' (الموضوع) في JWT غير صالح أو مفقود. مطلوب موضوع صالح." + jwtInvalidJtiExceptionMessage = "الادعاء 'jti' (معرّف JWT) في JWT غير صالح أو مفقود. مطلوب معرّف فريد صالح." + jwtAlgorithmMismatchExceptionMessage = 'عدم تطابق خوارزمية JWT: المتوقع {0}، ولكن تم العثور على {1}.' + jwtMissingJtiExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'jti' (معرّف JWT)." + deprecatedFunctionWarningMessage = "تحذير: الدالة '{0}' قديمة وسيتم إزالتها في الإصدارات المستقبلية. يرجى استخدام الدالة '{1}' بدلاً منها." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'خوارزمية غير معروفة أو تنسيق PEM غير صالح.' + unknownAlgorithmWithKeySizeExceptionMessage = 'خوارزمية {0} غير معروفة (حجم المفتاح: {1} بت).' + jwtCertificateAuthNotSupportedExceptionMessage = 'مصادقة شهادة JWT مدعومة فقط في PowerShell 7.0 أو أحدث.' + jwtNoExpirationExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'exp' (انتهاء الصلاحية)." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'مصادقة رمز Bearer باستخدام نص الطلب مدعومة فقط مع طرق HTTP PUT أو POST أو PATCH.' + certificateNotValidYetExceptionMessage = 'الشهادة {0} غير صالحة بعد. صالحة من: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "الشهادة غير صالحة لغرض '{0}'. الأغراض المكتشفة: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'تحتوي الشهادة على EKUs غير معروفة. وضع التحقق الصارم يرفضها. المكتشف: {0}' + failedToCreateCertificateRequestExceptionMessage = 'فشل في إنشاء طلب الشهادة.' + unsupportedCertificateKeyLengthExceptionMessage = 'طول مفتاح الشهادة غير مدعوم: {0} بت. يُرجى استخدام طول مفتاح مدعوم.' + invalidTypeExceptionMessage = 'خطأ: نوع غير صالح لـ {0}. المتوقع {1}، لكن تم استلام [{2}].' + certificateSignatureInvalidExceptionMessage = 'الشهادة {0} تحتوي على توقيع غير صالح. قد تم العبث بالشهادة أو لم يتم توقيعها من قبل جهة موثوقة.' + certificateUntrustedRootExceptionMessage = 'الشهادة {0} صادرة عن جهة جذر غير موثوقة. يرجى تثبيت شهادة الجذر CA أو استخدام شهادة من جهة موثوقة.' + certificateRevokedExceptionMessage = 'تم إبطال الشهادة {0}. السبب: {1}. يرجى الحصول على شهادة صالحة جديدة.' + certificateExpiredIntermediateExceptionMessage = 'الشهادة {0} موقعة من قبل شهادة وسيطة انتهت صلاحيتها في {1}. سلسلة الشهادات لم تعد صالحة.' + certificateValidationFailedExceptionMessage = 'فشلت عملية التحقق من الشهادة {0}. يرجى التحقق من سلسلة الشهادات وفترة الصلاحية.' + certificateWeakAlgorithmExceptionMessage = 'الشهادة {0} تستخدم خوارزمية تشفير ضعيفة: {1}. يُوصى باستخدام SHA-256 أو أقوى.' + selfSignedCertificatesNotAllowedExceptionMessage = 'لا يُسمح باستخدام الشهادات الموقعة ذاتيًا بسبب قيود الأمان.' + digestTokenAuthMethodNotSupportedExceptionMessage = "ميزة Digest Quality of Protection 'auth-int' مدعومة فقط لطرق HTTP PUT أو POST أو PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'تنسيق شهادة PEM غير مدعوم في PowerShell {0}. يرجى استخدام تنسيق شهادة آخر أو الترقية إلى PowerShell 7.0 أو أحدث.' } + diff --git a/src/Locales/de/Pode.psd1 b/src/Locales/de/Pode.psd1 index 6de56c63d..d99aa754b 100644 --- a/src/Locales/de/Pode.psd1 +++ b/src/Locales/de/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Zertifikat-Thumbprints/Name werden nur unter Windows unterstützt.' sseConnectionNameRequiredExceptionMessage = "Ein SSE-Verbindungsname ist erforderlich, entweder von -Name oder `$WebEvent.Sse.Namee" invalidMiddlewareTypeExceptionMessage = 'Eines der angegebenen Middleware-Objekte ist ein ungültiger Typ. Erwartet wurde entweder ein ScriptBlock oder ein Hashtable, aber erhalten wurde: {0}.' - noSecretForJwtSignatureExceptionMessage = 'Es wurde kein Geheimnis für die JWT-Signatur angegeben.' modulePathDoesNotExistExceptionMessage = 'Der Modulpfad existiert nicht: {0}' taskAlreadyDefinedExceptionMessage = '[Aufgabe] {0}: Aufgabe bereits definiert.' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: Bereits definiert.' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "Die Rate-Limit-Regel mit dem Namen '{0}' existiert nicht." accessLimitRuleAlreadyExistsExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert bereits." accessLimitRuleDoesNotExistExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert nicht." + missingKeyForAlgorithmExceptionMessage = 'Ein {0}-Schlüssel ist für {1}-Algorithmen ({2}) erforderlich.' + jwtIssuedInFutureExceptionMessage = "Der 'iat' (Issued At)-Zeitstempel des JWT ist in der Zukunft gesetzt. Das Token ist noch nicht gültig." + jwtInvalidIssuerExceptionMessage = "Der JWT-Anspruch 'iss' (Issuer) ist ungültig oder fehlt. Erwarteter Herausgeber: '{0}'." + jwtMissingIssuerExceptionMessage = "Dem JWT fehlt der erforderliche 'iss' (Issuer)-Anspruch. Ein gültiger Herausgeber ist erforderlich." + jwtInvalidAudienceExceptionMessage = "Der JWT-Anspruch 'aud' (Audience) ist ungültig oder fehlt. Erwartete Zielgruppe: '{0}'." + jwtMissingAudienceExceptionMessage = "Dem JWT fehlt der erforderliche 'aud' (Audience)-Anspruch. Eine gültige Zielgruppe ist erforderlich." + jwtInvalidSubjectExceptionMessage = "Der JWT-Anspruch 'sub' (Subject) ist ungültig oder fehlt. Ein gültiges Subjekt ist erforderlich." + jwtInvalidJtiExceptionMessage = "Der JWT-Anspruch 'jti' (JWT ID) ist ungültig oder fehlt. Eine gültige eindeutige Kennung ist erforderlich." + jwtAlgorithmMismatchExceptionMessage = 'JWT-Algorithmus stimmt nicht überein: Erwartet {0}, gefunden {1}.' + jwtMissingJtiExceptionMessage = "Dem JWT fehlt die erforderliche 'jti' (JWT-ID)-Anspruch." + deprecatedFunctionWarningMessage = "WARNUNG: Die Funktion '{0}' ist veraltet und wird in zukünftigen Versionen entfernt. Bitte verwenden Sie stattdessen die Funktion '{1}'." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Unbekannter Algorithmus oder ungültiges PEM-Format.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Unbekannter {0}-Algorithmus (Schlüsselgröße: {1} Bit).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT-Zertifikatauthentifizierung wird nur in PowerShell 7.0 oder höher unterstützt.' + jwtNoExpirationExceptionMessage = "Dem JWT fehlt der erforderliche 'exp' (Expiration)-Anspruch. Ein Ablaufdatum ist erforderlich." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Die Bearer-Token-Authentifizierung über den Anfragetext wird nur mit den HTTP-Methoden PUT, POST oder PATCH unterstützt.' + certificateNotValidYetExceptionMessage = 'Zertifikat {0} ist noch NICHT gültig. Gültig ab: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Zertifikat ist NICHT gültig für '{0}'. Gefundene Zwecke: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Zertifikat enthält unbekannte EKUs. Der strikte Modus lehnt es ab. Gefunden: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Fehler beim Erstellen einer Zertifikatsanforderung.' + unsupportedCertificateKeyLengthExceptionMessage = 'Nicht unterstützte Zertifikatschlüssellänge: {0} Bit. Bitte verwenden Sie eine unterstützte Schlüssellänge.' + invalidTypeExceptionMessage = 'Fehler: Ungültiger Typ für {0}. Erwartet {1}, aber erhalten [{2}].' + certificateSignatureInvalidExceptionMessage = 'Das Zertifikat {0} hat eine ungültige Signatur. Das Zertifikat wurde möglicherweise manipuliert oder nicht von einer vertrauenswürdigen Stelle signiert.' + certificateUntrustedRootExceptionMessage = 'Das Zertifikat {0} wurde von einer nicht vertrauenswürdigen Stammzertifizierungsstelle ausgestellt. Bitte installieren Sie das Stamm-CA-Zertifikat oder verwenden Sie ein Zertifikat einer vertrauenswürdigen Behörde.' + certificateRevokedExceptionMessage = 'Das Zertifikat {0} wurde widerrufen. Grund: {1}. Bitte fordern Sie ein neues gültiges Zertifikat an.' + certificateExpiredIntermediateExceptionMessage = 'Das Zertifikat {0} wurde von einem Zwischenzertifikat signiert, das am {1} abgelaufen ist. Die Zertifikatskette ist nicht mehr gültig.' + certificateValidationFailedExceptionMessage = 'Die Zertifikatsvalidierung für {0} ist fehlgeschlagen. Bitte überprüfen Sie die Zertifikatskette und die Gültigkeitsdauer.' + certificateWeakAlgorithmExceptionMessage = 'Das Zertifikat {0} verwendet eine schwache kryptografische Algorithmus: {1}. Es wird empfohlen, SHA-256 oder stärker zu verwenden.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Selbstsignierte Zertifikate sind aufgrund von Sicherheitsbeschränkungen nicht erlaubt.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' wird nur für die HTTP-Methoden PUT, POST oder PATCH unterstützt." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'Das PEM-Zertifikatsformat wird in PowerShell {0} nicht unterstützt. Bitte verwenden Sie ein anderes Zertifikatsformat oder aktualisieren Sie auf PowerShell 7.0 oder höher.' invalidPodeStateFormatExceptionMessage = 'Die PodeState-Datei "{0}" enthält ein ungültiges Format. Erwartet wurde eine Dictionary-ähnliche Struktur (ConcurrentDictionary, Hashtable oder OrderedDictionary), aber gefunden wurde [{1}]. Bitte überprüfen Sie den Dateiinhalt oder initialisieren Sie den Zustand neu.' unknownJsonDictionaryTypeExceptionMessage = 'Unbekannter Wörterbuch-/Sammlungstyp in JSON: {0}' invalidPodeStateDataExceptionMessage = 'Die bereitgestellten Daten stellen keinen gültigen Pode-Status dar.' diff --git a/src/Locales/en-us/Pode.psd1 b/src/Locales/en-us/Pode.psd1 index 1b9cd35da..ec9f3f137 100644 --- a/src/Locales/en-us/Pode.psd1 +++ b/src/Locales/en-us/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificate Thumbprints/Name are only supported on Windows OS.' sseConnectionNameRequiredExceptionMessage = "An SSE connection Name is required, either from -Name or `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'One of the Middlewares supplied is an invalid type. Expected either a ScriptBlock or Hashtable, but got: {0}' - noSecretForJwtSignatureExceptionMessage = 'No secret supplied for JWT signature.' modulePathDoesNotExistExceptionMessage = 'The module path does not exist: {0}' taskAlreadyDefinedExceptionMessage = '[Task] {0}: Task already defined.' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: Already defined' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "A rate limit rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An access limit rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An access limit rule with the name '{0}' does not exist." + missingKeyForAlgorithmExceptionMessage = 'A {0} key is required for {1} algorithms ({2}).' + jwtIssuedInFutureExceptionMessage = "The JWT's 'iat' (Issued At) timestamp is set in the future. The token is not valid yet." + jwtInvalidIssuerExceptionMessage = "The JWT 'iss' (Issuer) claim is invalid or missing. Expected issuer: '{0}'." + jwtMissingIssuerExceptionMessage = "The JWT is missing the required 'iss' (Issuer) claim. A valid issuer is required." + jwtInvalidAudienceExceptionMessage = "The JWT 'aud' (Audience) claim is invalid or missing. Expected audience: '{0}'." + jwtMissingAudienceExceptionMessage = "The JWT is missing the required 'aud' (Audience) claim. A valid audience is required." + jwtInvalidSubjectExceptionMessage = "The JWT 'sub' (Subject) claim is invalid or missing. A valid subject is required." + jwtInvalidJtiExceptionMessage = "The JWT 'jti' (JWT ID) claim is invalid or missing. A valid unique identifier is required." + jwtAlgorithmMismatchExceptionMessage = 'JWT algorithm mismatch: Expected {0}, found {1}.' + jwtMissingJtiExceptionMessage = "The JWT is missing the required 'jti' (JWT ID) claim." + deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Unknown algorithm or invalid PEM format.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Unknown {0} algorithm (Key Size: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT certificate authentication is supported only in PowerShell 7.0 or greater.' + jwtNoExpirationExceptionMessage = 'The JWT is missing the required expiration time (exp) claim. A valid expiration time is required.' + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Bearer token authentication using the request body is only supported with HTTP PUT, POST, or PATCH methods.' + certificateNotValidYetExceptionMessage = 'Certificate {0} is NOT valid yet. Valid from: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certificate is NOT valid for '{0}'. Found purposes: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Certificate contains unknown EKUs. Strict mode rejects it. Found: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Failed to generate a certificate request.' + unsupportedCertificateKeyLengthExceptionMessage = 'Unsupported certificate key length: {0} bits. Please use a supported key length.' + invalidTypeExceptionMessage = 'Error: Invalid type for {0}. Expected {1}, but received [{2}].' + certificateSignatureInvalidExceptionMessage = 'The certificate {0} has an invalid signature. The certificate may have been tampered with or was not signed by a trusted authority.' + certificateUntrustedRootExceptionMessage = 'The certificate {0} is issued by an untrusted root. Please install the root CA certificate or use a certificate from a trusted authority.' + certificateRevokedExceptionMessage = 'The certificate {0} has been revoked. Reason: {1}. Please obtain a new valid certificate.' + certificateExpiredIntermediateExceptionMessage = 'The certificate {0} is signed by an intermediate certificate that has expired on {1}. The certificate chain is no longer valid.' + certificateValidationFailedExceptionMessage = 'Certificate validation failed for {0}. Please check the certificate chain and validity period.' + certificateWeakAlgorithmExceptionMessage = 'The certificate {0} uses a weak cryptographic algorithm: {1}. It is recommended to use SHA-256 or stronger.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Self-signed certificates are not permitted due to security restrictions.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' is only supported for HTTP PUT, POST, or PATCH methods." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM certificate format is not supported in PowerShell {0}. Please use a different certificate format or upgrade to PowerShell 7.0 or later.' invalidPodeStateFormatExceptionMessage = 'The PodeState file "{0}" contains an invalid format. Expected a dictionary-like structure (ConcurrentDictionary, Hashtable, or OrderedDictionary), but found [{1}]. Please verify the file content or reinitialize the state.' unknownJsonDictionaryTypeExceptionMessage = 'Unknown dictionary/collection type in JSON: {0}' invalidPodeStateDataExceptionMessage = 'The provided data does not represent a valid Pode state.' diff --git a/src/Locales/en/Pode.psd1 b/src/Locales/en/Pode.psd1 index 58bfcb91d..ed54596fe 100644 --- a/src/Locales/en/Pode.psd1 +++ b/src/Locales/en/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificate Thumbprints/Name are only supported on Windows OS.' sseConnectionNameRequiredExceptionMessage = "An SSE connection Name is required, either from -Name or `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'One of the Middlewares supplied is an invalid type. Expected either a ScriptBlock or Hashtable, but got: {0}' - noSecretForJwtSignatureExceptionMessage = 'No secret supplied for JWT signature.' modulePathDoesNotExistExceptionMessage = 'The module path does not exist: {0}' taskAlreadyDefinedExceptionMessage = '[Task] {0}: Task already defined.' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: Already defined' @@ -326,6 +325,38 @@ rateLimitRuleDoesNotExistExceptionMessage = "A Rate Limit Rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An Access Limit Rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An Access Limit Rule with the name '{0}' does not exist." + missingKeyForAlgorithmExceptionMessage = 'A {0} key is required for {1} algorithms ({2}).' + jwtIssuedInFutureExceptionMessage = "The JWT's 'iat' (Issued At) timestamp is set in the future. The token is not valid yet." + jwtInvalidIssuerExceptionMessage = "The JWT 'iss' (Issuer) claim is invalid or missing. Expected issuer: '{0}'." + jwtMissingIssuerExceptionMessage = "The JWT is missing the required 'iss' (Issuer) claim. A valid issuer is required." + jwtInvalidAudienceExceptionMessage = "The JWT 'aud' (Audience) claim is invalid or missing. Expected audience: '{0}'." + jwtMissingAudienceExceptionMessage = "The JWT is missing the required 'aud' (Audience) claim. A valid audience is required." + jwtInvalidSubjectExceptionMessage = "The JWT 'sub' (Subject) claim is invalid or missing. A valid subject is required." + jwtInvalidJtiExceptionMessage = "The JWT 'jti' (JWT ID) claim is invalid or missing. A valid unique identifier is required." + jwtAlgorithmMismatchExceptionMessage = 'JWT algorithm mismatch: Expected {0}, found {1}.' + jwtMissingJtiExceptionMessage = "The JWT is missing the required 'jti' (JWT ID) claim." + deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Unknown algorithm or invalid PFX format.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Unknown {0} algorithm (Key Size: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT certificate authentication is supported only in PowerShell 7.0 or greater.' + jwtNoExpirationExceptionMessage = 'The JWT is missing the required expiration time (exp) claim. A valid expiration time is required.' + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Bearer token authentication using the request body is only supported with HTTP PUT, POST, or PATCH methods.' + certificateNotValidYetExceptionMessage = 'Certificate {0} is NOT valid yet. Valid from: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certificate is NOT valid for '{0}'. Found purposes: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Certificate contains unknown EKUs. Strict mode rejects it. Found: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Failed to generate a certificate request.' + unsupportedCertificateKeyLengthExceptionMessage = 'Unsupported certificate key length: {0} bits. Please use a supported key length.' + invalidTypeExceptionMessage = 'Error: Invalid type for {0}. Expected {1}, but received [{2}].' + certificateSignatureInvalidExceptionMessage = 'The certificate {0} has an invalid signature. The certificate may have been tampered with or was not signed by a trusted authority.' + certificateUntrustedRootExceptionMessage = 'The certificate {0} is issued by an untrusted root. Please install the root CA certificate or use a certificate from a trusted authority.' + certificateRevokedExceptionMessage = 'The certificate {0} has been revoked. Reason: {1}. Please obtain a new valid certificate.' + certificateExpiredIntermediateExceptionMessage = 'The certificate {0} is signed by an intermediate certificate that has expired on {1}. The certificate chain is no longer valid.' + certificateValidationFailedExceptionMessage = 'Certificate validation failed for {0}. Please check the certificate chain and validity period.' + certificateWeakAlgorithmExceptionMessage = 'The certificate {0} uses a weak cryptographic algorithm: {1}. It is recommended to use SHA-256 or stronger.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Self-signed certificates are not permitted due to security restrictions.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' is only supported for HTTP PUT, POST, or PATCH methods." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM certificate format is not supported in PowerShell {0}. Please use a different certificate format or upgrade to PowerShell 7.0 or later.' + invalidPodeStateFormatExceptionMessage = 'The PodeState file "{0}" contains an invalid format. Expected a dictionary-like structure (ConcurrentDictionary, Hashtable, or OrderedDictionary), but found [{1}]. Please verify the file content or reinitialize the state.' unknownJsonDictionaryTypeExceptionMessage = 'Unknown dictionary/collection type in JSON: {0}' invalidPodeStateDataExceptionMessage = 'The provided data does not represent a valid Pode state.' diff --git a/src/Locales/es/Pode.psd1 b/src/Locales/es/Pode.psd1 index ea7531bc8..58fab3d68 100644 --- a/src/Locales/es/Pode.psd1 +++ b/src/Locales/es/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Las huellas digitales/nombres de certificados solo son compatibles con Windows.' sseConnectionNameRequiredExceptionMessage = "Se requiere un nombre de conexión SSE, ya sea de -Name o `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Uno de los Middlewares suministrados es de un tipo no válido. Se esperaba ScriptBlock o Hashtable, pero se obtuvo: {0}' - noSecretForJwtSignatureExceptionMessage = 'No se suministró ningún secreto para la firma JWT.' modulePathDoesNotExistExceptionMessage = 'La ruta del módulo no existe: {0}' taskAlreadyDefinedExceptionMessage = '[Tarea] {0}: Tarea ya definida.' verbAlreadyDefinedExceptionMessage = '[Verbo] {0}: Ya está definido' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regla de límite de velocidad con el nombre '{0}' no existe." accessLimitRuleAlreadyExistsExceptionMessage = "La regla de límite de acceso con el nombre '{0}' ya existe." accessLimitRuleDoesNotExistExceptionMessage = "La regla de límite de acceso con el nombre '{0}' no existe." + missingKeyForAlgorithmExceptionMessage = 'Se requiere una clave {0} para los algoritmos {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "La marca de tiempo 'iat' (Issued At) del JWT está configurada en el futuro. El token aún no es válido." + jwtInvalidIssuerExceptionMessage = "La reclamación 'iss' (Issuer) del JWT es inválida o falta. Emisor esperado: '{0}'." + jwtMissingIssuerExceptionMessage = "El JWT no tiene la reclamación obligatoria 'iss' (Issuer). Se requiere un emisor válido." + jwtInvalidAudienceExceptionMessage = "La reclamación 'aud' (Audience) del JWT es inválida o falta. Audiencia esperada: '{0}'." + jwtMissingAudienceExceptionMessage = "El JWT no tiene la reclamación obligatoria 'aud' (Audience). Se requiere una audiencia válida." + jwtInvalidSubjectExceptionMessage = "La reclamación 'sub' (Subject) del JWT es inválida o falta. Se requiere un sujeto válido." + jwtInvalidJtiExceptionMessage = "La reclamación 'jti' (JWT ID) del JWT es inválida o falta. Se requiere un identificador único válido." + jwtAlgorithmMismatchExceptionMessage = 'Incompatibilidad de algoritmo JWT: Se esperaba {0}, pero se encontró {1}.' + jwtMissingJtiExceptionMessage = "El JWT no tiene la reclamación obligatoria 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "ADVERTENCIA: La función '{0}' está obsoleta y se eliminará en futuras versiones. Por favor, use la función '{1}' en su lugar." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algoritmo desconocido o formato PEM no válido.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algoritmo {0} desconocido (Tamaño de clave: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'La autenticación de certificados JWT solo es compatible con PowerShell 7.0 o superior.' + jwtNoExpirationExceptionMessage = "El JWT no tiene la reclamación 'exp' (Expiration). Se requiere una fecha de expiración válida." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'La autenticación con token Bearer usando el cuerpo de la solicitud solo es compatible con los métodos HTTP PUT, POST o PATCH.' + certificateNotValidYetExceptionMessage = 'El certificado {0} aún NO es válido. Válido desde: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "El certificado NO es válido para '{0}'. Propósitos encontrados: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'El certificado contiene EKUs desconocidos. El modo estricto lo rechaza. Encontrado: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Error al generar una solicitud de certificado.' + unsupportedCertificateKeyLengthExceptionMessage = 'Longitud de clave de certificado no compatible: {0} bits. Utilice una longitud de clave compatible.' + invalidTypeExceptionMessage = 'Error: Tipo no válido para {0}. Se esperaba {1}, pero se recibió [{2}].' + certificateSignatureInvalidExceptionMessage = 'El certificado {0} tiene una firma no válida. Puede haber sido alterado o no haber sido firmado por una autoridad de confianza.' + certificateUntrustedRootExceptionMessage = 'El certificado {0} ha sido emitido por una autoridad raíz no confiable. Por favor, instale el certificado de la CA raíz o utilice un certificado de una autoridad confiable.' + certificateRevokedExceptionMessage = 'El certificado {0} ha sido revocado. Razón: {1}. Por favor, obtenga un nuevo certificado válido.' + certificateExpiredIntermediateExceptionMessage = 'El certificado {0} fue firmado por un certificado intermedio que expiró el {1}. La cadena de certificados ya no es válida.' + certificateValidationFailedExceptionMessage = 'La validación del certificado ha fallado para {0}. Por favor, verifique la cadena de certificados y el período de validez.' + certificateWeakAlgorithmExceptionMessage = 'El certificado {0} usa un algoritmo criptográfico débil: {1}. Se recomienda usar SHA-256 o superior.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Los certificados autofirmados no están permitidos debido a restricciones de seguridad.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' solo es compatible con los métodos HTTP PUT, POST o PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'El formato de certificado PEM no es compatible con PowerShell {0}. Utilice un formato de certificado diferente o actualice a PowerShell 7.0 o posterior.' invalidPodeStateFormatExceptionMessage = 'El archivo PodeState "{0}" tiene un formato no válido. Se esperaba una estructura similar a un diccionario (ConcurrentDictionary, Hashtable o OrderedDictionary), pero se encontró [{1}]. Verifique el contenido del archivo o reinicialice el estado.' unknownJsonDictionaryTypeExceptionMessage = 'Tipo de diccionario/colección desconocido en JSON: {0}' invalidPodeStateDataExceptionMessage = 'Los datos proporcionados no representan un estado válido de Pode.' diff --git a/src/Locales/fr/Pode.psd1 b/src/Locales/fr/Pode.psd1 index f0d704d92..acc75938c 100644 --- a/src/Locales/fr/Pode.psd1 +++ b/src/Locales/fr/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Les empreintes digitales/Noms de certificat ne sont pris en charge que sous Windows.' sseConnectionNameRequiredExceptionMessage = "Un nom de connexion SSE est requis, soit de -Name soit de `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = "Un des Middlewares fournis est d'un type non valide. Attendu ScriptBlock ou Hashtable, mais a obtenu : {0}" - noSecretForJwtSignatureExceptionMessage = 'Aucun secret fourni pour la signature JWT.' modulePathDoesNotExistExceptionMessage = "Le chemin du module n'existe pas : {0}" taskAlreadyDefinedExceptionMessage = '[Tâche] {0} : Tâche déjà définie.' verbAlreadyDefinedExceptionMessage = '[Verbe] {0} : Déjà défini' @@ -331,4 +330,35 @@ invalidPodeStateDataExceptionMessage = 'Les données fournies ne représentent pas un état valide de Pode.' podeStateVersionMismatchExceptionMessage = "Les données d'état fournies proviennent d'une version plus récente de Pode : {0}." podeStateApplicationMismatchExceptionMessage = "Les données d'état fournies appartiennent à une autre application : { 0 }." -} \ No newline at end of file + missingKeyForAlgorithmExceptionMessage = 'Une clé {0} est requise pour les algorithmes {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "L'horodatage 'iat' (Issued At) du JWT est défini dans le futur. Le jeton n'est pas encore valide." + jwtInvalidIssuerExceptionMessage = "La revendication 'iss' (Issuer) du JWT est invalide ou absente. Émetteur attendu : '{0}'." + jwtMissingIssuerExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'iss' (Issuer). Un émetteur valide est requis." + jwtInvalidAudienceExceptionMessage = "La revendication 'aud' (Audience) du JWT est invalide ou absente. Audience attendue : '{0}'." + jwtMissingAudienceExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'aud' (Audience). Une audience valide est requise." + jwtInvalidSubjectExceptionMessage = "La revendication 'sub' (Subject) du JWT est invalide ou absente. Un sujet valide est requis." + jwtInvalidJtiExceptionMessage = "La revendication 'jti' (JWT ID) du JWT est invalide ou absente. Un identifiant unique valide est requis." + jwtAlgorithmMismatchExceptionMessage = "Incohérence d'algorithme JWT : attendu {0}, trouvé {1}." + jwtMissingJtiExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "AVERTISSEMENT : La fonction '{0}' est obsolète et sera supprimée dans les futures versions. Veuillez utiliser la fonction '{1}' à la place." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algorithme inconnu ou format PEM invalide.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algorithme {0} inconnu (Taille de clé : {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = "L'authentification par certificat JWT est prise en charge uniquement à partir de PowerShell 7.0." + jwtNoExpirationExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'exp' (Expiration). Une expiration valide est requise." + bearerTokenAuthMethodNotSupportedExceptionMessage = "L'authentification par jeton Bearer via le corps de la requête est prise en charge uniquement avec les méthodes HTTP PUT, POST ou PATCH." + certificateNotValidYetExceptionMessage = "Le certificat {0} n'est PAS encore valide. Valide à partir du : {1} (UTC)" + certificateNotValidForPurposeExceptionMessage = "Le certificat n'est PAS valide pour '{0}'. Objectifs trouvés : {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Le certificat contient des EKUs inconnus. Le mode strict le rejette. Trouvé : {0}' + failedToCreateCertificateRequestExceptionMessage = 'Échec de la génération de la demande de certificat.' + unsupportedCertificateKeyLengthExceptionMessage = 'Longueur de clé de certificat non prise en charge : {0} bits. Veuillez utiliser une longueur de clé prise en charge.' + invalidTypeExceptionMessage = 'Erreur : Type invalide pour {0}. Attendu {1}, mais reçu [{2}].' + certificateSignatureInvalidExceptionMessage = "Le certificat {0} a une signature invalide. Il a peut-être été altéré ou n'a pas été signé par une autorité de confiance." + certificateUntrustedRootExceptionMessage = "Le certificat {0} est émis par une autorité racine non fiable. Veuillez installer le certificat CA racine ou utiliser un certificat d'une autorité de confiance." + certificateRevokedExceptionMessage = 'Le certificat {0} a été révoqué. Raison : {1}. Veuillez obtenir un nouveau certificat valide.' + certificateExpiredIntermediateExceptionMessage = "Le certificat {0} est signé par un certificat intermédiaire qui a expiré le {1}. La chaîne de certificats n'est plus valide." + certificateValidationFailedExceptionMessage = 'La validation du certificat a échoué pour {0}. Veuillez vérifier la chaîne de certificats et la période de validité.' + certificateWeakAlgorithmExceptionMessage = "Le certificat {0} utilise un algorithme cryptographique faible : {1}. Il est recommandé d'utiliser SHA-256 ou plus sécurisé." + selfSignedCertificatesNotAllowedExceptionMessage = 'Les certificats auto-signés ne sont pas autorisés en raison de restrictions de sécurité.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' est uniquement pris en charge pour les méthodes HTTP PUT, POST ou PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = "Le format de certificat PEM n'est pas pris en charge dans PowerShell {0}. Veuillez utiliser un autre format de certificat ou passer à PowerShell 7.0 ou une version ultérieure." +} diff --git a/src/Locales/it/Pode.psd1 b/src/Locales/it/Pode.psd1 index 2de825473..ce2dbf7cd 100644 --- a/src/Locales/it/Pode.psd1 +++ b/src/Locales/it/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Impronte digitali/nome del certificato supportati solo su Windows OS.' sseConnectionNameRequiredExceptionMessage = "È richiesto un nome di connessione SSE, sia da -Name che da `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Uno dei Middleware forniti è di un tipo non valido. Previsto ScriptBlock o Hashtable, ma ottenuto: {0}' - noSecretForJwtSignatureExceptionMessage = "Nessun 'secret' fornito per la firma JWT." modulePathDoesNotExistExceptionMessage = 'Il percorso del modulo non esiste: {0}' taskAlreadyDefinedExceptionMessage = '[Attività] {0}: Attività già definita.' verbAlreadyDefinedExceptionMessage = '[Verbo] {0}: Già definito' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione del tasso con il nome '{0}' non esiste." accessLimitRuleAlreadyExistsExceptionMessage = "Una regola di limitazione dell'accesso con il nome '{0}' esiste già." accessLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione dell'accesso con il nome '{0}' non esiste." + missingKeyForAlgorithmExceptionMessage = 'È necessaria una chiave {0} per gli algoritmi {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "Il timestamp 'iat' (Issued At) del JWT è impostato nel futuro. Il token non è ancora valido." + jwtInvalidIssuerExceptionMessage = "Il claim 'iss' (Issuer) del JWT non è valido o è mancante. Emittente previsto: '{0}'." + jwtMissingIssuerExceptionMessage = "Il JWT non ha il claim obbligatorio 'iss' (Issuer). È richiesto un emittente valido." + jwtInvalidAudienceExceptionMessage = "Il claim 'aud' (Audience) del JWT non è valido o è mancante. Pubblico previsto: '{0}'." + jwtMissingAudienceExceptionMessage = "Il JWT non ha il claim obbligatorio 'aud' (Audience). È richiesto un pubblico valido." + jwtInvalidSubjectExceptionMessage = "Il claim 'sub' (Subject) del JWT non è valido o è mancante. È richiesto un soggetto valido." + jwtInvalidJtiExceptionMessage = "Il claim 'jti' (JWT ID) del JWT non è valido o è mancante. È richiesto un identificatore univoco valido." + jwtAlgorithmMismatchExceptionMessage = "Mancata corrispondenza dell'algoritmo JWT: previsto {0}, trovato {1}." + jwtMissingJtiExceptionMessage = "Il JWT non ha il claim obbligatorio 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "ATTENZIONE: La funzione '{0}' è obsoleta e verrà rimossa nelle versioni future. Si prega di utilizzare invece la funzione '{1}'." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algoritmo sconosciuto o formato PEM non valido.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algoritmo {0} sconosciuto (Dimensione chiave: {1} bit).' + jwtCertificateAuthNotSupportedExceptionMessage = "L'autenticazione tramite certificato JWT è supportata solo in PowerShell 7.0 o versioni successive." + jwtNoExpirationExceptionMessage = "Il JWT non ha il claim obbligatorio 'exp' (Expiration). È richiesta una data di scadenza valida." + bearerTokenAuthMethodNotSupportedExceptionMessage = "L'autenticazione con token Bearer tramite il corpo della richiesta è supportata solo con i metodi HTTP PUT, POST o PATCH." + certificateNotValidYetExceptionMessage = 'Il certificato {0} NON è ancora valido. Valido dal: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Il certificato NON è valido per '{0}'. Scopi trovati: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Il certificato contiene EKU sconosciuti. La modalità rigorosa lo rifiuta. Trovato: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Errore nella generazione della richiesta di certificato.' + unsupportedCertificateKeyLengthExceptionMessage = 'Lunghezza della chiave del certificato non supportata: {0} bit. Utilizzare una lunghezza di chiave supportata.' + invalidTypeExceptionMessage = 'Errore: Tipo non valido per {0}. Atteso {1}, ma ricevuto [{2}].' + certificateSignatureInvalidExceptionMessage = "Il certificato {0} ha una firma non valida. Potrebbe essere stato manomesso o non firmato da un'autorità attendibile." + certificateUntrustedRootExceptionMessage = "Il certificato {0} è stato emesso da un'autorità radice non affidabile. Si prega di installare il certificato CA radice o utilizzare un certificato di un'autorità affidabile." + certificateRevokedExceptionMessage = 'Il certificato {0} è stato revocato. Motivo: {1}. Si prega di ottenere un nuovo certificato valido.' + certificateExpiredIntermediateExceptionMessage = 'Il certificato {0} è firmato da un certificato intermedio che è scaduto il {1}. La catena di certificati non è più valida.' + certificateValidationFailedExceptionMessage = 'La validazione del certificato per {0} è fallita. Si prega di verificare la catena di certificati e il periodo di validità.' + certificateWeakAlgorithmExceptionMessage = 'Il certificato {0} utilizza un algoritmo crittografico debole: {1}. Si consiglia di utilizzare SHA-256 o superiore.' + selfSignedCertificatesNotAllowedExceptionMessage = 'I certificati auto-firmati non sono consentiti a causa di restrizioni di sicurezza.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' è supportato solo per i metodi HTTP PUT, POST o PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = "Il formato del certificato PEM non è supportato in PowerShell {0}. Utilizzare un formato di certificato diverso o eseguire l'aggiornamento a PowerShell 7.0 o versioni successive." invalidPodeStateFormatExceptionMessage = 'Il file PodeState "{0}" contiene un formato non valido. Era prevista una struttura simile a un dizionario (ConcurrentDictionary, Hashtable o OrderedDictionary), ma è stato trovato [{1}]. Verifica il contenuto del file o reinizializza lo stato.' unknownJsonDictionaryTypeExceptionMessage = 'Tipo di dizionario/collezione sconosciuto in JSON: {0}' invalidPodeStateDataExceptionMessage = 'I dati forniti non rappresentano uno stato valido di Pode.' diff --git a/src/Locales/ja/Pode.psd1 b/src/Locales/ja/Pode.psd1 index ae2bf038f..fba3f10c1 100644 --- a/src/Locales/ja/Pode.psd1 +++ b/src/Locales/ja/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificate Thumbprints/NameはWindowsでのみサポートされています。' sseConnectionNameRequiredExceptionMessage = "-Nameまたは`$WebEvent.Sse.NameからSSE接続名が必要です。" invalidMiddlewareTypeExceptionMessage = '提供されたMiddlewaresの1つが無効な型です。ScriptBlockまたはHashtableのいずれかを期待しましたが、次を取得しました: {0}' - noSecretForJwtSignatureExceptionMessage = 'JWT署名に対する秘密が提供されていません。' modulePathDoesNotExistExceptionMessage = 'モジュールパスが存在しません: {0}' taskAlreadyDefinedExceptionMessage = '[タスク] {0}: タスクは既に定義されています。' verbAlreadyDefinedExceptionMessage = '[動詞] {0}: すでに定義されています' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のレート制限ルールは存在しません。" accessLimitRuleAlreadyExistsExceptionMessage = "名前が '{0}' のアクセス制限ルールは既に存在します。" accessLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のアクセス制限ルールは存在しません。" + missingKeyForAlgorithmExceptionMessage = '{1} アルゴリズム ({2}) には {0} キーが必要です。' + jwtIssuedInFutureExceptionMessage = "JWTの'iat'(発行時刻)タイムスタンプが未来の日付になっています。トークンはまだ有効ではありません。" + jwtInvalidIssuerExceptionMessage = "JWTの'iss'(発行者)クレームが無効または欠落しています。期待される発行者: '{0}'。" + jwtMissingIssuerExceptionMessage = "JWTに必要な'iss'(発行者)クレームがありません。有効な発行者が必要です。" + jwtInvalidAudienceExceptionMessage = "JWTの'aud'(受信者)クレームが無効または欠落しています。期待される受信者: '{0}'。" + jwtMissingAudienceExceptionMessage = "JWTに必要な'aud'(受信者)クレームがありません。有効な受信者が必要です。" + jwtInvalidSubjectExceptionMessage = "JWTの'sub'(対象)クレームが無効または欠落しています。有効な対象が必要です。" + jwtInvalidJtiExceptionMessage = "JWTの'jti'(JWT ID)クレームが無効または欠落しています。有効な一意識別子が必要です。" + jwtAlgorithmMismatchExceptionMessage = 'JWTアルゴリズムの不一致: 期待値 {0}、実際の値 {1}。' + jwtMissingJtiExceptionMessage = "JWT に必要な 'jti' (JWT ID) クレームがありません。" + deprecatedFunctionWarningMessage = "警告: 関数 '{0}' は非推奨であり、今後のバージョンで削除されます。代わりに関数 '{1}' を使用してください。" + unknownAlgorithmOrInvalidPfxExceptionMessage = '未知のアルゴリズムまたは無効なPEMフォーマット。' + unknownAlgorithmWithKeySizeExceptionMessage = '未知の {0} アルゴリズム(キーサイズ: {1} ビット)。' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT 証明書認証は PowerShell 7.0 以上でのみサポートされています。' + jwtNoExpirationExceptionMessage = "JWTに有効期限がありません。'exp'(有効期限)クレームが必要です。" + bearerTokenAuthMethodNotSupportedExceptionMessage = 'リクエスト本文を使用したBearerトークン認証は、HTTP PUT、POST、PATCHメソッドでのみサポートされています。' + certificateNotValidYetExceptionMessage = '証明書 {0} はまだ有効ではありません。有効開始日: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "証明書は '{0}' に対して有効ではありません。検出された目的: {1}" + certificateUnknownEkusStrictModeExceptionMessage = '証明書に不明な EKU が含まれています。厳格モードでは拒否されます。検出された値: {0}' + failedToCreateCertificateRequestExceptionMessage = '証明書リクエストの生成に失敗しました。' + unsupportedCertificateKeyLengthExceptionMessage = 'サポートされていない証明書キー長: {0} ビット。サポートされているキー長を使用してください。' + invalidTypeExceptionMessage = 'エラー: {0} の型が無効です。期待された型 {1} ですが、受け取った型は [{2}] です。' + certificateSignatureInvalidExceptionMessage = '証明書 {0} の署名が無効です。証明書が改ざんされた可能性があるか、信頼できる機関によって署名されていません。' + certificateUntrustedRootExceptionMessage = '証明書 {0} は信頼されていないルート証明機関によって発行されました。ルートCA証明書をインストールするか、信頼できる機関の証明書を使用してください。' + certificateRevokedExceptionMessage = '証明書 {0} は失効しました。理由: {1}。新しい有効な証明書を取得してください。' + certificateExpiredIntermediateExceptionMessage = '証明書 {0} は {1} に有効期限が切れた中間証明書によって署名されています。証明書チェーンは無効です。' + certificateValidationFailedExceptionMessage = '証明書 {0} の検証に失敗しました。証明書チェーンと有効期限を確認してください。' + certificateWeakAlgorithmExceptionMessage = '証明書 {0} は脆弱な暗号アルゴリズム ({1}) を使用しています。SHA-256 以上を使用することを推奨します。' + selfSignedCertificatesNotAllowedExceptionMessage = 'セキュリティ上の制限により、自己署名証明書は許可されていません。' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' は HTTP PUT、POST、PATCH メソッドでのみサポートされます。" + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM 証明書フォーマットは PowerShell {0} ではサポートされていません。別の証明書フォーマットを使用するか、PowerShell 7.0 以降にアップグレードしてください。' invalidPodeStateFormatExceptionMessage = 'PodeState ファイル "{0}" の形式が無効です。辞書のような構造 (ConcurrentDictionary、Hashtable、OrderedDictionary) が期待されましたが、[{1}] が見つかりました。ファイルの内容を確認するか、状態を再初期化してください。' unknownJsonDictionaryTypeExceptionMessage = 'JSON 内の不明な辞書/コレクション型: {0}' invalidPodeStateDataExceptionMessage = '提供されたデータは有効なPodeの状態ではありません。' diff --git a/src/Locales/ko/Pode.psd1 b/src/Locales/ko/Pode.psd1 index 8dc6a7be5..d26a73d0b 100644 --- a/src/Locales/ko/Pode.psd1 +++ b/src/Locales/ko/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = '인증서 지문/이름은 Windows에서만 지원됩니다.' sseConnectionNameRequiredExceptionMessage = "-Name 또는 `$WebEvent.Sse.Name에서 SSE 연결 이름이 필요합니다." invalidMiddlewareTypeExceptionMessage = '제공된 미들웨어 중 하나가 잘못된 유형입니다. 예상된 유형은 ScriptBlock 또는 Hashtable이지만, 얻은 것은: {0}' - noSecretForJwtSignatureExceptionMessage = 'JWT 서명을 위한 비밀이 제공되지 않았습니다.' modulePathDoesNotExistExceptionMessage = '모듈 경로가 존재하지 않습니다: {0}' taskAlreadyDefinedExceptionMessage = '[작업] {0}: 작업이 이미 정의되었습니다.' verbAlreadyDefinedExceptionMessage = '[동사] {0}: 이미 정의되었습니다.' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 비율 제한 규칙이 존재하지 않습니다." accessLimitRuleAlreadyExistsExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 이미 존재합니다." accessLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 존재하지 않습니다." + missingKeyForAlgorithmExceptionMessage = '{1} 알고리즘 ({2}) 에는 {0} 키가 필요합니다.' + jwtIssuedInFutureExceptionMessage = "JWT의 'iat' (발행 시간) 타임스탬프가 미래로 설정되어 있습니다. 토큰은 아직 유효하지 않습니다." + jwtInvalidIssuerExceptionMessage = "JWT의 'iss' (발행자) 클레임이 잘못되었거나 누락되었습니다. 예상 발행자: '{0}'." + jwtMissingIssuerExceptionMessage = "JWT에 필요한 'iss' (발행자) 클레임이 없습니다. 유효한 발행자가 필요합니다." + jwtInvalidAudienceExceptionMessage = "JWT의 'aud' (대상) 클레임이 잘못되었거나 누락되었습니다. 예상 대상: '{0}'." + jwtMissingAudienceExceptionMessage = "JWT에 필요한 'aud' (대상) 클레임이 없습니다. 유효한 대상이 필요합니다." + jwtInvalidSubjectExceptionMessage = "JWT의 'sub' (주체) 클레임이 잘못되었거나 누락되었습니다. 유효한 주체가 필요합니다." + jwtInvalidJtiExceptionMessage = "JWT의 'jti' (JWT ID) 클레임이 잘못되었거나 누락되었습니다. 유효한 고유 식별자가 필요합니다." + jwtAlgorithmMismatchExceptionMessage = 'JWT 알고리즘 불일치: 예상 {0}, 발견된 {1}.' + jwtMissingJtiExceptionMessage = "JWT에 필요한 'jti' (JWT ID) 클레임이 없습니다." + deprecatedFunctionWarningMessage = "경고: 함수 '{0}'는 더 이상 지원되지 않으며 향후 버전에서 제거될 예정입니다. 대신 '{1}' 함수를 사용하십시오." + unknownAlgorithmOrInvalidPfxExceptionMessage = '알 수 없는 알고리즘 또는 잘못된 PEM 형식입니다.' + unknownAlgorithmWithKeySizeExceptionMessage = '알 수 없는 {0} 알고리즘 (키 크기: {1} 비트).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT 인증서 인증은 PowerShell 7.0 이상에서만 지원됩니다.' + jwtNoExpirationExceptionMessage = "JWT에 'exp' (만료 시간) 클레임이 없습니다. 유효한 만료 시간이 필요합니다." + bearerTokenAuthMethodNotSupportedExceptionMessage = '요청 본문을 사용한 Bearer 토큰 인증은 HTTP PUT, POST, PATCH 메서드에서만 지원됩니다.' + certificateNotValidYetExceptionMessage = '인증서 {0}는 아직 유효하지 않습니다. 유효 기간 시작: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "인증서는 '{0}'에 대해 유효하지 않습니다. 발견된 용도: {1}" + certificateUnknownEkusStrictModeExceptionMessage = '인증서에 알 수 없는 EKU가 포함되어 있습니다. 엄격한 모드에서 거부되었습니다. 발견된 값: {0}' + failedToCreateCertificateRequestExceptionMessage = '인증서 요청 생성에 실패했습니다.' + unsupportedCertificateKeyLengthExceptionMessage = '지원되지 않는 인증서 키 길이: {0}비트. 지원되는 키 길이를 사용하세요.' + invalidTypeExceptionMessage = '오류: {0}의 타입이 잘못되었습니다. 예상된 타입: {1}, 받은 타입: [{2}].' + certificateSignatureInvalidExceptionMessage = '인증서 {0}의 서명이 잘못되었습니다. 인증서가 변조되었거나 신뢰할 수 있는 기관에서 서명되지 않았을 수 있습니다.' + certificateUntrustedRootExceptionMessage = '인증서 {0}는 신뢰할 수 없는 루트에서 발급되었습니다. 루트 CA 인증서를 설치하거나 신뢰할 수 있는 기관의 인증서를 사용하세요.' + certificateRevokedExceptionMessage = '인증서 {0}가 폐기되었습니다. 사유: {1}. 새로운 유효한 인증서를 얻으세요.' + certificateExpiredIntermediateExceptionMessage = '인증서 {0}는 {1}에 만료된 중간 인증서에 의해 서명되었습니다. 인증서 체인이 더 이상 유효하지 않습니다.' + certificateValidationFailedExceptionMessage = '인증서 {0}의 검증이 실패했습니다. 인증서 체인과 유효 기간을 확인하세요.' + certificateWeakAlgorithmExceptionMessage = '인증서 {0}는 약한 암호화 알고리즘 ({1})을 사용합니다. SHA-256 이상의 강력한 알고리즘을 사용하는 것이 권장됩니다.' + selfSignedCertificatesNotAllowedExceptionMessage = '보안 제한으로 인해 자체 서명된 인증서는 허용되지 않습니다.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int'는 HTTP PUT, POST, PATCH 메서드에서만 지원됩니다." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM 인증서 형식은 PowerShell {0}에서 지원되지 않습니다. 다른 인증서 형식을 사용하거나 PowerShell 7.0 이상으로 업그레이드하세요.' invalidPodeStateFormatExceptionMessage = 'Het PodeState-bestand "{0}" bevat een ongeldig formaat. Verwacht werd een dictionary-achtige structuur (ConcurrentDictionary, Hashtable of OrderedDictionary), maar gevonden werd [{1}]. Controleer de inhoud van het bestand of initialiseert u de status opnieuw.' unknownJsonDictionaryTypeExceptionMessage = 'JSON에서 알 수 없는 사전/컬렉션 유형: {0}' invalidPodeStateDataExceptionMessage = '제공된 데이터는 유효한 Pode 상태 데이터가 아닙니다.' diff --git a/src/Locales/nl/Pode.psd1 b/src/Locales/nl/Pode.psd1 index eb0b5522b..147f5fc1e 100644 --- a/src/Locales/nl/Pode.psd1 +++ b/src/Locales/nl/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificaat thumbprints/naam worden alleen ondersteund op Windows OS.' sseConnectionNameRequiredExceptionMessage = "Een SSE-verbindingnaam is vereist, hetzij van -Naam of `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Een van de opgegeven middlewares is van een ongeldig type. Verwachte ScriptBlock of Hashtable, maar kreeg: {0}' - noSecretForJwtSignatureExceptionMessage = 'Geen geheim opgegeven voor JWT-handtekening.' modulePathDoesNotExistExceptionMessage = 'Het modulepad bestaat niet: {0}' taskAlreadyDefinedExceptionMessage = '[Taak] {0}: Taak al gedefinieerd.' verbAlreadyDefinedExceptionMessage = '[Werkwoord] {0}: Al gedefinieerd' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "Rate Limit-regel met de naam '{0}' bestaat niet." accessLimitRuleAlreadyExistsExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat al." accessLimitRuleDoesNotExistExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat niet." + missingKeyForAlgorithmExceptionMessage = 'Een {0}-sleutel is vereist voor {1}-algoritmen ({2}).' + jwtIssuedInFutureExceptionMessage = "De 'iat' (Issued At) tijdstempel van de JWT is ingesteld in de toekomst. Het token is nog niet geldig." + jwtInvalidIssuerExceptionMessage = "De JWT 'iss' (Issuer) claim is ongeldig of ontbreekt. Verwachte uitgever: '{0}'." + jwtMissingIssuerExceptionMessage = "De JWT mist de vereiste 'iss' (Issuer) claim. Een geldige uitgever is vereist." + jwtInvalidAudienceExceptionMessage = "De JWT 'aud' (Audience) claim is ongeldig of ontbreekt. Verwacht publiek: '{0}'." + jwtMissingAudienceExceptionMessage = "De JWT mist de vereiste 'aud' (Audience) claim. Een geldig publiek is vereist." + jwtInvalidSubjectExceptionMessage = "De JWT 'sub' (Subject) claim is ongeldig of ontbreekt. Een geldig subject is vereist." + jwtInvalidJtiExceptionMessage = "De JWT 'jti' (JWT ID) claim is ongeldig of ontbreekt. Een geldige unieke identificatie is vereist." + jwtAlgorithmMismatchExceptionMessage = 'JWT-algoritme komt niet overeen: Verwacht {0}, gevonden {1}.' + jwtMissingJtiExceptionMessage = "De JWT mist de vereiste 'jti' (JWT ID) claim." + deprecatedFunctionWarningMessage = "WAARSCHUWING: De functie '{0}' is verouderd en zal in toekomstige versies worden verwijderd. Gebruik in plaats daarvan de functie '{1}'." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Onbekend algoritme of ongeldig PEM-formaat.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Onbekend {0}-algoritme (Sleutelgrootte: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT-certificaatverificatie wordt alleen ondersteund in PowerShell 7.0 of hoger.' + jwtNoExpirationExceptionMessage = "De JWT mist de vereiste 'exp' (Expiration) claim. Een geldige vervaldatum is vereist." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Bearer-tokenverificatie met de aanvraagtekst wordt alleen ondersteund met HTTP PUT-, POST- of PATCH-methoden.' + certificateNotValidYetExceptionMessage = 'Certificaat {0} is NOG niet geldig. Geldig vanaf: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certificaat is NIET geldig voor '{0}'. Gevonden doeleinden: {1}" + certificateUnknownEkusStrictModeExceptionMessage = "Certificaat bevat onbekende EKU's. Strikte modus wijst het af. Gevonden: {0}" + failedToCreateCertificateRequestExceptionMessage = 'Kan geen certificaataanvraag genereren.' + unsupportedCertificateKeyLengthExceptionMessage = 'Niet-ondersteunde certificaatsleutellengte: {0} bits. Gebruik een ondersteunde sleutellengte.' + invalidTypeExceptionMessage = 'Fout: Ongeldig type voor {0}. Verwacht {1}, maar ontvangen [{2}].' + certificateSignatureInvalidExceptionMessage = 'Het certificaat {0} heeft een ongeldige handtekening. Het certificaat kan zijn gewijzigd of is niet ondertekend door een vertrouwde autoriteit.' + certificateUntrustedRootExceptionMessage = 'Het certificaat {0} is uitgegeven door een niet-vertrouwde root. Installeer het root-CA-certificaat of gebruik een certificaat van een vertrouwde autoriteit.' + certificateRevokedExceptionMessage = 'Het certificaat {0} is ingetrokken. Reden: {1}. Verkrijg een nieuw geldig certificaat.' + certificateExpiredIntermediateExceptionMessage = 'Het certificaat {0} is ondertekend door een tussenliggend certificaat dat op {1} is verlopen. De certificaatketen is niet langer geldig.' + certificateValidationFailedExceptionMessage = 'Certificaatvalidatie mislukt voor {0}. Controleer de certificaatketen en de geldigheidsperiode.' + certificateWeakAlgorithmExceptionMessage = 'Het certificaat {0} gebruikt een zwak cryptografisch algoritme: {1}. Het wordt aanbevolen om SHA-256 of sterker te gebruiken.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Zelfondertekende certificaten zijn niet toegestaan vanwege beveiligingsbeperkingen.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' wordt alleen ondersteund voor HTTP PUT-, POST- of PATCH-methoden." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'Het PEM-certificaatformaat wordt niet ondersteund in PowerShell {0}. Gebruik een ander certificaatformaat of upgrade naar PowerShell 7.0 of later.' invalidPodeStateFormatExceptionMessage = 'Plik PodeState "{0}" ma nieprawidłowy format. Oczekiwano struktury podobnej do słownika (ConcurrentDictionary, Hashtable lub OrderedDictionary), ale znaleziono [{1}]. Sprawdź zawartość pliku lub ponownie zainicjalizuj stan.' unknownJsonDictionaryTypeExceptionMessage = 'Onbekend woordenboek-/collectietype in JSON: {0}' invalidPodeStateDataExceptionMessage = 'De opgegeven gegevens vertegenwoordigen geen geldige Pode-status.' diff --git a/src/Locales/pl/Pode.psd1 b/src/Locales/pl/Pode.psd1 index 5292410af..dab099cd6 100644 --- a/src/Locales/pl/Pode.psd1 +++ b/src/Locales/pl/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Odciski palców/nazwa certyfikatu są obsługiwane tylko w systemie Windows.' sseConnectionNameRequiredExceptionMessage = "Wymagana jest nazwa połączenia SSE, z -Name lub `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Jeden z dostarczonych Middleware jest nieprawidłowego typu. Oczekiwano ScriptBlock lub Hashtable, ale otrzymano: {0}' - noSecretForJwtSignatureExceptionMessage = 'Nie podano tajemnicy dla podpisu JWT.' modulePathDoesNotExistExceptionMessage = 'Ścieżka modułu nie istnieje: {0}' taskAlreadyDefinedExceptionMessage = '[Zadanie] {0}: Zadanie już zdefiniowane.' verbAlreadyDefinedExceptionMessage = '[Czasownik] {0}: Już zdefiniowane' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "Reguła limitu szybkości o nazwie '{0}' nie istnieje." accessLimitRuleAlreadyExistsExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' już istnieje." accessLimitRuleDoesNotExistExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' nie istnieje." + missingKeyForAlgorithmExceptionMessage = 'Klucz {0} jest wymagany dla algorytmów {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "Znacznik czasu 'iat' (Issued At) w JWT jest ustawiony w przyszłości. Token nie jest jeszcze ważny." + jwtInvalidIssuerExceptionMessage = "Pole 'iss' (Issuer) w JWT jest nieprawidłowe lub nieobecne. Oczekiwany wydawca: '{0}'." + jwtMissingIssuerExceptionMessage = "JWT nie zawiera wymaganego pola 'iss' (Issuer). Wymagany jest prawidłowy wydawca." + jwtInvalidAudienceExceptionMessage = "Pole 'aud' (Audience) w JWT jest nieprawidłowe lub nieobecne. Oczekiwana publiczność: '{0}'." + jwtMissingAudienceExceptionMessage = "JWT nie zawiera wymaganego pola 'aud' (Audience). Wymagana jest prawidłowa publiczność." + jwtInvalidSubjectExceptionMessage = "Pole 'sub' (Subject) w JWT jest nieprawidłowe lub nieobecne. Wymagany jest prawidłowy podmiot." + jwtInvalidJtiExceptionMessage = "Pole 'jti' (JWT ID) w JWT jest nieprawidłowe lub nieobecne. Wymagany jest prawidłowy unikalny identyfikator." + jwtAlgorithmMismatchExceptionMessage = 'Niezgodność algorytmu JWT: Oczekiwano {0}, znaleziono {1}.' + jwtMissingJtiExceptionMessage = "JWT nie zawiera wymaganego pola 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "OSTRZEŻENIE: Funkcja '{0}' jest przestarzała i zostanie usunięta w przyszłych wersjach. Proszę użyć funkcji '{1}' zamiast niej." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Nieznany algorytm lub nieprawidłowy format PEM.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Nieznany algorytm {0} (Rozmiar klucza: {1} bitów).' + jwtCertificateAuthNotSupportedExceptionMessage = 'Uwierzytelnianie certyfikatem JWT jest obsługiwane tylko w PowerShell 7.0 lub nowszym.' + jwtNoExpirationExceptionMessage = "JWT nie zawiera wymaganego pola 'exp' (Expiration). Wymagany jest prawidłowy czas wygaśnięcia." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Uwierzytelnianie tokenem Bearer za pomocą treści żądania jest obsługiwane tylko dla metod HTTP PUT, POST lub PATCH.' + certificateNotValidYetExceptionMessage = 'Certyfikat {0} NIE jest jeszcze ważny. Ważny od: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certyfikat NIE jest ważny dla '{0}'. Znalezione przeznaczenia: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Certyfikat zawiera nieznane EKU. Tryb ścisły go odrzuca. Znaleziono: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Nie udało się wygenerować żądania certyfikatu.' + unsupportedCertificateKeyLengthExceptionMessage = 'Nieobsługiwana długość klucza certyfikatu: {0} bitów. Proszę użyć obsługiwanej długości klucza.' + invalidTypeExceptionMessage = 'Błąd: Nieprawidłowy typ dla {0}. Oczekiwano {1}, ale otrzymano [{2}].' + certificateSignatureInvalidExceptionMessage = 'Certyfikat {0} ma nieprawidłowy podpis. Może zostać sfałszowany lub nie został podpisany przez zaufany urząd certyfikacji.' + certificateUntrustedRootExceptionMessage = 'Certyfikat {0} został wydany przez niezaufanego dostawcę. Zainstaluj certyfikat główny CA lub użyj certyfikatu od zaufanego dostawcy.' + certificateRevokedExceptionMessage = 'Certyfikat {0} został unieważniony. Powód: {1}. Proszę uzyskać nowy ważny certyfikat.' + certificateExpiredIntermediateExceptionMessage = 'Certyfikat {0} został podpisany przez certyfikat pośredni, który wygasł {1}. Łańcuch certyfikatów nie jest już ważny.' + certificateValidationFailedExceptionMessage = 'Weryfikacja certyfikatu dla {0} nie powiodła się. Sprawdź łańcuch certyfikatów i okres ważności.' + certificateWeakAlgorithmExceptionMessage = 'Certyfikat {0} używa słabego algorytmu kryptograficznego: {1}. Zaleca się użycie SHA-256 lub silniejszego.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Certyfikaty samopodpisane nie są dozwolone ze względu na ograniczenia bezpieczeństwa.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' jest obsługiwany tylko dla metod HTTP PUT, POST lub PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'Format certyfikatu PEM nie jest obsługiwany w PowerShell {0}. Użyj innego formatu certyfikatu lub zaktualizuj do PowerShell 7.0 lub nowszego.' invalidPodeStateFormatExceptionMessage = 'Plik PodeState "{0}" ma nieprawidłowy format. Oczekiwano struktury podobnej do słownika (ConcurrentDictionary, Hashtable lub OrderedDictionary), ale znaleziono [{1}]. Sprawdź zawartość pliku lub ponownie zainicjalizuj stan.' unknownJsonDictionaryTypeExceptionMessage = 'Nieznany typ słownika/kolekcji w JSON: {0}' invalidPodeStateDataExceptionMessage = 'Podane dane nie są poprawnym stanem Pode.' diff --git a/src/Locales/pt/Pode.psd1 b/src/Locales/pt/Pode.psd1 index af35ad6de..e458fa5e7 100644 --- a/src/Locales/pt/Pode.psd1 +++ b/src/Locales/pt/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Impressões digitais/nome do certificado são suportados apenas no Windows.' sseConnectionNameRequiredExceptionMessage = "Um nome de conexão SSE é necessário, seja de -Name ou `$WebEvent.Sse.Name." invalidMiddlewareTypeExceptionMessage = 'Um dos Middlewares fornecidos é de um tipo inválido. Esperado ScriptBlock ou Hashtable, mas obtido: {0}' - noSecretForJwtSignatureExceptionMessage = 'Nenhum segredo fornecido para a assinatura JWT.' modulePathDoesNotExistExceptionMessage = 'O caminho do módulo não existe: {0}' taskAlreadyDefinedExceptionMessage = '[Tarefa] {0}: Tarefa já definida.' verbAlreadyDefinedExceptionMessage = '[Verbo] {0}: Já definido' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = "A regra de limite de taxa com o nome '{0}' não existe." accessLimitRuleAlreadyExistsExceptionMessage = "A regra de limite de acesso com o nome '{0}' já existe." accessLimitRuleDoesNotExistExceptionMessage = "A regra de limite de acesso com o nome '{0}' não existe." + missingKeyForAlgorithmExceptionMessage = 'Uma chave {0} é necessária para os algoritmos {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "O carimbo de data/hora 'iat' (Emitido em) do JWT está definido para o futuro. O token ainda não é válido." + jwtInvalidIssuerExceptionMessage = "A reivindicação 'iss' (Issuer) do JWT é inválida ou está ausente. Emissor esperado: '{0}'." + jwtMissingIssuerExceptionMessage = "O JWT está sem a reivindicação obrigatória 'iss' (Issuer). Um emissor válido é necessário." + jwtInvalidAudienceExceptionMessage = "A reivindicação 'aud' (Audience) do JWT é inválida ou está ausente. Audiência esperada: '{0}'." + jwtMissingAudienceExceptionMessage = "O JWT está sem a reivindicação obrigatória 'aud' (Audience). Uma audiência válida é necessária." + jwtInvalidSubjectExceptionMessage = "A reivindicação 'sub' (Subject) do JWT é inválida ou está ausente. Um sujeito válido é necessário." + jwtInvalidJtiExceptionMessage = "A reivindicação 'jti' (JWT ID) do JWT é inválida ou está ausente. Um identificador único válido é necessário." + jwtAlgorithmMismatchExceptionMessage = 'Incompatibilidade de algoritmo JWT: Esperado {0}, encontrado {1}.' + jwtMissingJtiExceptionMessage = "O JWT está sem a reivindicação obrigatória 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "警告: 函数 '{0}' 已被弃用,并将在未来版本中移除。请改用函数 '{1}'。" + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algoritmo desconhecido ou formato PEM inválido.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algoritmo {0} desconhecido (Tamanho da chave: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'A autenticação de certificado JWT é suportada apenas no PowerShell 7.0 ou superior.' + jwtNoExpirationExceptionMessage = "O JWT está sem a reivindicação obrigatória 'exp' (Expiração)." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'A autenticação com token Bearer usando o corpo da solicitação é suportada apenas nos métodos HTTP PUT, POST ou PATCH.' + certificateNotValidYetExceptionMessage = 'O certificado {0} AINDA NÃO é válido. Válido a partir de: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "O certificado NÃO é válido para '{0}'. Finalidades encontradas: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'O certificado contém EKUs desconhecidos. O modo estrito o rejeita. Encontrado: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Falha ao gerar a solicitação de certificado.' + unsupportedCertificateKeyLengthExceptionMessage = 'Comprimento da chave do certificado não suportado: {0} bits. Use um comprimento de chave suportado.' + invalidTypeExceptionMessage = 'Erro: Tipo inválido para {0}. Esperado {1}, mas recebido [{2}].' + certificateSignatureInvalidExceptionMessage = 'O certificado {0} possui uma assinatura inválida. O certificado pode ter sido adulterado ou não foi assinado por uma autoridade confiável.' + certificateUntrustedRootExceptionMessage = 'O certificado {0} foi emitido por uma raiz não confiável. Instale o certificado da CA raiz ou use um certificado de uma autoridade confiável.' + certificateRevokedExceptionMessage = 'O certificado {0} foi revogado. Motivo: {1}. Por favor, obtenha um novo certificado válido.' + certificateExpiredIntermediateExceptionMessage = 'O certificado {0} foi assinado por um certificado intermediário que expirou em {1}. A cadeia de certificação não é mais válida.' + certificateValidationFailedExceptionMessage = 'Falha na validação do certificado para {0}. Verifique a cadeia de certificação e o período de validade.' + certificateWeakAlgorithmExceptionMessage = 'O certificado {0} usa um algoritmo criptográfico fraco: {1}. É recomendado o uso de SHA-256 ou superior.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Certificados autoassinados não são permitidos devido a restrições de segurança.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' é suportado apenas para os métodos HTTP PUT, POST ou PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'O formato de certificado PEM não é suportado no PowerShell {0}. Use um formato de certificado diferente ou atualize para o PowerShell 7.0 ou posterior.' invalidPodeStateFormatExceptionMessage = 'O arquivo PodeState "{0}" contém um formato inválido. Era esperado uma estrutura semelhante a um dicionário (ConcurrentDictionary, Hashtable ou OrderedDictionary), mas foi encontrado [{1}]. Verifique o conteúdo do arquivo ou reinicialize o estado.' unknownJsonDictionaryTypeExceptionMessage = 'Tipo de dicionário/coleção desconhecido no JSON: {0}' invalidPodeStateDataExceptionMessage = 'Os dados fornecidos não representam um estado válido do Pode.' diff --git a/src/Locales/zh/Pode.psd1 b/src/Locales/zh/Pode.psd1 index 2cb1bd87f..f4dedcd1d 100644 --- a/src/Locales/zh/Pode.psd1 +++ b/src/Locales/zh/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = '证书指纹/名称仅在 Windows 上受支持。' sseConnectionNameRequiredExceptionMessage = "需要SSE连接名称, 可以从-Name或`$WebEvent.Sse.Name获取。" invalidMiddlewareTypeExceptionMessage = '提供的中间件之一是无效的类型。期望是 ScriptBlock 或 Hashtable, 但得到了: {0}' - noSecretForJwtSignatureExceptionMessage = '未提供 JWT 签名的密钥。' modulePathDoesNotExistExceptionMessage = '模块路径不存在: {0}' taskAlreadyDefinedExceptionMessage = '[任务] {0}: 任务已定义。' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: 已经定义' @@ -326,6 +325,37 @@ rateLimitRuleDoesNotExistExceptionMessage = '速率限制规则不存在: {0}' accessLimitRuleAlreadyExistsExceptionMessage = '访问限制规则已存在: {0}' accessLimitRuleDoesNotExistExceptionMessage = '访问限制规则不存在: {0}' + missingKeyForAlgorithmExceptionMessage = 'Uma chave {0} é necessária para os algoritmos {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "JWT 的 'iat' (签发时间) 时间戳设置在未来。该令牌尚未生效。" + jwtInvalidIssuerExceptionMessage = "JWT 的 'iss' (发行者) 声明无效或缺失。预期发行者: '{0}'。" + jwtMissingIssuerExceptionMessage = "JWT 缺少必要的 'iss' (发行者) 声明。必须提供有效的发行者。" + jwtInvalidAudienceExceptionMessage = "JWT 的 'aud' (受众) 声明无效或缺失。预期受众: '{0}'。" + jwtMissingAudienceExceptionMessage = "JWT 缺少必要的 'aud' (受众) 声明。必须提供有效的受众。" + jwtInvalidSubjectExceptionMessage = "JWT 的 'sub' (主题) 声明无效或缺失。必须提供有效的主题。" + jwtInvalidJtiExceptionMessage = "JWT 的 'jti' (JWT ID) 声明无效或缺失。必须提供有效的唯一标识符。" + jwtAlgorithmMismatchExceptionMessage = 'JWT 算法不匹配: 预期 {0},实际 {1}。' + jwtMissingJtiExceptionMessage = "JWT 缺少必要的 'jti' (JWT ID) 声明。" + deprecatedFunctionWarningMessage = "警告: 函数 '{0}' 已被弃用,并将在未来版本中移除。请改用函数 '{1}'。" + unknownAlgorithmOrInvalidPfxExceptionMessage = '未知算法或无效的 PEM 格式。' + unknownAlgorithmWithKeySizeExceptionMessage = '未知 {0} 算法(密钥大小: {1} 位)。' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT 证书身份验证仅支持 PowerShell 7.0 或更高版本。' + jwtNoExpirationExceptionMessage = "JWT 缺少必要的 'exp' (到期时间) 声明。必须提供有效的到期时间。" + bearerTokenAuthMethodNotSupportedExceptionMessage = '使用请求正文进行Bearer令牌认证仅支持HTTP PUT、POST或PATCH方法。' + certificateNotValidYetExceptionMessage = '证书 {0} 仍然无效。有效期开始: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "证书对 '{0}' 无效。发现的用途: {1}" + certificateUnknownEkusStrictModeExceptionMessage = '证书包含未知的 EKU。严格模式拒绝它。发现: {0}' + failedToCreateCertificateRequestExceptionMessage = '生成证书请求失败。' + unsupportedCertificateKeyLengthExceptionMessage = '不支持的证书密钥长度: {0} 位。请使用受支持的密钥长度。' + invalidTypeExceptionMessage = '错误: {0} 的类型无效。期望 {1},但收到 [{2}]。' + certificateSignatureInvalidExceptionMessage = '证书 {0} 的签名无效。证书可能已被篡改,或未由受信任的机构签署。' + certificateUntrustedRootExceptionMessage = '证书 {0} 由不受信任的根证书颁发。请安装根 CA 证书或使用受信任机构的证书。' + certificateRevokedExceptionMessage = '证书 {0} 已被吊销。原因: {1}。请获取新的有效证书。' + certificateExpiredIntermediateExceptionMessage = '证书 {0} 由 {1} 过期的中间证书签署。证书链已失效。' + certificateValidationFailedExceptionMessage = '证书 {0} 的验证失败。请检查证书链和有效期。' + certificateWeakAlgorithmExceptionMessage = '证书 {0} 使用了弱加密算法: {1}。建议使用 SHA-256 或更强的算法。' + selfSignedCertificatesNotAllowedExceptionMessage = '由于安全限制,不允许使用自签名证书。' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' 仅支持 HTTP PUT、POST 或 PATCH 方法。" + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM 证书格式在 PowerShell {0} 中不受支持。请使用其他证书格式或升级到 PowerShell 7.0 或更高版本。' invalidPodeStateFormatExceptionMessage = 'PodeState 文件 "{0}" 的格式无效。预期为类似字典的结构 (ConcurrentDictionary、Hashtable 或 OrderedDictionary),但发现 [{1}]。请验证文件内容或重新初始化状态。' unknownJsonDictionaryTypeExceptionMessage = 'JSON 中的未知字典/集合类型: {0}' invalidPodeStateDataExceptionMessage = '提供的数据不是有效的 Pode 状态数据。' diff --git a/src/Pode.psd1 b/src/Pode.psd1 index 3d9cb9286..00e0bd337 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -261,9 +261,6 @@ 'Add-PodeAuthMiddleware', 'Add-PodeAuthIIS', 'Add-PodeAuthUserFile', - 'ConvertTo-PodeJwt', - 'ConvertFrom-PodeJwt', - 'Test-PodeJwt' 'Use-PodeAuth', 'ConvertFrom-PodeOIDCDiscovery', 'Test-PodeAuthUser', @@ -273,6 +270,22 @@ 'Get-PodeAuthUser', 'Add-PodeAuthSession', 'New-PodeAuthKeyTab', + 'New-PodeAuthBearerScheme', + 'New-PodeAuthDigestScheme', + + #JWT + 'ConvertTo-PodeJwt', + 'ConvertFrom-PodeJwt', + 'Test-PodeJwt', + 'Update-PodeJwt', + + #Certificate + 'Export-PodeCertificate', + 'Get-PodeCertificatePurpose', + 'Import-PodeCertificate', + 'New-PodeCertificateRequest', + 'New-PodeSelfSignedCertificate', + 'Test-PodeCertificate', # access 'New-PodeAccessScheme', diff --git a/src/Private/ADAuthentication.ps1 b/src/Private/ADAuthentication.ps1 new file mode 100644 index 000000000..c4aea6c09 --- /dev/null +++ b/src/Private/ADAuthentication.ps1 @@ -0,0 +1,749 @@ + + +function Get-PodeAuthWindowsADIISMethod { + return { + param($token, $options) + + # get the close handler + $win32Handler = Add-Type -Name Win32CloseHandle -PassThru -MemberDefinition @' + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr handle); +'@ + + try { + # parse the auth token and get the user + $winAuthToken = [System.IntPtr][Int]"0x$($token)" + $winIdentity = [System.Security.Principal.WindowsIdentity]::new($winAuthToken, 'Windows') + + # get user and domain + $username = ($winIdentity.Name -split '\\')[-1] + $domain = ($winIdentity.Name -split '\\')[0] + + # create base user object + $user = @{ + UserType = 'Domain' + Identity = @{ + AccessToken = $winIdentity.AccessToken + } + AuthenticationType = $winIdentity.AuthenticationType + DistinguishedName = [string]::Empty + Username = $username + Name = [string]::Empty + Email = [string]::Empty + Fqdn = [string]::Empty + Domain = $domain + Groups = @() + } + + # if the domain isn't local, attempt AD user + if (![string]::IsNullOrWhiteSpace($domain) -and (@('.', $PodeContext.Server.ComputerName) -inotcontains $domain)) { + # get the server's fdqn (and name/email) + try { + # Open ADSISearcher and change context to given domain + $searcher = [adsisearcher]'' + $searcher.SearchRoot = [adsi]"LDAP://$($domain)" + $searcher.Filter = "ObjectSid=$($winIdentity.User.Value.ToString())" + + # Query the ADSISearcher for the above defined SID + $ad = $searcher.FindOne() + + # Save it to our existing array for later usage + $user.DistinguishedName = @($ad.Properties.distinguishedname)[0] + $user.Name = @($ad.Properties.name)[0] + $user.Email = @($ad.Properties.mail)[0] + $user.Fqdn = (Get-PodeADServerFromDistinguishedName -DistinguishedName $user.DistinguishedName) + } + finally { + Close-PodeDisposable -Disposable $searcher + } + + try { + if (!$options.NoGroups) { + + # open a new connection + $result = (Open-PodeAuthADConnection -Server $user.Fqdn -Domain $domain -Provider $options.Provider) + if (!$result.Success) { + return @{ Message = "Failed to connect to Domain Server '$($user.Fqdn)' of $domain for $($user.DistinguishedName)." } + } + + # get the connection + $connection = $result.Connection + + # get the users groups + $directGroups = $options.DirectGroups + $user.Groups = (Get-PodeAuthADGroup -Connection $connection -DistinguishedName $user.DistinguishedName -Username $user.Username -Direct:$directGroups -Provider $options.Provider) + } + } + finally { + if ($null -ne $connection) { + Close-PodeDisposable -Disposable $connection.Searcher + Close-PodeDisposable -Disposable $connection.Entry -Close + $connection.Credential = $null + } + } + } + + # otherwise, get details of local user + else { + # get the user's name and groups + try { + $user.UserType = 'Local' + + if (!$options.NoLocalCheck) { + $localUser = $winIdentity.Name -replace '\\', '/' + $ad = [adsi]"WinNT://$($localUser)" + $user.Name = @($ad.FullName)[0] + + # dirty, i know :/ - since IIS runs using pwsh, the InvokeMember part fails + # we can safely call windows powershell here, as IIS is only on windows. + if (!$options.NoGroups) { + $cmd = "`$ad = [adsi]'WinNT://$($localUser)'; @(`$ad.Groups() | Foreach-Object { `$_.GetType().InvokeMember('Name', 'GetProperty', `$null, `$_, `$null) })" + $user.Groups = [string[]](powershell -c $cmd) + } + } + } + finally { + Close-PodeDisposable -Disposable $ad -Close + } + } + } + catch { + $_ | Write-PodeErrorLog + return @{ Message = 'Failed to retrieve user using Authentication Token' } + } + finally { + $win32Handler::CloseHandle($winAuthToken) + } + + # is the user valid for any users/groups - if not, error! + if (!(Test-PodeAuthUserGroup -User $user -Users $options.Users -Groups $options.Groups)) { + return @{ Message = 'You are not authorised to access this website' } + } + + $result = @{ User = $user } + + # call additional scriptblock if supplied + if ($null -ne $options.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $options.ScriptBlock.Script -UsingVariables $options.ScriptBlock.UsingVariables + } + + # return final result, this could contain a user obj, or an error message from custom scriptblock + return $result + } +} + +function Get-PodeADServerFromDistinguishedName { + param( + [Parameter()] + [string] + $DistinguishedName + ) + + if ([string]::IsNullOrWhiteSpace($DistinguishedName)) { + return [string]::Empty + } + + $parts = @($DistinguishedName -split ',') + $name = @() + + foreach ($part in $parts) { + if ($part -imatch '^DC=(?.+)$') { + $name += $Matches['name'] + } + } + + return ($name -join '.') +} + +function Get-PodeAuthADResult { + param( + [Parameter()] + [string] + $Server, + + [Parameter()] + [string] + $Domain, + + [Parameter()] + [string] + $SearchBase, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [string] + $Password, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider, + + [switch] + $NoGroups, + + [switch] + $DirectGroups, + + [switch] + $KeepCredential + ) + + try { + # validate the user's AD creds + $result = (Open-PodeAuthADConnection -Server $Server -Domain $Domain -Username $Username -Password $Password -Provider $Provider) + if (!$result.Success) { + return @{ Message = 'Invalid credentials supplied' } + } + + # get the connection + $connection = $result.Connection + + # get the user + $user = (Get-PodeAuthADUser -Connection $connection -Username $Username -Provider $Provider) + if ($null -eq $user) { + return @{ Message = 'User not found in Active Directory' } + } + + # get the users groups + $groups = @() + if (!$NoGroups) { + $groups = (Get-PodeAuthADGroup -Connection $connection -DistinguishedName $user.DistinguishedName -Username $Username -Direct:$DirectGroups -Provider $Provider) + } + + # check if we want to keep the credentials in the User object + if ($KeepCredential) { + $credential = [pscredential]::new($($Domain + '\' + $Username), (ConvertTo-SecureString -String $Password -AsPlainText -Force)) + } + else { + $credential = $null + } + + # return the user + return @{ + User = @{ + UserType = 'Domain' + AuthenticationType = 'LDAP' + DistinguishedName = $user.DistinguishedName + Username = ($Username -split '\\')[-1] + Name = $user.Name + Email = $user.Email + Fqdn = $Server + Domain = $Domain + Groups = $groups + Credential = $credential + } + } + } + finally { + if ($null -ne $connection) { + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $connection.Username = $null + $connection.Password = $null + } + + 'activedirectory' { + $connection.Credential = $null + } + + 'directoryservices' { + Close-PodeDisposable -Disposable $connection.Searcher + Close-PodeDisposable -Disposable $connection.Entry -Close + } + } + } + } +} + +function Open-PodeAuthADConnection { + param( + [Parameter(Mandatory = $true)] + [string] + $Server, + + [Parameter()] + [string] + $Domain, + + [Parameter()] + [string] + $SearchBase, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [string] + $Password, + + [Parameter()] + [ValidateSet('LDAP', 'WinNT')] + [string] + $Protocol = 'LDAP', + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + $result = $true + $connection = $null + + # validate the user's AD creds + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + if (![string]::IsNullOrWhiteSpace($SearchBase)) { + $baseDn = $SearchBase + } + else { + $baseDn = "DC=$(($Server -split '\.') -join ',DC=')" + } + + $query = (Get-PodeAuthADQuery -Username $Username) + $hostname = "$($Protocol)://$($Server)" + + $user = $Username + if (!$Username.StartsWith($Domain)) { + $user = "$($Domain)\$($Username)" + } + + $null = (ldapsearch -x -LLL -H "$($hostname)" -D "$($user)" -w "$($Password)" -b "$($baseDn)" -o ldif-wrap=no "$($query)" dn) + if (!$? -or ($LASTEXITCODE -ne 0)) { + $result = $false + } + else { + $connection = @{ + Hostname = $hostname + Username = $user + BaseDN = $baseDn + Password = $Password + } + } + } + + 'activedirectory' { + try { + $creds = [pscredential]::new($Username, (ConvertTo-SecureString -String $Password -AsPlainText -Force)) + $null = Get-ADUser -Identity $Username -Credential $creds -ErrorAction Stop + $connection = @{ + Credential = $creds + } + } + catch { + $result = $false + } + } + + 'directoryservices' { + if ([string]::IsNullOrWhiteSpace($Password)) { + $ad = [System.DirectoryServices.DirectoryEntry]::new("$($Protocol)://$($Server)") + } + else { + $ad = [System.DirectoryServices.DirectoryEntry]::new("$($Protocol)://$($Server)", "$($Username)", "$($Password)") + } + + if (Test-PodeIsEmpty $ad.distinguishedName) { + $result = $false + } + else { + $connection = @{ + Entry = $ad + } + } + } + } + + return @{ + Success = $result + Connection = $connection + } +} + +function Get-PodeAuthADQuery { + param( + [Parameter(Mandatory = $true)] + [string] + $Username + ) + + return "(&(objectCategory=person)(samaccountname=$($Username)))" +} + +function Get-PodeAuthADUser { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter(Mandatory = $true)] + [string] + $Username, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + $query = (Get-PodeAuthADQuery -Username $Username) + $user = $null + + # generate query to find user + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" name mail) + if (!$? -or ($LASTEXITCODE -ne 0)) { + return $null + } + + $user = @{ + DistinguishedName = (Get-PodeOpenLdapValue -Lines $result -Property 'dn') + Name = (Get-PodeOpenLdapValue -Lines $result -Property 'name') + Email = (Get-PodeOpenLdapValue -Lines $result -Property 'mail') + } + } + + 'activedirectory' { + $result = Get-ADUser -LDAPFilter $query -Credential $Connection.Credential -Properties mail + $user = @{ + DistinguishedName = $result.DistinguishedName + Name = $result.Name + Email = $result.mail + } + } + + 'directoryservices' { + $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) + $Connection.Searcher.filter = $query + + $result = $Connection.Searcher.FindOne().Properties + if (Test-PodeIsEmpty $result) { + return $null + } + + $user = @{ + DistinguishedName = @($result.distinguishedname)[0] + Name = @($result.name)[0] + Email = @($result.mail)[0] + } + } + } + + return $user +} + + + +function Get-PodeOpenLdapValue { + param( + [Parameter()] + [string[]] + $Lines, + + [Parameter()] + [string] + $Property, + + [switch] + $All + ) + + foreach ($line in $Lines) { + if ($line -imatch "^$($Property)\:\s+(?<$($Property)>.+)$") { + # return the first found + if (!$All) { + return $Matches[$Property] + } + + # return array of all + $Matches[$Property] + } + } +} +<# +.SYNOPSIS + Retrieves Active Directory (AD) group information for a user. + +.DESCRIPTION + This function retrieves AD group information for a specified user. It supports two modes of operation: + 1. Direct: Retrieves groups directly associated with the user. + 2. All: Retrieves all groups within the specified distinguished name (DN). + +.PARAMETER Connection + The AD connection object or credentials for connecting to the AD server. + +.PARAMETER DistinguishedName + The distinguished name (DN) of the user or group. If not provided, the default DN is used. + +.PARAMETER Username + The username for which to retrieve group information. + +.PARAMETER Provider + The AD provider to use (e.g., 'DirectoryServices', 'ActiveDirectory', 'OpenLDAP'). + +.PARAMETER Direct + Switch parameter. If specified, retrieves only direct group memberships for the user. + +.OUTPUTS + Returns AD group information as needed based on the mode of operation. + +.EXAMPLE + Get-PodeAuthADGroup -Connection $adConnection -Username "john.doe" + # Retrieves all AD groups for the user "john.doe". + + Get-PodeAuthADGroup -Connection $adConnection -Username "jane.smith" -Direct + # Retrieves only direct group memberships for the user "jane.smith". +#> +function Get-PodeAuthADGroup { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter()] + [string] + $DistinguishedName, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider, + + [switch] + $Direct + ) + + if ($Direct) { + return (Get-PodeAuthADGroupDirect -Connection $Connection -Username $Username -Provider $Provider) + } + + return (Get-PodeAuthADGroupAll -Connection $Connection -DistinguishedName $DistinguishedName -Provider $Provider) +} + +function Get-PodeAuthADGroupDirect { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + # create the query + $query = "(&(objectCategory=person)(samaccountname=$($Username)))" + $groups = @() + + # get the groups + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" memberof) + $groups = (Get-PodeOpenLdapValue -Lines $result -Property 'memberof' -All) + } + + 'activedirectory' { + $groups = (Get-ADPrincipalGroupMembership -Identity $Username -Credential $Connection.Credential).distinguishedName + } + + 'directoryservices' { + if ($null -eq $Connection.Searcher) { + $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) + } + + $Connection.Searcher.filter = $query + $groups = @($Connection.Searcher.FindOne().Properties.memberof) + } + } + + $groups = @(foreach ($group in $groups) { + if ($group -imatch '^CN=(?.+?),') { + $Matches['group'] + } + }) + + return $groups +} + +function Get-PodeAuthADGroupAll { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter()] + [string] + $DistinguishedName, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + # create the query + $query = "(member:1.2.840.113556.1.4.1941:=$($DistinguishedName))" + $groups = @() + + # get the groups + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" samaccountname) + $groups = (Get-PodeOpenLdapValue -Lines $result -Property 'sAMAccountName' -All) + } + + 'activedirectory' { + $groups = (Get-ADObject -LDAPFilter $query -Credential $Connection.Credential).Name + } + + 'directoryservices' { + if ($null -eq $Connection.Searcher) { + $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) + } + + $null = $Connection.Searcher.PropertiesToLoad.Add('samaccountname') + $Connection.Searcher.filter = $query + $groups = @($Connection.Searcher.FindAll().Properties.samaccountname) + } + } + + return $groups +} + +function Get-PodeAuthDomainName { + $domain = $null + + if (Test-PodeIsMacOS) { + $domain = (scutil --dns | grep -m 1 'search domain\[0\]' | cut -d ':' -f 2) + } + elseif (Test-PodeIsUnix) { + $domain = (dnsdomainname) + if ([string]::IsNullOrWhiteSpace($domain)) { + $domain = (/usr/sbin/realm list --name-only) + } + } + else { + $domain = $env:USERDNSDOMAIN + if ([string]::IsNullOrWhiteSpace($domain)) { + $domain = (Get-CimInstance -Class Win32_ComputerSystem -Verbose:$false).Domain + } + } + + if (![string]::IsNullOrEmpty($domain)) { + $domain = $domain.Trim() + } + + return $domain +} + + +function Get-PodeAuthWindowsADMethod { + return { + param($username, $password, $options) + + # using pscreds? + if (($null -eq $options) -and ($username -is [pscredential])) { + $_username = ([pscredential]$username).UserName + $_password = ([pscredential]$username).GetNetworkCredential().Password + $_options = [hashtable]$password + } + else { + $_username = $username + $_password = $password + $_options = $options + } + + # parse username to remove domains + $_username = (($_username -split '@')[0] -split '\\')[-1] + + # validate and retrieve the AD user + $noGroups = $_options.NoGroups + $directGroups = $_options.DirectGroups + $keepCredential = $_options.KeepCredential + + $result = Get-PodeAuthADResult ` + -Server $_options.Server ` + -Domain $_options.Domain ` + -SearchBase $_options.SearchBase ` + -Username $_username ` + -Password $_password ` + -Provider $_options.Provider ` + -NoGroups:$noGroups ` + -DirectGroups:$directGroups ` + -KeepCredential:$keepCredential + + # if there's a message, fail and return the message + if (![string]::IsNullOrWhiteSpace($result.Message)) { + return $result + } + + # if there's no user, then, err, oops + if (Test-PodeIsEmpty $result.User) { + return @{ Message = 'An unexpected error occured' } + } + + # is the user valid for any users/groups - if not, error! + if (!(Test-PodeAuthUserGroup -User $result.User -Users $_options.Users -Groups $_options.Groups)) { + return @{ Message = 'You are not authorised to access this website' } + } + + # call additional scriptblock if supplied + if ($null -ne $_options.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $_options.ScriptBlock.Script -UsingVariables $_options.ScriptBlock.UsingVariables + } + + # return final result, this could contain a user obj, or an error message from custom scriptblock + return $result + } +} + + + +function Import-PodeAuthADModule { + if (!(Test-PodeIsWindows)) { + # Active Directory module only available on Windows + throw ($PodeLocale.adModuleWindowsOnlyExceptionMessage) + } + + if (!(Test-PodeModuleInstalled -Name ActiveDirectory)) { + # Active Directory module is not installed + throw ($PodeLocale.adModuleNotInstalledExceptionMessage) + } + + Import-Module -Name ActiveDirectory -Force -ErrorAction Stop + Export-PodeModule -Name ActiveDirectory +} + +function Get-PodeAuthADProvider { + param( + [switch] + $OpenLDAP, + + [switch] + $ADModule + ) + + # openldap (literal, or not windows) + if ($OpenLDAP -or !(Test-PodeIsWindows)) { + return 'OpenLDAP' + } + + # ad module + if ($ADModule) { + return 'ActiveDirectory' + } + + # ds + return 'DirectoryServices' +} diff --git a/src/Private/Authentication.ps1 b/src/Private/Authentication.ps1 index c8331de65..15ec10fe8 100644 --- a/src/Private/Authentication.ps1 +++ b/src/Private/Authentication.ps1 @@ -1,29 +1,81 @@ function Get-PodeAuthBasicType { + <# + .SYNOPSIS + Processes Basic Authentication from the Authorization header. + + .DESCRIPTION + The `Get-PodeAuthBasicType` function extracts and validates the Basic Authorization header + from an HTTP request. It verifies the header format, decodes Base64 credentials, + and returns the extracted username and password. If any validation step fails, + an appropriate HTTP response code and challenge are returned. + + .PARAMETER options + A hashtable containing options for processing the authentication: + - `HeaderTag` [string]: Expected header prefix (e.g., "Basic"). + - `Encoding` [string]: Character encoding for decoding the credentials (default: UTF-8). + - `AsCredential` [bool]: If true, returns credentials as a [PSCredential] object. + + .OUTPUTS + [array] + Returns an array containing the extracted username and password. + If `AsCredential` is set to `$true`, returns a `[PSCredential]` object. + + .EXAMPLE + $options = @{ HeaderTag = 'Basic'; Encoding = 'UTF-8'; AsCredential = $false } + $result = Get-PodeAuthBasicType -options $options + + Returns: + @('username', 'password') + + .EXAMPLE + $options = @{ HeaderTag = 'Basic'; Encoding = 'UTF-8'; AsCredential = $true } + $result = Get-PodeAuthBasicType -options $options + + Returns: + [PSCredential] object containing username and password. + + .NOTES + This function is internal to Pode and subject to change in future releases. + + Possible response codes: + - 401 Unauthorized: When the Authorization header is missing. + - 400 Bad Request: For invalid format, encoding, or credential issues. + + Challenge responses include the following error types: + - `invalid_request` for missing or incorrectly formatted headers. + - `invalid_token` for improperly encoded or malformed credentials. + #> return { param($options) # get the auth header $header = (Get-PodeHeader -Name 'Authorization') if ($null -eq $header) { + $message = 'No Authorization header found' return @{ - Message = 'No Authorization header found' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 } } # ensure the first atom is basic (or opt override) $atoms = $header -isplit '\s+' if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } if ($atoms[0] -ine $options.HeaderTag) { + $message = "Header is not for $($options.HeaderTag) Authorization" return @{ - Message = "Header is not for $($options.HeaderTag) Authorization" - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -32,9 +84,11 @@ function Get-PodeAuthBasicType { $enc = [System.Text.Encoding]::GetEncoding($options.Encoding) } catch { + $message = 'Invalid encoding specified for Authorization' return @{ - Message = 'Invalid encoding specified for Authorization' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -42,9 +96,22 @@ function Get-PodeAuthBasicType { $decoded = $enc.GetString([System.Convert]::FromBase64String($atoms[1])) } catch { + $message = 'Invalid Base64 string found in Authorization header' return @{ - Message = 'Invalid Base64 string found in Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 400 + } + } + + # ensure the decoded string contains a colon separator + $index = $decoded.IndexOf(':') + if ($index -lt 0) { + $message = 'Invalid Authorization credential format' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -68,6 +135,7 @@ function Get-PodeAuthBasicType { } } + function Get-PodeAuthOAuth2Type { return { param($options, $schemes) @@ -282,32 +350,91 @@ function Get-PodeOAuth2RedirectHost { } function Get-PodeAuthClientCertificateType { + <# + .SYNOPSIS + Validates and extracts information from a client certificate in an HTTP request. + + .DESCRIPTION + The `Get-PodeAuthClientCertificateType` function processes the client certificate + from an incoming HTTP request. It validates whether the certificate is supplied, + checks its validity, and ensures it's trusted. If any of these checks fail, + appropriate response codes and challenges are returned. + + .PARAMETER options + A hashtable containing options that can be used to extend the function in the future. + + .OUTPUTS + [array] + Returns an array containing the validated client certificate and any associated errors. + + .EXAMPLE + $options = @{} + $result = Get-PodeAuthClientCertificateType -options $options + + Returns: + An array with the client certificate object and any certificate validation errors. + + .EXAMPLE + $options = @{} + $result = Get-PodeAuthClientCertificateType -options $options + + Example Output: + @($cert, 0) + + .NOTES + This function is internal to Pode and subject to change in future releases. + + Possible response codes: + - 401 Unauthorized: When the client certificate is missing or invalid. + - 403 Forbidden: When the client certificate is untrusted. + + Challenge responses include the following error types: + - `invalid_request`: If no certificate is provided. + - `invalid_token`: If the certificate is invalid, expired, or untrusted. + + #> return { param($options) $cert = $WebEvent.Request.ClientCertificate # ensure we have a client cert if ($null -eq $cert) { + $message = 'No client certificate supplied' return @{ - Message = 'No client certificate supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 } } # ensure the cert has a thumbprint if ([string]::IsNullOrWhiteSpace($cert.Thumbprint)) { + $message = 'Invalid client certificate supplied' return @{ - Message = 'Invalid client certificate supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 401 } } # ensure the cert hasn't expired, or has it even started $now = [datetime]::Now if (($cert.NotAfter -lt $now) -or ($cert.NotBefore -gt $now)) { + $message = 'Invalid client certificate supplied (expired or not yet valid)' return @{ - Message = 'Invalid client certificate supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 401 + } + } + + $errors = $WebEvent.Request.ClientCertificateErrors + if ($errors -ne 0) { + $message = 'Untrusted client certificate supplied' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 403 } } @@ -358,12 +485,43 @@ function Get-PodeAuthNegotiateType { } function Get-PodeAuthApiKeyType { + <# + .SYNOPSIS + Handles API key authentication by retrieving the key from various locations. + + .DESCRIPTION + The `Get-PodeAuthApiKeyType` function extracts and validates API keys + from specified locations such as headers, query parameters, or cookies. + If the API key is found, it is returned as a result; otherwise, + an appropriate authentication challenge is issued. + + .PARAMETER $options + A hashtable containing the following keys: + - `Location`: Specifies where to retrieve the API key from (`header`, `query`, or `cookie`). + - `LocationName`: The name of the header, query parameter, or cookie that holds the API key. + - `AsJWT`: (Optional) If set to `$true`, the function will treat the API key as a JWT token. + - `Secret`: (Required if `AsJWT` is `$true`) The secret used to validate the JWT token. + + .OUTPUTS + [array] + Returns an array containing the extracted API key or JWT payload if authentication is successful. + + .NOTES + The function will return an HTTP 400 response code if the API key is not found. + If `AsJWT` is enabled, the key will be decoded and validated using the provided secret. + The challenge response is formatted to align with authentication best practices. + + Possible HTTP response codes: + - 400 Bad Request: When the API key is missing or JWT validation fails. + + #> return { param($options) - # get api key from appropriate location + # Initialize API key variable $apiKey = [string]::Empty + # Determine API key location and retrieve it switch ($options.Location.ToLowerInvariant()) { 'header' { $apiKey = Get-PodeHeader -Name $options.LocationName @@ -376,38 +534,50 @@ function Get-PodeAuthApiKeyType { 'cookie' { $apiKey = Get-PodeCookieValue -Name $options.LocationName } + default { + $message = "Invalid API key location: $($options.Location)" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 + } + } } - # 400 if no key + # If no API key found, return error if ([string]::IsNullOrWhiteSpace($apiKey)) { + $message = "API key missing in $($options.Location) location: $($options.LocationName)" return @{ - Message = "No $($options.LocationName) $($options.Location) found" - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } - # build the result + # Trim and process the API key $apiKey = $apiKey.Trim() $result = @($apiKey) - # convert as jwt? + # Convert to JWT if required if ($options.AsJWT) { try { - $payload = ConvertFrom-PodeJwt -Token $apiKey -Secret $options.Secret - Test-PodeJwt -Payload $payload + #$payload = ConvertFrom-PodeJwt -Token $apiKey -Secret $options.Secret -Algorithm $options.Algorithm + $result = Confirm-PodeJwt -Token $apiKey -Secret $options.Secret -Algorithm $options.Algorithm + # Test-PodeJwt -Payload $result #-JwtVerificationMode $options.JwtVerificationMode + Test-PodeJwt -Payload $result } catch { if ($_.Exception.Message -ilike '*jwt*') { + $message = "Invalid JWT token: $($_.Exception.Message)" return @{ - Message = $_.Exception.Message - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } throw } - - $result = @($payload) } # return the result @@ -416,167 +586,299 @@ function Get-PodeAuthApiKeyType { } function Get-PodeAuthBearerType { + <# + .SYNOPSIS + Validates the Bearer token in the Authorization header. + + .DESCRIPTION + This function processes the Authorization header, verifies the presence of a Bearer token, + and optionally decodes it as a JWT. It returns appropriate HTTP response codes + as per RFC 6750 (OAuth 2.0 Bearer Token Usage). + + .PARAMETER $options + A hashtable containing the following keys: + - Realm: The authentication realm. + - Scopes: Expected scopes for the token. + - BearerTag: The expected Authorization header tag (e.g., 'Bearer'). + - AsJWT: Boolean indicating if the token should be processed as a JWT. + - Secret: Secret key for JWT verification. + + .OUTPUTS + A hashtable containing the following keys based on the validation result: + - Message: Error or success message. + - Code: HTTP response code. + - Header: HTTP response header for authentication challenges. + - Challenge: Optional authentication challenge. + + .NOTES + The function adheres to RFC 6750, which mandates: + - 401 Unauthorized for missing or invalid authentication credentials. + - 400 Bad Request for malformed requests. + + RFC 6750 HTTP Status Code Usage + # | Scenario | Recommended Status Code | + # |-------------------------------------------|-------------------------| + # | No Authorization header provided | 401 Unauthorized | + # | Incorrect Authorization header format | 401 Unauthorized | + # | Wrong authentication scheme used | 401 Unauthorized | + # | Token is empty or malformed | 400 Bad Request | + # | Invalid JWT signature | 401 Unauthorized | + #> return { param($options) - # get the auth header + # Get the Authorization header $header = (Get-PodeHeader -Name 'Authorization') + + # If no Authorization header is provided, return 401 Unauthorized if ($null -eq $header) { + $message = 'No Authorization header found' return @{ - Message = 'No Authorization header found' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) - Code = 400 + Message = $message + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + Code = 401 # RFC 6750: Missing credentials should return 401 } } + switch ($options.Location.ToLowerInvariant()) { + 'header' { + # Ensure the first part of the header is 'Bearer' + $atoms = $header -isplit '\s+' - # ensure the first atom is bearer - $atoms = $header -isplit '\s+' - if ($atoms.Length -lt 2) { - return @{ - Message = 'Invalid Authorization header' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) - Code = 400 + # 400 Bad Request if no token is provided + $token = $atoms[1] + if ([string]::IsNullOrWhiteSpace($token)) { + $message = 'No Bearer token found' + return @{ + Message = $message + Code = 401 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + } + } + + if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message) + Code = 401 # RFC 6750: Invalid credentials format should return 401 + } + } + + if ($atoms[0] -ine $options.BearerTag) { + $message = "Authorization header is not $($options.BearerTag)" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message) + Code = 401 # RFC 6750: Wrong authentication scheme should return 401 + } + } } - } - if ($atoms[0] -ine $options.HeaderTag) { - return @{ - Message = "Authorization header is not $($options.HeaderTag)" - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) - Code = 400 + 'query' { + # support RFC6750 + $token = $WebEvent.Query[$options.BearerTag] + if ([string]::IsNullOrWhiteSpace($token)) { + $message = 'No Bearer token found' + return @{ + Message = $message + Code = 400 # RFC 6750: Malformed request should return 400 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + } + } } - } - # 400 if no token - $token = $atoms[1] - if ([string]::IsNullOrWhiteSpace($token)) { - return @{ - Message = 'No Bearer token found' - Code = 400 + 'body' { + # support RFC6750 + $token = $WebEvent.Data.($options.BearerTag) + if ([string]::IsNullOrWhiteSpace($token)) { + $message = 'No Bearer token found' + return @{ + Message = $message + Code = 400 # RFC 6750: Malformed request should return 400 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + } + } + } + + default { + $message = "Invalid Bearer Token location: $($options.Location)" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 + } } } - # build the result + + # Trim and build the result $token = $token.Trim() - $result = @($token) + #$result = @($token) - # convert as jwt? + # Convert to JWT if required if ($options.AsJWT) { try { - $payload = ConvertFrom-PodeJwt -Token $token -Secret $options.Secret - Test-PodeJwt -Payload $payload + $param = @{ + Token = $token + Secret = $options.Secret + X509Certificate = $options.X509Certificate + Algorithm = $options.Algorithm + } + $result = Confirm-PodeJwt @param + Test-PodeJwt -Payload $result -JwtVerificationMode $options.JwtVerificationMode } catch { if ($_.Exception.Message -ilike '*jwt*') { return @{ - Message = $_.Exception.Message - #https://www.rfc-editor.org/rfc/rfc6750 Bearer token should return 401 - Code = 401 + Message = $_.Exception.Message + Code = 401 # RFC 6750: Invalid token should return 401 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_token -ErrorDescription $_.Exception.Message } } throw } - $result = @($payload) } - - # return the result + else { + $result = $token + } + # Return the validated result return $result } } function Get-PodeAuthBearerPostValidator { + <# + .SYNOPSIS + Validates the Bearer token and user authentication. + + .DESCRIPTION + This function processes the Bearer token, checks for the presence of a valid user, + and verifies token scopes against required scopes. It returns appropriate HTTP response codes + as per RFC 6750 (OAuth 2.0 Bearer Token Usage). + + .PARAMETER token + The Bearer token provided by the client. + + .PARAMETER result + The decoded token result containing user and scope information. + + .PARAMETER options + A hashtable containing the following keys: + - Scopes: An array of required scopes for authorization. + + .OUTPUTS + A hashtable containing the following keys based on the validation result: + - Message: Error or success message. + - Code: HTTP response code. + - Challenge: HTTP response challenge in case of errors. + + .NOTES + The function adheres to RFC 6750, which mandates: + - 401 Unauthorized for missing or invalid authentication credentials. + - 403 Forbidden for insufficient scopes. + #> return { param($token, $result, $options) - # if there's no user, fail with challenge + # Validate user presence in the token if (($null -eq $result) -or ($null -eq $result.User)) { + $message = 'User authentication failed' return @{ - Message = 'User not found' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_token) + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_token -ErrorDescription $message ) Code = 401 } } - # check for an error and description + # Check for token error and return appropriate response if (![string]::IsNullOrWhiteSpace($result.Error)) { return @{ - Message = 'Authorization failed' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType $result.Error -ErrorDescription $result.ErrorDescription) + Message = $result.ErrorDescription + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType $result.Error -ErrorDescription $result.ErrorDescription) Code = 401 } } - # check the scopes + # Scope validation $hasAuthScopes = (($null -ne $options.Scopes) -and ($options.Scopes.Length -gt 0)) - $hasTokenScope = ![string]::IsNullOrWhiteSpace($result.Scope) + $hasTokenScope = (($null -ne $result.Scope) -and ($result.Scope.Length -gt 0)) - # 403 if we have auth scopes but no token scope + # Return 403 if authorization scopes exist but token lacks scopes if ($hasAuthScopes -and !$hasTokenScope) { + $message = 'Token scope is missing or invalid' return @{ - Message = 'Invalid Scope' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType insufficient_scope) + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType insufficient_scope -ErrorDescription $message ) Code = 403 } } - # 403 if we have both, but token not in auth scope - if ($hasAuthScopes -and $hasTokenScope -and ($options.Scopes -notcontains $result.Scope)) { + # Return 403 if token scopes do not intersect with required auth scopes + if ($hasAuthScopes -and $hasTokenScope -and (-not ($options.Scopes | Where-Object { $_ -in $result.Scope }))) { + $message = 'Token scope is insufficient' return @{ - Message = 'Invalid Scope' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType insufficient_scope) + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType insufficient_scope -ErrorDescription $message ) Code = 403 } } - # return result + # Return the validated token result return $result } } -function New-PodeAuthBearerChallenge { - param( - [Parameter()] - [string[]] - $Scopes, - - [Parameter()] - [ValidateSet('', 'invalid_request', 'invalid_token', 'insufficient_scope')] - [string] - $ErrorType, - - [Parameter()] - [string] - $ErrorDescription - ) - - $items = @() - if (($null -ne $Scopes) -and ($Scopes.Length -gt 0)) { - $items += "scope=`"$($Scopes -join ' ')`"" - } - - if (![string]::IsNullOrWhiteSpace($ErrorType)) { - $items += "error=`"$($ErrorType)`"" - } - - if (![string]::IsNullOrWhiteSpace($ErrorDescription)) { - $items += "error_description=`"$($ErrorDescription)`"" - } - return ($items -join ', ') -} function Get-PodeAuthDigestType { + <# + .SYNOPSIS + Validates the Digest token in the Authorization header. + + .DESCRIPTION + This function processes the Authorization header, verifies the presence of a Digest token, + and optionally decodes it. It returns appropriate HTTP response codes + as per RFC 7616 (HTTP Digest Access Authentication). + + .PARAMETER $options + A hashtable containing the following keys: + - Realm: The authentication realm. + - Nonce: A unique value provided by the server to prevent replay attacks. + - HeaderTag: The expected Authorization header tag (e.g., 'Digest'). + + .OUTPUTS + A hashtable containing the following keys based on the validation result: + - Message: Error or success message. + - Code: HTTP response code. + - Challenge: Optional authentication challenge. + + .NOTES + The function adheres to RFC 7616, which mandates: + - 401 Unauthorized for missing or invalid authentication credentials. + - 400 Bad Request for malformed requests. + + - RFC 7616 HTTP Status Code Usage + | Scenario | Recommended Status Code | + |-------------------------------------------|-------------------------| + | No Authorization header provided | 401 Unauthorized | + | Incorrect Authorization header format | 401 Unauthorized | + | Wrong authentication scheme used | 401 Unauthorized | + | Token is empty or malformed | 400 Bad Request | + | Invalid digest response | 401 Unauthorized | + + #> return { param($options) - + $nonce = (New-PodeGuid -Secure -NoDashes) # get the auth header - send challenge if missing $header = (Get-PodeHeader -Name 'Authorization') if ($null -eq $header) { + $message = 'No Authorization header found' return @{ - Message = 'No Authorization header found' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) Code = 401 } } @@ -584,16 +886,19 @@ function Get-PodeAuthDigestType { # if auth header isn't digest send challenge $atoms = $header -isplit '\s+' if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) + Code = 401 # RFC 7616: Invalid credentials format should return 401 } } if ($atoms[0] -ine $options.HeaderTag) { + $message = "Authorization header is not $($options.HeaderTag)" return @{ - Message = "Authorization header is not $($options.HeaderTag)" - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) Code = 401 } } @@ -601,26 +906,31 @@ function Get-PodeAuthDigestType { # parse the other atoms of the header (after the scheme), return 400 if none $params = ConvertFrom-PodeAuthDigestHeader -Parts ($atoms[1..$($atoms.Length - 1)]) if ($params.Count -eq 0) { + $message = 'Invalid Authorization header' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Code = 400 + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) } } # if no username then 401 and challenge if ([string]::IsNullOrWhiteSpace($params.username)) { + $message = 'Authorization header is missing username' return @{ - Message = 'Authorization header is missing username' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) Code = 401 } } # return 400 if domain doesnt match request domain if ($WebEvent.Path -ine $params.uri) { + $message = 'Invalid Authorization header' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ) + Code = 400 } } @@ -630,42 +940,187 @@ function Get-PodeAuthDigestType { } function Get-PodeAuthDigestPostValidator { + <# +.SYNOPSIS + Validates HTTP Digest authentication responses for incoming requests. + +.DESCRIPTION + The `Get-PodeAuthDigestPostValidator` function processes and validates HTTP Digest + authentication responses by computing and verifying the response hash against + the client's provided hash. It ensures authentication is performed securely by + supporting multiple hashing algorithms and optional integrity protection (`auth-int`). + +.PARAMETER username + The username extracted from the client's authentication request. + +.PARAMETER params + A hashtable containing Digest authentication parameters, including: + - `username` : The username provided in the request. + - `realm` : The authentication realm. + - `nonce` : A unique server-generated nonce value. + - `uri` : The requested resource URI. + - `nc` : Nonce count (tracking the number of requests). + - `cnonce` : Client-generated nonce value. + - `qop` : Quality of Protection (`auth` or `auth-int`). + - `response` : The client's computed response hash. + - `algorithm` : The hashing algorithm used by the client. + +.PARAMETER result + A hashtable containing the user data retrieved from the authentication source. + This should include: + - `User` : The username. + - `Password` : The stored password or hash for verification. + +.PARAMETER options + A hashtable defining authentication options, including: + - `algorithm` : The list of supported hashing algorithms (MD5, SHA-256, etc.). + - `QualityOfProtection` : The supported Quality of Protection values (`auth`, `auth-int`). + +.OUTPUTS + - Returns the user data (with the password removed) on successful authentication. + - Returns an error response with a Digest authentication challenge and HTTP status code + if authentication fails. + +.NOTES + This scriptblock ensures robust Digest authentication by: + - Supporting multiple hashing algorithms (MD5, SHA-1, SHA-256, SHA-512/256, etc.). + - Handling authentication with and without message integrity (`auth` vs `auth-int`). + - Verifying authentication by comparing the computed hash with the client's response. + + **Behavior:** + - If the user is unknown or the password is missing, authentication fails with a `401 Unauthorized`. + - If the client selects an unsupported algorithm, authentication fails with `400 Bad Request`. + - If the computed response does not match the client’s hash, authentication fails with `401 Unauthorized`. + + **Digest Authentication Elements:** + - `qop="auth"`: Standard authentication (default). + - `qop="auth-int"`: Authentication with message integrity (includes request body hashing). + - `algorithm="MD5, SHA-256, SHA-512/256"`: Server-supported algorithms. + - `nonce=""`: Unique server nonce for replay protection. + +#> return { param($username, $params, $result, $options) - # if there's no user or password, fail with challenge + # If no user data or password is found, authentication fails if (($null -eq $result) -or ($null -eq $result.User) -or [string]::IsNullOrWhiteSpace($result.Password)) { + $message = 'Invalid credentials' return @{ - Message = 'User not found' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -Nonce $params.nonce ` + -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ` + -ErrorDescription $message) Code = 401 } } - # generate the first hash - $hash1 = Invoke-PodeMD5Hash -Value "$($params.username):$($params.realm):$($result.Password)" + # Extract the client-provided algorithm + $algorithm = $params.algorithm + + # Ensure the selected algorithm is supported by the server + if (-not ($options.algorithm -contains $algorithm)) { + $message = "Unsupported algorithm: $algorithm" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -Nonce $params.nonce ` + -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ` + -ErrorDescription $message) + Code = 400 + } + } + + # Extract Quality of Protection (qop) value + $qop = $params.qop + + # Retrieve the HTTP method (GET, POST, etc.) and the request URI + $method = $WebEvent.Method.ToUpperInvariant() + $uri = $params.uri + + # Compute HA1: Hash of (username:realm:password) + $HA1 = ConvertTo-PodeDigestHash -Value "$($params.username):$($params.realm):$($result.Password)" -Algorithm $algorithm - # generate the second hash - $hash2 = Invoke-PodeMD5Hash -Value "$($WebEvent.Method.ToUpperInvariant()):$($params.uri)" + # Compute HA2: Hash of request method and URI + if ($qop -eq 'auth-int') { + # If the request body is null, use an empty string (RFC 7616 compliance) + $entityBody = if ($null -eq $WebEvent.RawData) { [string]::Empty } else { $WebEvent.RawData } - # generate final hash - $final = Invoke-PodeMD5Hash -Value "$($hash1):$($params.nonce):$($params.nc):$($params.cnonce):$($params.qop):$($hash2)" + # Compute H(entity-body): Hash of request body (to ensure message integrity) + $entityHash = ConvertTo-PodeDigestHash -Value $entityBody -Algorithm $algorithm - # compare final hash to client response + # Compute HA2 for `auth-int`: Hash of (method:uri:H(entity-body)) + $HA2 = ConvertTo-PodeDigestHash -Value "$($method):$($uri):$($entityHash)" -Algorithm $algorithm + } + else { + # Standard HA2 computation for `auth`: Hash of (method:uri) + $HA2 = ConvertTo-PodeDigestHash -Value "$($method):$($uri)" -Algorithm $algorithm + } + + # Compute the final digest response hash + $final = ConvertTo-PodeDigestHash -Value "$($HA1):$($params.nonce):$($params.nc):$($params.cnonce):$($qop):$($HA2)" -Algorithm $algorithm + + # Compare the computed hash with the client's provided response if ($final -ne $params.response) { + $message = 'Invalid authentication response' return @{ - Message = 'Hashes failed to match' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -Nonce $params.nonce ` + -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ` + -ErrorDescription $message) Code = 401 } } - # hashes are valid, remove password and return result + # If hashes match, authentication is successful + # Remove the stored password from the result before returning the authenticated user $null = $result.Remove('Password') return $result } } +<# +.SYNOPSIS + Parses a Digest Authentication header and extracts its key-value pairs. + +.DESCRIPTION + The `ConvertFrom-PodeAuthDigestHeader` function takes an array of Digest authentication + header parts and converts them into a hashtable. This is used to process the + `WWW-Authenticate` and `Authorization` headers in Digest authentication requests. + +.PARAMETER Parts + An array of strings representing parts of the Digest authentication header. + These parts are typically extracted from the `WWW-Authenticate` or `Authorization` headers. + +.OUTPUTS + A hashtable containing the parsed key-value pairs from the Digest authentication header. + +.EXAMPLE + $header = @('Digest username="morty", realm="PodeRealm", nonce="abc123", uri="/users", response="xyz456"') + ConvertFrom-PodeAuthDigestHeader -Parts $header + + Returns: + @{ + username = "morty" + realm = "PodeRealm" + nonce = "abc123" + uri = "/users" + response = "xyz456" + } + +.EXAMPLE + # Handling empty or missing headers + ConvertFrom-PodeAuthDigestHeader -Parts @() + + Returns: + @{ } + +.NOTES + - This function ensures proper parsing of Digest authentication headers by correctly + handling quoted values and splitting by commas only when appropriate. + - The regex pattern ensures that quoted values (e.g., `nonce="abc123"`) are correctly extracted. + - If the input is empty or null, an empty hashtable is returned. + +#> + function ConvertFrom-PodeAuthDigestHeader { param( [Parameter()] @@ -673,28 +1128,83 @@ function ConvertFrom-PodeAuthDigestHeader { $Parts ) + # Return an empty hashtable if no header parts are provided if (($null -eq $Parts) -or ($Parts.Length -eq 0)) { return @{} } + # Initialize a hashtable to store parsed key-value pairs $obj = @{} + + # Join all parts into a single string to process as one header $value = ($Parts -join ' ') + # Split by commas, ensuring quoted values remain intact @($value -isplit ',(?=(?:[^"]|"[^"]*")*$)') | ForEach-Object { + # Match key-value pairs (handles both quoted and unquoted values) if ($_ -imatch '(?\w+)=["]?(?[^"]+)["]?$') { $obj[$Matches['name']] = $Matches['value'] } } + # Return the parsed hashtable return $obj } -function New-PodeAuthDigestChallenge { - $items = @('qop="auth"', 'algorithm="MD5"', "nonce=`"$(New-PodeGuid -Secure -NoDashes)`"") - return ($items -join ', ') -} function Get-PodeAuthFormType { + <# +.SYNOPSIS + Processes form-based authentication requests. + +.DESCRIPTION + The `Get-PodeAuthFormType` function extracts and validates user credentials from + an incoming HTTP form submission. It verifies the presence and format of the + provided username and password and optionally converts them to secure credentials. + +.PARAMETER $options + A hashtable containing configuration options for the authentication process. + Expected keys: + - `Fields.Username`: The key used to extract the username from the request data. + - `Fields.Password`: The key used to extract the password from the request data. + - `AsCredential`: (Boolean) If true, converts credentials into a [PSCredential] object. + +.OUTPUTS + [array] + Returns an array containing the validated username and password. + If `AsCredential` is set to `$true`, returns a `[PSCredential]` object. + +.EXAMPLE + $options = @{ + Fields = @{ Username = 'user'; Password = 'pass' } + AsCredential = $false + } + $result = Get-PodeAuthFormType -options $options + + Returns: + @('user123', 'securePassword') + +.EXAMPLE + $options = @{ + Fields = @{ Username = 'user'; Password = 'pass' } + AsCredential = $true + } + $result = Get-PodeAuthFormType -options $options + + Returns: + [PSCredential] object containing username and password. + +.NOTES + This function performs several checks, including: + - Ensuring both username and password are provided. + - Validating the username format (only alphanumeric, dot, underscore, and dash allowed). + - Returning HTTP status codes and error messages in case of validation failures. + + Possible HTTP response codes: + - 401 Unauthorized: When credentials are missing or incomplete. + - 400 Bad Request: When the username format is invalid. + +#> return { param($options) @@ -706,11 +1216,41 @@ function Get-PodeAuthFormType { $username = $WebEvent.Data.$userField $password = $WebEvent.Data.$passField - # if either are empty, fail auth - if ([string]::IsNullOrWhiteSpace($username) -or [string]::IsNullOrWhiteSpace($password)) { + # Handle cases where fields are missing or empty + if ([string]::IsNullOrWhiteSpace($username) -and [string]::IsNullOrWhiteSpace($password)) { + $message = 'Username and password must be provided' return @{ - Message = 'Username or Password not supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 + } + } + + if ([string]::IsNullOrWhiteSpace($username)) { + $message = 'Username is required' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 + } + } + + if ([string]::IsNullOrWhiteSpace($password)) { + $message = 'Password is required' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 + } + } + + # Validate username format + if ($username -notmatch '^[a-zA-Z0-9._-]{3,20}$') { + $message = 'Invalid username format' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -729,7 +1269,9 @@ function Get-PodeAuthFormType { } } -<# + +function Get-PodeAuthUserFileMethod { + <# .SYNOPSIS Authenticates a user based on a username and password provided as parameters. @@ -756,7 +1298,6 @@ function Get-PodeAuthFormType { This example authenticates a user with username "admin" and password "password123". It reads user data from the JSON file at "C:\Users.json", computes a HMAC-SHA256 hash of the password using "secret" as the secret key, and checks if the user is in the "admin" user or "Administrators" group. It also performs additional validation using a script block that checks if the user's name is "admin". #> -function Get-PodeAuthUserFileMethod { return { param($username, $password, $options) @@ -826,71 +1367,11 @@ function Get-PodeAuthUserFileMethod { } } -function Get-PodeAuthWindowsADMethod { - return { - param($username, $password, $options) - - # using pscreds? - if (($null -eq $options) -and ($username -is [pscredential])) { - $_username = ([pscredential]$username).UserName - $_password = ([pscredential]$username).GetNetworkCredential().Password - $_options = [hashtable]$password - } - else { - $_username = $username - $_password = $password - $_options = $options - } - - # parse username to remove domains - $_username = (($_username -split '@')[0] -split '\\')[-1] - - # validate and retrieve the AD user - $noGroups = $_options.NoGroups - $directGroups = $_options.DirectGroups - $keepCredential = $_options.KeepCredential - - $result = Get-PodeAuthADResult ` - -Server $_options.Server ` - -Domain $_options.Domain ` - -SearchBase $_options.SearchBase ` - -Username $_username ` - -Password $_password ` - -Provider $_options.Provider ` - -NoGroups:$noGroups ` - -DirectGroups:$directGroups ` - -KeepCredential:$keepCredential - - # if there's a message, fail and return the message - if (![string]::IsNullOrWhiteSpace($result.Message)) { - return $result - } - - # if there's no user, then, err, oops - if (Test-PodeIsEmpty $result.User) { - return @{ Message = 'An unexpected error occured' } - } - - # is the user valid for any users/groups - if not, error! - if (!(Test-PodeAuthUserGroup -User $result.User -Users $_options.Users -Groups $_options.Groups)) { - return @{ Message = 'You are not authorised to access this website' } - } - - # call additional scriptblock if supplied - if ($null -ne $_options.ScriptBlock.Script) { - $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $_options.ScriptBlock.Script -UsingVariables $_options.ScriptBlock.UsingVariables - } - - # return final result, this could contain a user obj, or an error message from custom scriptblock - return $result - } -} - -function Invoke-PodeAuthInbuiltScriptBlock { - param( - [Parameter(Mandatory = $true)] - [hashtable] - $User, +function Invoke-PodeAuthInbuiltScriptBlock { + param( + [Parameter(Mandatory = $true)] + [hashtable] + $User, [Parameter(Mandatory = $true)] [scriptblock] @@ -972,138 +1453,6 @@ function Get-PodeAuthWindowsLocalMethod { } } -function Get-PodeAuthWindowsADIISMethod { - return { - param($token, $options) - - # get the close handler - $win32Handler = Add-Type -Name Win32CloseHandle -PassThru -MemberDefinition @' - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool CloseHandle(IntPtr handle); -'@ - - try { - # parse the auth token and get the user - $winAuthToken = [System.IntPtr][Int]"0x$($token)" - $winIdentity = [System.Security.Principal.WindowsIdentity]::new($winAuthToken, 'Windows') - - # get user and domain - $username = ($winIdentity.Name -split '\\')[-1] - $domain = ($winIdentity.Name -split '\\')[0] - - # create base user object - $user = @{ - UserType = 'Domain' - Identity = @{ - AccessToken = $winIdentity.AccessToken - } - AuthenticationType = $winIdentity.AuthenticationType - DistinguishedName = [string]::Empty - Username = $username - Name = [string]::Empty - Email = [string]::Empty - Fqdn = [string]::Empty - Domain = $domain - Groups = @() - } - - # if the domain isn't local, attempt AD user - if (![string]::IsNullOrWhiteSpace($domain) -and (@('.', $PodeContext.Server.ComputerName) -inotcontains $domain)) { - # get the server's fdqn (and name/email) - try { - # Open ADSISearcher and change context to given domain - $searcher = [adsisearcher]'' - $searcher.SearchRoot = [adsi]"LDAP://$($domain)" - $searcher.Filter = "ObjectSid=$($winIdentity.User.Value.ToString())" - - # Query the ADSISearcher for the above defined SID - $ad = $searcher.FindOne() - - # Save it to our existing array for later usage - $user.DistinguishedName = @($ad.Properties.distinguishedname)[0] - $user.Name = @($ad.Properties.name)[0] - $user.Email = @($ad.Properties.mail)[0] - $user.Fqdn = (Get-PodeADServerFromDistinguishedName -DistinguishedName $user.DistinguishedName) - } - finally { - Close-PodeDisposable -Disposable $searcher - } - - try { - if (!$options.NoGroups) { - - # open a new connection - $result = (Open-PodeAuthADConnection -Server $user.Fqdn -Domain $domain -Provider $options.Provider) - if (!$result.Success) { - return @{ Message = "Failed to connect to Domain Server '$($user.Fqdn)' of $domain for $($user.DistinguishedName)." } - } - - # get the connection - $connection = $result.Connection - - # get the users groups - $directGroups = $options.DirectGroups - $user.Groups = (Get-PodeAuthADGroup -Connection $connection -DistinguishedName $user.DistinguishedName -Username $user.Username -Direct:$directGroups -Provider $options.Provider) - } - } - finally { - if ($null -ne $connection) { - Close-PodeDisposable -Disposable $connection.Searcher - Close-PodeDisposable -Disposable $connection.Entry -Close - $connection.Credential = $null - } - } - } - - # otherwise, get details of local user - else { - # get the user's name and groups - try { - $user.UserType = 'Local' - - if (!$options.NoLocalCheck) { - $localUser = $winIdentity.Name -replace '\\', '/' - $ad = [adsi]"WinNT://$($localUser)" - $user.Name = @($ad.FullName)[0] - - # dirty, i know :/ - since IIS runs using pwsh, the InvokeMember part fails - # we can safely call windows powershell here, as IIS is only on windows. - if (!$options.NoGroups) { - $cmd = "`$ad = [adsi]'WinNT://$($localUser)'; @(`$ad.Groups() | Foreach-Object { `$_.GetType().InvokeMember('Name', 'GetProperty', `$null, `$_, `$null) })" - $user.Groups = [string[]](powershell -c $cmd) - } - } - } - finally { - Close-PodeDisposable -Disposable $ad -Close - } - } - } - catch { - $_ | Write-PodeErrorLog - return @{ Message = 'Failed to retrieve user using Authentication Token' } - } - finally { - $win32Handler::CloseHandle($winAuthToken) - } - - # is the user valid for any users/groups - if not, error! - if (!(Test-PodeAuthUserGroup -User $user -Users $options.Users -Groups $options.Groups)) { - return @{ Message = 'You are not authorised to access this website' } - } - - $result = @{ User = $user } - - # call additional scriptblock if supplied - if ($null -ne $options.ScriptBlock.Script) { - $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $options.ScriptBlock.Script -UsingVariables $options.ScriptBlock.UsingVariables - } - - # return final result, this could contain a user obj, or an error message from custom scriptblock - return $result - } -} - <# .SYNOPSIS Authenticates a user based on group membership or specific user authorization. @@ -1238,6 +1587,24 @@ function Invoke-PodeAuthValidation { return $result } +<# +.SYNOPSIS + Tests the authentication validation for a specified authentication method. + +.DESCRIPTION + The `Test-PodeAuthValidation` function processes an authentication method by its name, + running the associated scripts, middleware, and validations to determine authentication success or failure. + +.PARAMETER Name + The name of the authentication method to validate. This parameter is mandatory. + +.OUTPUTS + A hashtable containing the authentication validation result, including success status, user details, + headers, and redirection information if applicable. + +.NOTES + This is an internal function and is subject to change in future versions of Pode. +#> function Test-PodeAuthValidation { param( [Parameter(Mandatory = $true)] @@ -1246,13 +1613,13 @@ function Test-PodeAuthValidation { ) try { - # get auth method + # Retrieve authentication method configuration from Pode context $auth = $PodeContext.Server.Authentications.Methods[$Name] - # auth result + # Initialize authentication result variable $result = $null - # run pre-auth middleware + # Run pre-authentication middleware if defined if ($null -ne $auth.Scheme.Middleware) { if (!(Invoke-PodeMiddleware -Middleware $auth.Scheme.Middleware)) { return @{ @@ -1261,26 +1628,28 @@ function Test-PodeAuthValidation { } } - # run auth scheme script to parse request for data + # Prepare arguments for the authentication scheme script $_args = @(Merge-PodeScriptblockArguments -ArgumentList $auth.Scheme.Arguments -UsingVariables $auth.Scheme.ScriptBlock.UsingVariables) - # call inner schemes first + # Handle inner authentication schemes (if any) if ($null -ne $auth.Scheme.InnerScheme) { $schemes = @() - $_scheme = $auth.Scheme + + # Traverse through the inner schemes to collect them $_inner = @(while ($null -ne $_scheme.InnerScheme) { $_scheme = $_scheme.InnerScheme $_scheme }) + # Process inner schemes in reverse order for ($i = $_inner.Length - 1; $i -ge 0; $i--) { $_tmp_args = @(Merge-PodeScriptblockArguments -ArgumentList $_inner[$i].Arguments -UsingVariables $_inner[$i].ScriptBlock.UsingVariables) - $_tmp_args += , $schemes + $result = (Invoke-PodeScriptBlock -ScriptBlock $_inner[$i].ScriptBlock.Script -Arguments $_tmp_args -Return -Splat) if ($result -is [hashtable]) { - break + break # Exit if a valid result is returned } $schemes += , $result @@ -1290,25 +1659,27 @@ function Test-PodeAuthValidation { $_args += , $schemes } + # Execute the primary authentication script if no result from inner schemes and not a route script if ($null -eq $result) { $result = (Invoke-PodeScriptBlock -ScriptBlock $auth.Scheme.ScriptBlock.Script -Arguments $_args -Return -Splat) } - # if data is a hashtable, then don't call validator (parser either failed, or forced a success) + # If authentication script returns a non-hashtable, perform further validation if ($result -isnot [hashtable]) { $original = $result - $_args = @($result) + @($auth.Arguments) + + # Run main authentication validation script $result = (Invoke-PodeScriptBlock -ScriptBlock $auth.ScriptBlock -Arguments $_args -UsingVariables $auth.UsingVariables -Return -Splat) - # if we have user, then run post validator if present + # Run post-authentication validation if applicable if ([string]::IsNullOrEmpty($result.Code) -and ($null -ne $auth.Scheme.PostValidator.Script)) { $_args = @($original) + @($result) + @($auth.Scheme.Arguments) $result = (Invoke-PodeScriptBlock -ScriptBlock $auth.Scheme.PostValidator.Script -Arguments $_args -UsingVariables $auth.Scheme.PostValidator.UsingVariables -Return -Splat) } } - # is the auth trying to redirect ie: oauth? + # Handle authentication redirection scenarios (e.g., OAuth) if ($result.IsRedirected) { return @{ Success = $false @@ -1316,11 +1687,12 @@ function Test-PodeAuthValidation { } } - # if there's no result, or no user, then the auth failed - but allow auth if anon enabled + + # Authentication failure handling if (($null -eq $result) -or ($result.Count -eq 0) -or (Test-PodeIsEmpty $result.User)) { $code = (Protect-PodeValue -Value $result.Code -Default 401) - # set the www-auth header + # Set WWW-Authenticate header for appropriate HTTP response $validCode = (($code -eq 401) -or ![string]::IsNullOrEmpty($result.Challenge)) if ($validCode) { @@ -1332,6 +1704,7 @@ function Test-PodeAuthValidation { $result.Headers = @{} } + # Generate authentication challenge header if (![string]::IsNullOrWhiteSpace($auth.Scheme.Name) -and !$result.Headers.ContainsKey('WWW-Authenticate')) { $authHeader = Get-PodeAuthWwwHeaderValue -Name $auth.Scheme.Name -Realm $auth.Scheme.Realm -Challenge $result.Challenge $result.Headers['WWW-Authenticate'] = $authHeader @@ -1347,7 +1720,7 @@ function Test-PodeAuthValidation { } } - # authentication was successful + # Authentication succeeded, return user and headers return @{ Success = $true User = $result.User @@ -1356,6 +1729,8 @@ function Test-PodeAuthValidation { } catch { $_ | Write-PodeErrorLog + + # Handle unexpected errors and log them return @{ Success = $false StatusCode = 500 @@ -1364,6 +1739,8 @@ function Test-PodeAuthValidation { } } + + function Get-PodeAuthMiddlewareScript { return { param($opts) @@ -1730,655 +2107,331 @@ function Set-PodeAuthStatus { return $true } -function Get-PodeADServerFromDistinguishedName { - param( - [Parameter()] - [string] - $DistinguishedName - ) - if ([string]::IsNullOrWhiteSpace($DistinguishedName)) { - return [string]::Empty - } - - $parts = @($DistinguishedName -split ',') - $name = @() - - foreach ($part in $parts) { - if ($part -imatch '^DC=(?.+)$') { - $name += $Matches['name'] - } - } - - return ($name -join '.') -} - -function Get-PodeAuthADResult { +function Find-PodeAuth { param( - [Parameter()] - [string] - $Server, - - [Parameter()] - [string] - $Domain, - - [Parameter()] - [string] - $SearchBase, - - [Parameter()] - [string] - $Username, - - [Parameter()] - [string] - $Password, - - [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] [string] - $Provider, - - [switch] - $NoGroups, - - [switch] - $DirectGroups, - - [switch] - $KeepCredential + $Name ) - try { - # validate the user's AD creds - $result = (Open-PodeAuthADConnection -Server $Server -Domain $Domain -Username $Username -Password $Password -Provider $Provider) - if (!$result.Success) { - return @{ Message = 'Invalid credentials supplied' } - } - - # get the connection - $connection = $result.Connection - - # get the user - $user = (Get-PodeAuthADUser -Connection $connection -Username $Username -Provider $Provider) - if ($null -eq $user) { - return @{ Message = 'User not found in Active Directory' } - } - - # get the users groups - $groups = @() - if (!$NoGroups) { - $groups = (Get-PodeAuthADGroup -Connection $connection -DistinguishedName $user.DistinguishedName -Username $Username -Direct:$DirectGroups -Provider $Provider) - } - - # check if we want to keep the credentials in the User object - if ($KeepCredential) { - $credential = [pscredential]::new($($Domain + '\' + $Username), (ConvertTo-SecureString -String $Password -AsPlainText -Force)) - } - else { - $credential = $null - } - - # return the user - return @{ - User = @{ - UserType = 'Domain' - AuthenticationType = 'LDAP' - DistinguishedName = $user.DistinguishedName - Username = ($Username -split '\\')[-1] - Name = $user.Name - Email = $user.Email - Fqdn = $Server - Domain = $Domain - Groups = $groups - Credential = $credential - } - } - } - finally { - if ($null -ne $connection) { - switch ($Provider.ToLowerInvariant()) { - 'openldap' { - $connection.Username = $null - $connection.Password = $null - } - - 'activedirectory' { - $connection.Credential = $null - } - - 'directoryservices' { - Close-PodeDisposable -Disposable $connection.Searcher - Close-PodeDisposable -Disposable $connection.Entry -Close - } - } - } - } + return $PodeContext.Server.Authentications.Methods[$Name] } -function Open-PodeAuthADConnection { - param( - [Parameter(Mandatory = $true)] - [string] - $Server, - - [Parameter()] - [string] - $Domain, - - [Parameter()] - [string] - $SearchBase, +<# +.SYNOPSIS + Expands a list of authentication names, including merged authentication methods. - [Parameter()] - [string] - $Username, +.DESCRIPTION + The Expand-PodeAuthMerge function takes an array of authentication names and expands it by resolving any merged authentication methods + into their individual components. It is particularly useful in scenarios where authentication methods are combined or merged, and there + is a need to process each individual method separately. - [Parameter()] - [string] - $Password, +.PARAMETER Names + An array of authentication method names. These names can include both discrete authentication methods and merged ones. - [Parameter()] - [ValidateSet('LDAP', 'WinNT')] - [string] - $Protocol = 'LDAP', +.EXAMPLE + $expandedAuthNames = Expand-PodeAuthMerge -Names @('BasicAuth', 'CustomMergedAuth') - [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] - [string] - $Provider + Expands the provided authentication names, resolving 'CustomMergedAuth' into its constituent authentication methods if it's a merged one. +#> +function Expand-PodeAuthMerge { + param ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string[]] + $Names ) - $result = $true - $connection = $null - - # validate the user's AD creds - switch ($Provider.ToLowerInvariant()) { - 'openldap' { - if (![string]::IsNullOrWhiteSpace($SearchBase)) { - $baseDn = $SearchBase - } - else { - $baseDn = "DC=$(($Server -split '\.') -join ',DC=')" - } - - $query = (Get-PodeAuthADQuery -Username $Username) - $hostname = "$($Protocol)://$($Server)" - - $user = $Username - if (!$Username.StartsWith($Domain)) { - $user = "$($Domain)\$($Username)" - } + # Initialize a hashtable to store expanded authentication names + $authNames = @{} - $null = (ldapsearch -x -LLL -H "$($hostname)" -D "$($user)" -w "$($Password)" -b "$($baseDn)" -o ldif-wrap=no "$($query)" dn) - if (!$? -or ($LASTEXITCODE -ne 0)) { - $result = $false - } - else { - $connection = @{ - Hostname = $hostname - Username = $user - BaseDN = $baseDn - Password = $Password - } - } + # Iterate over each authentication name + foreach ($authName in $Names) { + # Handle the special case of anonymous access + if ($authName -eq '%_allowanon_%') { + $authNames[$authName] = $true } + else { + # Retrieve the authentication method from the Pode context + $_auth = $PodeContext.Server.Authentications.Methods[$authName] - 'activedirectory' { - try { - $creds = [pscredential]::new($Username, (ConvertTo-SecureString -String $Password -AsPlainText -Force)) - $null = Get-ADUser -Identity $Username -Credential $creds -ErrorAction Stop - $connection = @{ - Credential = $creds + # Check if the authentication is a merged one and expand it + if ($_auth.merged) { + foreach ($key in (Expand-PodeAuthMerge -Names $_auth.Authentications)) { + $authNames[$key] = $true } } - catch { - $result = $false - } - } - - 'directoryservices' { - if ([string]::IsNullOrWhiteSpace($Password)) { - $ad = [System.DirectoryServices.DirectoryEntry]::new("$($Protocol)://$($Server)") - } else { - $ad = [System.DirectoryServices.DirectoryEntry]::new("$($Protocol)://$($Server)", "$($Username)", "$($Password)") - } - - if (Test-PodeIsEmpty $ad.distinguishedName) { - $result = $false - } - else { - $connection = @{ - Entry = $ad - } + # If not merged, add the authentication name to the list + $authNames[$_auth.Name] = $true } } } - return @{ - Success = $result - Connection = $connection - } + # Return the keys of the hashtable, which are the expanded authentication names + return $authNames.Keys } -function Get-PodeAuthADQuery { - param( - [Parameter(Mandatory = $true)] - [string] - $Username - ) - return "(&(objectCategory=person)(samaccountname=$($Username)))" -} - -function Get-PodeAuthADUser { +function Set-PodeAuthRedirectUrl { param( - [Parameter(Mandatory = $true)] - $Connection, - - [Parameter(Mandatory = $true)] - [string] - $Username, - - [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] - [string] - $Provider + [switch] + $UseOrigin ) - $query = (Get-PodeAuthADQuery -Username $Username) - $user = $null - - # generate query to find user - switch ($Provider.ToLowerInvariant()) { - 'openldap' { - $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" name mail) - if (!$? -or ($LASTEXITCODE -ne 0)) { - return $null - } - - $user = @{ - DistinguishedName = (Get-PodeOpenLdapValue -Lines $result -Property 'dn') - Name = (Get-PodeOpenLdapValue -Lines $result -Property 'name') - Email = (Get-PodeOpenLdapValue -Lines $result -Property 'mail') - } - } - - 'activedirectory' { - $result = Get-ADUser -LDAPFilter $query -Credential $Connection.Credential -Properties mail - $user = @{ - DistinguishedName = $result.DistinguishedName - Name = $result.Name - Email = $result.mail - } - } - - 'directoryservices' { - $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) - $Connection.Searcher.filter = $query - - $result = $Connection.Searcher.FindOne().Properties - if (Test-PodeIsEmpty $result) { - return $null - } - - $user = @{ - DistinguishedName = @($result.distinguishedname)[0] - Name = @($result.name)[0] - Email = @($result.mail)[0] - } - } + if ($UseOrigin -and ($WebEvent.Method -ieq 'get')) { + $null = Set-PodeCookie -Name 'pode.redirecturl' -Value $WebEvent.Request.Url.PathAndQuery } - - return $user } -function Get-PodeOpenLdapValue { +function Get-PodeAuthRedirectUrl { param( - [Parameter()] - [string[]] - $Lines, - [Parameter()] [string] - $Property, + $Url, [switch] - $All + $UseOrigin ) - foreach ($line in $Lines) { - if ($line -imatch "^$($Property)\:\s+(?<$($Property)>.+)$") { - # return the first found - if (!$All) { - return $Matches[$Property] - } + if (!$UseOrigin) { + return $Url + } - # return array of all - $Matches[$Property] - } + $tmpUrl = Get-PodeCookieValue -Name 'pode.redirecturl' + Remove-PodeCookie -Name 'pode.redirecturl' + + if (![string]::IsNullOrWhiteSpace($tmpUrl)) { + $Url = $tmpUrl } + + return $Url } + + <# .SYNOPSIS - Retrieves Active Directory (AD) group information for a user. + Generates the WWW-Authenticate challenge header for failed authentication attempts. .DESCRIPTION - This function retrieves AD group information for a specified user. It supports two modes of operation: - 1. Direct: Retrieves groups directly associated with the user. - 2. All: Retrieves all groups within the specified distinguished name (DN). - -.PARAMETER Connection - The AD connection object or credentials for connecting to the AD server. + The `New-PodeAuthChallenge` function constructs a formatted authentication challenge + string to be included in HTTP responses when authentication fails. + It supports optional parameters such as scopes, error types, descriptions, + and digest authentication mechanisms. + +.PARAMETER Scopes + An array of required scopes to be included in the challenge response. + Scopes define the level of access required for the requested resource. + +.PARAMETER ErrorType + Specifies the type of error to include in the challenge response. + Accepted values are: + - 'invalid_request' : The request is missing a required parameter. + - 'invalid_token' : The provided token is expired, revoked, or invalid. + - 'insufficient_scope' : The provided token lacks necessary privileges. + +.PARAMETER ErrorDescription + Provides a descriptive error message in the challenge response to explain + the reason for the authentication failure. + +.PARAMETER Digest + A switch parameter that, when specified, includes digest authentication elements + such as quality of protection (qop), algorithm, and a unique nonce value. -.PARAMETER DistinguishedName - The distinguished name (DN) of the user or group. If not provided, the default DN is used. +.OUTPUTS + [string] + Returns a formatted challenge string to be used in the HTTP response header. -.PARAMETER Username - The username for which to retrieve group information. +.EXAMPLE + New-PodeAuthChallenge -Scopes @('read', 'write') -ErrorType 'invalid_token' -ErrorDescription 'Token has expired' -.PARAMETER Provider - The AD provider to use (e.g., 'DirectoryServices', 'ActiveDirectory', 'OpenLDAP'). + Returns: + scope="read write", error="invalid_token", error_description="Token has expired" -.PARAMETER Direct - Switch parameter. If specified, retrieves only direct group memberships for the user. +.EXAMPLE + New-PodeAuthChallenge -Digest -.OUTPUTS - Returns AD group information as needed based on the mode of operation. + Returns: + qop="auth", algorithm="MD5", nonce="generated_nonce" .EXAMPLE - Get-PodeAuthADGroup -Connection $adConnection -Username "john.doe" - # Retrieves all AD groups for the user "john.doe". + New-PodeAuthChallenge -Scopes @('admin') -ErrorType 'insufficient_scope' + + Returns: + scope="admin", error="insufficient_scope" - Get-PodeAuthADGroup -Connection $adConnection -Username "jane.smith" -Direct - # Retrieves only direct group memberships for the user "jane.smith". +.NOTES + This function is used to generate the `WWW-Authenticate` response header + when authentication attempts fail. It helps inform clients of the authentication + requirements and reasons for failure. #> -function Get-PodeAuthADGroup { - param( - [Parameter(Mandatory = $true)] - $Connection, +function New-PodeAuthChallenge { + param( [Parameter()] - [string] - $DistinguishedName, + [string[]] + $Scopes, [Parameter()] + [ValidateSet('invalid_request', 'invalid_token', 'insufficient_scope')] [string] - $Username, + $ErrorType = 'invalid_request', [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] [string] - $Provider, - - [switch] - $Direct - ) - - if ($Direct) { - return (Get-PodeAuthADGroupDirect -Connection $Connection -Username $Username -Provider $Provider) - } - - return (Get-PodeAuthADGroupAll -Connection $Connection -DistinguishedName $DistinguishedName -Provider $Provider) -} - -function Get-PodeAuthADGroupDirect { - param( - [Parameter(Mandatory = $true)] - $Connection, + $ErrorDescription, [Parameter()] [string] - $Username, + $Nonce, [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] - [string] - $Provider - ) - - # create the query - $query = "(&(objectCategory=person)(samaccountname=$($Username)))" - $groups = @() - - # get the groups - switch ($Provider.ToLowerInvariant()) { - 'openldap' { - $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" memberof) - $groups = (Get-PodeOpenLdapValue -Lines $result -Property 'memberof' -All) - } - - 'activedirectory' { - $groups = (Get-ADPrincipalGroupMembership -Identity $Username -Credential $Connection.Credential).distinguishedName - } - - 'directoryservices' { - if ($null -eq $Connection.Searcher) { - $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) - } - - $Connection.Searcher.filter = $query - $groups = @($Connection.Searcher.FindOne().Properties.memberof) - } - } - - $groups = @(foreach ($group in $groups) { - if ($group -imatch '^CN=(?.+?),') { - $Matches['group'] - } - }) - - return $groups -} - -function Get-PodeAuthADGroupAll { - param( - [Parameter(Mandatory = $true)] - $Connection, + [string[]] + $Algorithm = 'md5', [Parameter()] - [string] - $DistinguishedName, + [string[]] + $QualityOfProtection = 'auth' - [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] - [string] - $Provider ) - # create the query - $query = "(member:1.2.840.113556.1.4.1941:=$($DistinguishedName))" - $groups = @() - - # get the groups - switch ($Provider.ToLowerInvariant()) { - 'openldap' { - $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" samaccountname) - $groups = (Get-PodeOpenLdapValue -Lines $result -Property 'sAMAccountName' -All) - } - - 'activedirectory' { - $groups = (Get-ADObject -LDAPFilter $query -Credential $Connection.Credential).Name - } - - 'directoryservices' { - if ($null -eq $Connection.Searcher) { - $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) - } + $items = @() - $null = $Connection.Searcher.PropertiesToLoad.Add('samaccountname') - $Connection.Searcher.filter = $query - $groups = @($Connection.Searcher.FindAll().Properties.samaccountname) - } + if (![string]::IsNullOrWhiteSpace($Nonce)) { + $items += "qop=`"$QualityOfProtection`"", "algorithm=$Algorithm" , "nonce=`"$Nonce`"" } - return $groups -} - -function Get-PodeAuthDomainName { - $domain = $null - - if (Test-PodeIsMacOS) { - $domain = (scutil --dns | grep -m 1 'search domain\[0\]' | cut -d ':' -f 2) - } - elseif (Test-PodeIsUnix) { - $domain = (dnsdomainname) - if ([string]::IsNullOrWhiteSpace($domain)) { - $domain = (/usr/sbin/realm list --name-only) - } + if (($null -ne $Scopes) -and ($Scopes.Length -gt 0)) { + $items += "scope=`"$($Scopes -join ' ')`"" } - else { - $domain = $env:USERDNSDOMAIN - if ([string]::IsNullOrWhiteSpace($domain)) { - $domain = (Get-CimInstance -Class Win32_ComputerSystem -Verbose:$false).Domain - } + + if (![string]::IsNullOrWhiteSpace($ErrorType)) { + $items += "error=`"$($ErrorType)`"" } - if (![string]::IsNullOrEmpty($domain)) { - $domain = $domain.Trim() + if (![string]::IsNullOrWhiteSpace($ErrorDescription)) { + $items += "error_description=`"$($ErrorDescription)`"" } - return $domain + return ($items -join ', ') } -function Find-PodeAuth { - param( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $Name - ) - - return $PodeContext.Server.Authentications.Methods[$Name] -} <# .SYNOPSIS - Expands a list of authentication names, including merged authentication methods. + Validates that the HTTP method supports bearer token authentication in the body. .DESCRIPTION - The Expand-PodeAuthMerge function takes an array of authentication names and expands it by resolving any merged authentication methods - into their individual components. It is particularly useful in scenarios where authentication methods are combined or merged, and there - is a need to process each individual method separately. + This function checks if the provided HTTP method is one that typically supports request bodies (e.g., PUT, POST, PATCH) when bearer token authentication is expected in the body. Throws an error if the method is not supported. -.PARAMETER Names - An array of authentication method names. These names can include both discrete authentication methods and merged ones. +.PARAMETER Method + The HTTP method to validate (e.g., GET, POST, PUT, PATCH). + +.PARAMETER Authentication + The authentication scheme to validate against Pode's configured authentications. .EXAMPLE - $expandedAuthNames = Expand-PodeAuthMerge -Names @('BasicAuth', 'CustomMergedAuth') + Test-PodeBodyAuthMethod -Method 'POST' -Authentication 'Bearer' + # Validates successfully as POST supports body authentication. - Expands the provided authentication names, resolving 'CustomMergedAuth' into its constituent authentication methods if it's a merged one. +.EXAMPLE + Test-PodeBodyAuthMethod -Method 'GET' -Authentication 'Bearer' + # Throws an error as GET does not support body authentication. + +.NOTES + Internal Pode function for HTTP authentication validation. Subject to change. #> -function Expand-PodeAuthMerge { +function Test-PodeBodyAuthMethod { param ( [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] [string[]] - $Names - ) + $Method, - # Initialize a hashtable to store expanded authentication names - $authNames = @{} + [Parameter(Mandatory = $true)] + [string] + $Authentication + ) - # Iterate over each authentication name - foreach ($authName in $Names) { - # Handle the special case of anonymous access - if ($authName -eq '%_allowanon_%') { - $authNames[$authName] = $true + if (![string]::IsNullOrWhiteSpace($Authentication) -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $uberAuth = $PodeContext.Server.Authentications.Methods[$Authentication] + if ($uberAuth.ContainsKey('Authentications')) { + $authentications = $uberAuth.Authentications } else { - # Retrieve the authentication method from the Pode context - $_auth = $PodeContext.Server.Authentications.Methods[$authName] - - # Check if the authentication is a merged one and expand it - if ($_auth.merged) { - foreach ($key in (Expand-PodeAuthMerge -Names $_auth.Authentications)) { - $authNames[$key] = $true + $authentications = @($Authentication) + } + foreach ($auth in $authentications) { + switch ($PodeContext.Server.Authentications.Methods[$auth].Scheme.Name) { + 'Digest' { + $arguments = $PodeContext.Server.Authentications.Methods[$auth].Scheme.Arguments + if (($null -ne $arguments ) -and ($arguments.QualityOfProtection -eq 'auth-int')) { + $Method | Foreach-Object({ if ($_ -inotmatch '^(PUT|POST|PATCH)$') { + throw $PodeLocale.digestTokenAuthMethodNotSupportedExceptionMessage + } }) + } + } + default { + $arguments = $PodeContext.Server.Authentications.Methods[$auth].Scheme.Arguments + if (($null -ne $arguments ) -and $arguments.ContainsKey('Location') -and $arguments['Location'] -eq 'body') { + $Method | Foreach-Object({ if ($_ -inotmatch '^(PUT|POST|PATCH)$') { + throw $PodeLocale.bearerTokenAuthMethodNotSupportedExceptionMessage + } }) + } } - } - else { - # If not merged, add the authentication name to the list - $authNames[$_auth.Name] = $true } } - } - # Return the keys of the hashtable, which are the expanded authentication names - return $authNames.Keys -} - - -function Import-PodeAuthADModule { - if (!(Test-PodeIsWindows)) { - # Active Directory module only available on Windows - throw ($PodeLocale.adModuleWindowsOnlyExceptionMessage) } - - if (!(Test-PodeModuleInstalled -Name ActiveDirectory)) { - # Active Directory module is not installed - throw ($PodeLocale.adModuleNotInstalledExceptionMessage) - } - - Import-Module -Name ActiveDirectory -Force -ErrorAction Stop - Export-PodeModule -Name ActiveDirectory } -function Get-PodeAuthADProvider { - param( - [switch] - $OpenLDAP, - - [switch] - $ADModule - ) - # openldap (literal, or not windows) - if ($OpenLDAP -or !(Test-PodeIsWindows)) { - return 'OpenLDAP' - } - - # ad module - if ($ADModule) { - return 'ActiveDirectory' - } - - # ds - return 'DirectoryServices' -} - -function Set-PodeAuthRedirectUrl { - param( - [switch] - $UseOrigin - ) +<# +.SYNOPSIS + Retrieves the Bearer token from an HTTP request based on authentication configuration. - if ($UseOrigin -and ($WebEvent.Method -ieq 'get')) { - $null = Set-PodeCookie -Name 'pode.redirecturl' -Value $WebEvent.Request.Url.PathAndQuery - } -} +.DESCRIPTION + The `Get-PodeBearerToken` function extracts the Bearer token from an HTTP request, depending on + the authentication method's configured token location. It supports retrieval from the request's + header, query parameters, or body. -function Get-PodeAuthRedirectUrl { - param( - [Parameter()] - [string] - $Url, +.PARAMETER Authentication + Specifies the authentication method configured in Pode. The function checks if the method exists + within the server's authentication methods. - [switch] - $UseOrigin - ) +.OUTPUTS + [string] + Returns the extracted Bearer token as a string. If the authentication method does not exist, + the function throws an exception. - if (!$UseOrigin) { - return $Url +.EXAMPLE + $token = Get-PodeBearerToken + # Retrieves the Bearer token from the request's headers, query parameters, or body. + +.NOTES + - This function depends on Pode's authentication context and must be used within a Pode route. + - The token location is determined based on the authentication method's configuration. + - If the authentication method does not exist, an exception is thrown. + - Supported token locations: `header`, `query`, `body`. +#> +function Get-PodeBearenToken { + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $authOptions = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments + switch ($authOptions.Location.ToLowerInvariant()) { + 'header' { + $atoms = $(Get-PodeHeader -Name 'Authorization') -isplit '\s+' + return $atoms[1] + } + 'query' { + return $WebEvent.Query[$options.BearerTag] + } + 'body' { + return $WebEvent.Data.($options.BearerTag) + } + } } - - $tmpUrl = Get-PodeCookieValue -Name 'pode.redirecturl' - Remove-PodeCookie -Name 'pode.redirecturl' - - if (![string]::IsNullOrWhiteSpace($tmpUrl)) { - $Url = $tmpUrl + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) } - - return $Url } \ No newline at end of file diff --git a/src/Private/Certificate.ps1 b/src/Private/Certificate.ps1 new file mode 100644 index 000000000..ad37ab09b --- /dev/null +++ b/src/Private/Certificate.ps1 @@ -0,0 +1,425 @@ +<# +.SYNOPSIS + Exports a private key in PEM format, optionally encrypting it with a password. + +.DESCRIPTION + This function exports a private key in PEM format using PKCS#8 encoding. + If a password is provided, the private key is encrypted using AES-256-CBC + with SHA-256 hashing and 100,000 iterations. The function supports both + PowerShell 7+ and older versions, using native methods where available. + +.PARAMETER Key + The asymmetric key to export. Must be an instance of System.Security.Cryptography.AsymmetricAlgorithm. + +.PARAMETER Password + A secure string containing the password for encrypting the private key. + If omitted, the private key is exported unencrypted. + +.OUTPUTS + [string] + Returns the private key as a PEM-formatted string. + +.EXAMPLE + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $pem = Export-PodePrivateKeyPem -Key $rsa + Exports an unencrypted private key in PEM format. + +.EXAMPLE + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $securePassword = ConvertTo-SecureString -String "MyStrongPass" -AsPlainText -Force + $pem = Export-PodePrivateKeyPem -Key $rsa -Password $securePassword + Exports an encrypted private key in PEM format using the provided password. + +.NOTES + This function ensures compatibility with both PowerShell 7+ (using native PEM methods) + and older versions (manually constructing the PEM format). It is designed for use + within Pode’s cryptographic handling utilities. + This is an internal Pode function and may be subject to change. +#> +function Export-PodePrivateKeyPem { + param ( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.AsymmetricAlgorithm] + $Key, + + [Parameter()] + [securestring] + $Password + ) + $builder = [System.Text.StringBuilder]::new() + + if ($null -ne $Password) { + if ($PSVersionTable.PSVersion.Major -ge 7) { + # Export encrypted private key in PEM using the native method + return $Key.ExportEncryptedPkcs8PrivateKeyPem( + (Convert-PodeSecureStringToPlainText($Password)), + [System.Security.Cryptography.PbeParameters]::new( + [System.Security.Cryptography.PbeEncryptionAlgorithm]::Aes256Cbc, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + 100000 + ) + ) + } + # For older versions, export encrypted key using PKCS#8 format + $encryptedBytes = $Key.ExportEncryptedPkcs8PrivateKey( + (Convert-PodeSecureStringToPlainText($Password)), + [System.Security.Cryptography.PbeParameters]::new( + [System.Security.Cryptography.PbeEncryptionAlgorithm]::Aes256Cbc, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + 100000 + ) + ) + $base64Key = [Convert]::ToBase64String($encryptedBytes) + $null = $builder.AppendLine('-----BEGIN ENCRYPTED PRIVATE KEY-----') + } + else { + if ($PSVersionTable.PSVersion.Major -ge 7) { + # Export unencrypted private key in PEM using the native method + return $Key.ExportPkcs8PrivateKeyPem() + } + # For older versions, export unencrypted key using PKCS#8 format + $unencryptedBytes = $Key.ExportPkcs8PrivateKey() + $base64Key = [Convert]::ToBase64String($unencryptedBytes) + $null = $builder.AppendLine('-----BEGIN PRIVATE KEY-----') + } + + for ($i = 0; $i -lt $base64Key.Length; $i += 64) { + $null = $builder.AppendLine($base64Key.Substring($i, [System.Math]::Min(64, $base64Key.Length - $i))) + } + $null = $builder.AppendLine('-----END PRIVATE KEY-----') + return $builder.ToString() +} + +<# +.SYNOPSIS + Generates a certificate signing request (CSR) with specified parameters. + +.DESCRIPTION + This function creates a certificate signing request (CSR) using RSA or ECDSA key pairs. + It supports specifying subject details, key usage, enhanced key usage (EKU), + and custom extensions. The function returns a PSCustomObject containing + the CSR in Base64 format, the request object, the generated private key, + and additional metadata. + +.PARAMETER DnsName + One or more DNS names (or IP addresses) to be included in the Subject Alternative Name (SAN). + +.PARAMETER CommonName + The Common Name (CN) for the certificate subject. Defaults to the first DNS name if not provided. + +.PARAMETER Organization + The organization (O) name to be included in the certificate subject. + +.PARAMETER Locality + The locality (L) name to be included in the certificate subject. + +.PARAMETER State + The state (S) name to be included in the certificate subject. + +.PARAMETER Country + The country (C) code (ISO 3166-1 alpha-2). Defaults to 'XX'. + +.PARAMETER KeyType + The cryptographic key type for the certificate request. Supported values: 'RSA', 'ECDSA'. Defaults to 'RSA'. + +.PARAMETER KeyLength + The key length for RSA (2048, 3072, 4096) or ECDSA (256, 384, 521). Defaults to 2048. + +.PARAMETER CertificatePurpose + The intended purpose of the certificate, which automatically sets the EKU. + Supported values: 'ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom'. + +.PARAMETER EnhancedKeyUsages + A list of OID strings for Enhanced Key Usage (EKU) if 'Custom' is selected as CertificatePurpose. + +.PARAMETER CustomExtensions + An array of additional custom certificate extensions. + +.OUTPUTS + [PSCustomObject] (PsTypeName = 'PodeCertificateRequest') + Returns an object containing: + - Request: The CSR in Base64 format. + - CertificateRequest: The generated certificate request object. + - PrivateKey: The generated private key. + +.EXAMPLE + $csr = New-PodeCertificateRequestInternal -DnsName "example.com" -CommonName "example.com" -KeyType "RSA" -KeyLength 2048 + Creates a certificate request for "example.com" using RSA with a 2048-bit key. + +.EXAMPLE + $csr = New-PodeCertificateRequestInternal -DnsName "example.com" -KeyType "ECDSA" -KeyLength 384 -CertificatePurpose "ServerAuth" + Generates an ECDSA certificate request for "example.com" with an automatically assigned EKU for server authentication. + +.NOTES + This is an internal Pode function and may be subject to change. + It is designed to integrate with Pode’s SSL and security handling mechanisms. +#> +function New-PodeCertificateRequestInternal { + [CmdletBinding(DefaultParameterSetName = 'CommonName')] + [OutputType([PSCustomObject])] + param ( + # Required: one or more DNS names (or IP addresses) + [Parameter()] + [string[]] + $DnsName, + + # Subject parts + [Parameter()] + [string] + $CommonName, + + [Parameter()] + [string] + $Organization, + + [Parameter()] + [string] + $Locality, + + [Parameter()] + [string] + $State, + + [Parameter()] + [string] + $Country = 'XX', + + # Key type and size + [Parameter()] + [ValidateSet('RSA', 'ECDSA')] + [string]$KeyType = 'RSA', + + [Parameter()] + [ValidateSet(2048, 3072, 4096, 256, 384, 521)] + [int]$KeyLength = 2048, + + #Automatically set EKUs based on intended purpose + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom')] + [string] + $CertificatePurpose, + + # Enhanced Key Usages (EKU) - supply one or more OID strings if desired. + [Parameter()] + [string[]] + $EnhancedKeyUsages, + + # Additional custom extensions (as an array of certificate extension objects). + [Parameter()] + [object[]] + $CustomExtensions + ) + + # Assign EKU based on selected purpose + $ekuOids = switch ($CertificatePurpose) { + 'ServerAuth' { @('1.3.6.1.5.5.7.3.1') } # Server Authentication (HTTPS/TLS) + 'ClientAuth' { @('1.3.6.1.5.5.7.3.2') } # Client Authentication (VPN, Mutual TLS) + 'CodeSigning' { @('1.3.6.1.5.5.7.3.3') } # Code Signing (JWT, Software) + 'EmailSecurity' { @('1.3.6.1.5.5.7.3.4') } # Email Security (S/MIME) + 'Custom' { $EnhancedKeyUsages } # Use manually supplied OIDs + default { $null } + } + + # Ensure CommonName is set (fallback to first DNS entry if missing) + if (-not $CommonName -and $DnsName.Count -gt 0) { + $CommonName = $DnsName[0] + } + if (-not $CommonName) { + $CommonName = 'SelfSigned' + } + + + # Build the Distinguished Name (DN) string. + $subjectParts = @("CN=$CommonName") + if (![string]::IsNullOrEmpty($Organization)) { $subjectParts += "O=$Organization" } + if (![string]::IsNullOrEmpty($Locality)) { $subjectParts += "L=$Locality" } + if (![string]::IsNullOrEmpty( $State)) { $subjectParts += "S=$State" } + $subjectParts += "C=$Country" + $SubjectDN = $subjectParts -join ', ' + + # Initialize the SAN (Subject Alternative Name) builder. + $sanBuilder = $null + if ($DnsName) { + $sanBuilder = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new() + foreach ($name in $DnsName) { + $parsedIp = $null + if ([System.Net.IPAddress]::TryParse($name, [ref]$parsedIp)) { + $sanBuilder.AddIpAddress($parsedIp) + } + else { + $sanBuilder.AddDnsName($name) + } + } + } + + # Generate key pair and certificate request based on the chosen key type. + switch ($KeyType) { + 'RSA' { + if (@(2048, 3072, 4096) -notcontains $KeyLength ) { + ($PodeLocale.unsupportedCertificateKeyLengthExceptionMessage -f $KeyLength) + } + $key = [System.Security.Cryptography.RSA]::Create($KeyLength) + if (! $key) { throw $PodeLocale.failedToCreateCertificateRequestExceptionMessage } + $distinguishedName = [X500DistinguishedName]::new($SubjectDN) + $hashAlgorithm = [System.Security.Cryptography.HashAlgorithmName]::SHA256 + $rsaPadding = [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $distinguishedName, + $key, + $hashAlgorithm, + $rsaPadding + ) + } + 'ECDSA' { + $curveOid = switch ($KeyLength) { + 256 { '1.2.840.10045.3.1.7' } # nistP256 + 384 { '1.3.132.0.34' } # nistP384 + 521 { '1.3.132.0.35' } # nistP521 + default { throw ($PodeLocale.unsupportedCertificateKeyLengthExceptionMessage -f $KeyLength) } + } + $curve = [System.Security.Cryptography.ECCurve]::CreateFromOid( + [System.Security.Cryptography.Oid]::new($curveOid) + ) + $key = [System.Security.Cryptography.ECDsa]::Create($curve) + if (-not $key) { throw $PodeLocale.failedToCreateCertificateRequestExceptionMessage } + $hashAlgorithm = switch ($KeyLength) { + 256 { [System.Security.Cryptography.HashAlgorithmName]::SHA256 } + 384 { [System.Security.Cryptography.HashAlgorithmName]::SHA384 } + 521 { [System.Security.Cryptography.HashAlgorithmName]::SHA512 } + } + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $SubjectDN, + $key, + $hashAlgorithm + ) + } + } + + if (! $req) { throw $PodeLocale.failedToCreateCertificateRequestExceptionMessage } + + # Add Basic Constraints (as a CA: false certificate). + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true) + ) + + # Add Key Usage extension. + $keyUsageFlags = ( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DataEncipherment + ) + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new($keyUsageFlags, $false) + ) + + # Add Subject Alternative Name (SAN) extension. + if ($sanBuilder) { + $req.CertificateExtensions.Add($sanBuilder.Build()) + } + + # Add EKU extension + if ($ekuOids) { + $oidCollection = [System.Security.Cryptography.OidCollection]::new() + foreach ($oid in $ekuOids) { + $oidCollection.Add([System.Security.Cryptography.Oid]::new($oid)) | Out-Null + } + $ekuExtension = [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($oidCollection, $false) + $req.CertificateExtensions.Add($ekuExtension) + } + + # Add any additional custom extensions. + if ($CustomExtensions) { + foreach ($ext in $CustomExtensions) { + $req.CertificateExtensions.Add($ext) + } + } + + # Create the signing request (CSR) in PKCS#10 format. + $csrBytes = $req.CreateSigningRequest() + $csrBase64 = [System.Convert]::ToBase64String($csrBytes) + + return [PSCustomObject]@{ + PsTypeName = 'PodeCertificateRequest' + Request = $csrBase64 + CertificateRequest = $req + PrivateKey = $key + } +} + + +<# +.SYNOPSIS + Validates whether an X.509 certificate is authorized for a specific purpose. + +.DESCRIPTION + This internal function checks if an X.509 certificate contains the necessary Enhanced Key Usage (EKU) + for an expected purpose. If the certificate lacks the required EKU, an exception is thrown. + + If `-Strict` mode is enabled, the function also rejects certificates containing unknown EKUs. + +.PARAMETER Certificate + The X509Certificate2 object to validate. + +.PARAMETER ExpectedPurpose + The required purpose for the certificate. Supported values: + - 'ServerAuth' (1.3.6.1.5.5.7.3.1) + - 'ClientAuth' (1.3.6.1.5.5.7.3.2) + - 'CodeSigning' (1.3.6.1.5.5.7.3.3) + - 'EmailSecurity' (1.3.6.1.5.5.7.3.4) + +.PARAMETER Strict + If specified, the function will **fail** if the certificate contains unknown EKUs. + +.OUTPUTS + [boolean] + Returns `$true` if the certificate is valid for the specified purpose. + Throws an exception if the certificate lacks the required EKU or contains unknown EKUs in `-Strict` mode. + +.EXAMPLE + Test-PodeCertificateRestriction -Certificate $cert -ExpectedPurpose 'ServerAuth' + Validates whether the given certificate can be used for server authentication. + +.EXAMPLE + Test-PodeCertificateRestriction -Certificate $cert -ExpectedPurpose 'ClientAuth' -Strict + Validates whether the certificate is authorized for client authentication, rejecting unknown EKUs. + +.NOTES + This is an internal Pode function and may be subject to change. +#> +function Test-PodeCertificateRestriction { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + + [Parameter(Mandatory = $true)] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity')] + [string]$ExpectedPurpose, + + [Parameter()] + [switch]$Strict + ) + + # Get the actual purposes of the certificate + $purposes = Get-PodeCertificatePurpose -Certificate $Certificate + + # If the certificate has no EKU and no restrictions, allow it (but warn) + if ($purposes.Count -eq 0 -and ! $Strict) { + Write-Verbose 'Certificate has no EKU restrictions. It can be used for any purpose.' + return + } + + # If the expected purpose is not in the list, throw an exception + if ($ExpectedPurpose -notin $purposes) { + throw ($PodeLocale.certificateNotValidForPurposeExceptionMessage -f $ExpectedPurpose, ($purposes -join ', ')) + } + + # If strict mode is enabled, fail if there are any unknown EKUs + if ($Strict -and ($purposes -match '^Unknown')) { + throw ($PodeLocale.certificateUnknownEkusStrictModeExceptionMessage -f ($purposes -join ', ')) + } + + # Certificate is valid for the expected purpose + Write-Verbose "Certificate is valid for '$ExpectedPurpose'. Found purposes: $($purposes -join ', ')" + +} diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index 70b8b6072..a6dfb64d5 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -53,7 +53,10 @@ function New-PodeContext { $IgnoreServerConfig, [string] - $ConfigFile + $ConfigFile, + + [string] + $ApplicationName ) # set a random server name if one not supplied @@ -97,7 +100,14 @@ function New-PodeContext { $ctx.Server.PodeModule = (Get-PodeModuleInfo) $ctx.Server.Console = $Console $ctx.Server.ComputerName = [System.Net.DNS]::GetHostName() - $ctx.Server.ApplicationName = (Get-PodeApplicationName) + try { + $ctx.Server.Fqdn = [System.Net.Dns]::GetHostEntry($ctx.Server.ComputerName).HostName + } + catch { + $ctx.Server.Fqdn = $ctx.Server.ComputerName + } + $ctx.Server.ApplicationName = $ApplicationName + # list of created listeners/receivers $ctx.Listeners = @() diff --git a/src/Private/Cryptography.ps1 b/src/Private/Cryptography.ps1 index 8ac0b86d0..9bad032c6 100644 --- a/src/Private/Cryptography.ps1 +++ b/src/Private/Cryptography.ps1 @@ -1,3 +1,4 @@ +using namespace System.Security.Cryptography <# .SYNOPSIS Computes an HMAC-SHA256 hash for a given value using a secret key. @@ -62,135 +63,6 @@ function Invoke-PodeHMACSHA256Hash { return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) } -<# -.SYNOPSIS - Computes a private HMAC-SHA384 hash for a given value using a secret key. - -.DESCRIPTION - This function calculates a private HMAC-SHA384 hash for the specified value using either a secret provided as a string or as a byte array. It supports two parameter sets: - 1. String: The secret is provided as a string. - 2. Bytes: The secret is provided as a byte array. - -.PARAMETER Value - The value for which the private HMAC-SHA384 hash needs to be computed. - -.PARAMETER Secret - The secret key as a string. If this parameter is provided, it will be converted to a byte array. - -.PARAMETER SecretBytes - The secret key as a byte array. If this parameter is provided, it will be used directly. - -.OUTPUTS - Returns the computed private HMAC-SHA384 hash as a base64-encoded string. - -.EXAMPLE - $value = "MySecretValue" - $secret = "MySecretKey" - $hash = Invoke-PodeHMACSHA384Hash -Value $value -Secret $secret - Write-PodeHost "Private HMAC-SHA384 hash: $hash" - - This example computes the private HMAC-SHA384 hash for the value "MySecretValue" using the secret key "MySecretKey". - -.NOTES - - This function is intended for internal use. -#> -function Invoke-PodeHMACSHA384Hash { - [CmdletBinding(DefaultParameterSetName = 'String')] - [OutputType([String])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value, - - [Parameter(Mandatory = $true, ParameterSetName = 'String')] - [string] - $Secret, - - [Parameter(Mandatory = $true, ParameterSetName = 'Bytes')] - [byte[]] - $SecretBytes - ) - - # Convert secret to byte array if provided as a string - if (![string]::IsNullOrWhiteSpace($Secret)) { - $SecretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) - } - - # Validate secret length - if ($SecretBytes.Length -eq 0) { - # No secret supplied for HMAC384 hash - throw ($PodeLocale.noSecretForHmac384ExceptionMessage) - } - - # Compute private HMAC-SHA384 hash - $crypto = [System.Security.Cryptography.HMACSHA384]::new($SecretBytes) - return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) -} - -<# -.SYNOPSIS - Computes a private HMAC-SHA512 hash for a given value using a secret key. - -.DESCRIPTION - This function calculates a private HMAC-SHA512 hash for the specified value using either a secret provided as a string or as a byte array. It supports two parameter sets: - 1. String: The secret is provided as a string. - 2. Bytes: The secret is provided as a byte array. - -.PARAMETER Value - The value for which the private HMAC-SHA512 hash needs to be computed. - -.PARAMETER Secret - The secret key as a string. If this parameter is provided, it will be converted to a byte array. - -.PARAMETER SecretBytes - The secret key as a byte array. If this parameter is provided, it will be used directly. - -.OUTPUTS - Returns the computed private HMAC-SHA512 hash as a base64-encoded string. - -.EXAMPLE - $value = "MySecretValue" - $secret = "MySecretKey" - $hash = Invoke-PodeHMACSHA512Hash -Value $value -Secret $secret - Write-PodeHost "Private HMAC-SHA512 hash: $hash" - - This example computes the private HMAC-SHA512 hash for the value "MySecretValue" using the secret key "MySecretKey". - -.NOTES - - This function is intended for internal use. -#> -function Invoke-PodeHMACSHA512Hash { - [CmdletBinding(DefaultParameterSetName = 'String')] - [OutputType([string])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value, - - [Parameter(Mandatory = $true, ParameterSetName = 'String')] - [string] - $Secret, - - [Parameter(Mandatory = $true, ParameterSetName = 'Bytes')] - [byte[]] - $SecretBytes - ) - - # Convert secret to byte array if provided as a string - if (![string]::IsNullOrWhiteSpace($Secret)) { - $SecretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) - } - - # Validate secret length - if ($SecretBytes.Length -eq 0) { - # No secret supplied for HMAC512 hash - throw ($PodeLocale.noSecretForHmac512ExceptionMessage) - } - - # Compute private HMAC-SHA512 hash - $crypto = [System.Security.Cryptography.HMACSHA512]::new($SecretBytes) - return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) -} function Invoke-PodeSHA256Hash { [CmdletBinding()] @@ -436,130 +308,4 @@ function ConvertTo-PodeStrictSecret { return "$($Secret);$($WebEvent.Request.UserAgent);$($WebEvent.Request.RemoteEndPoint.Address.IPAddressToString)" } - -function New-PodeJwtSignature { - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory = $true)] - [string] - $Algorithm, - - [Parameter(Mandatory = $true)] - [string] - $Token, - - [Parameter()] - [byte[]] - $SecretBytes - ) - - if (($Algorithm -ine 'none') -and (($null -eq $SecretBytes) -or ($SecretBytes.Length -eq 0))) { - # No secret supplied for JWT signature - throw ($PodeLocale.noSecretForJwtSignatureExceptionMessage) - } - - if (($Algorithm -ieq 'none') -and (($null -ne $secretBytes) -and ($SecretBytes.Length -gt 0))) { - # Expected no secret to be supplied for no signature - throw ($PodeLocale.noSecretExpectedForNoSignatureExceptionMessage) - } - - $sig = $null - - switch ($Algorithm.ToUpperInvariant()) { - 'HS256' { - $sig = Invoke-PodeHMACSHA256Hash -Value $Token -SecretBytes $SecretBytes - $sig = ConvertTo-PodeBase64UrlValue -Value $sig -NoConvert - } - - 'HS384' { - $sig = Invoke-PodeHMACSHA384Hash -Value $Token -SecretBytes $SecretBytes - $sig = ConvertTo-PodeBase64UrlValue -Value $sig -NoConvert - } - - 'HS512' { - $sig = Invoke-PodeHMACSHA512Hash -Value $Token -SecretBytes $SecretBytes - $sig = ConvertTo-PodeBase64UrlValue -Value $sig -NoConvert - } - - 'NONE' { - $sig = [string]::Empty - } - - default { - throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $Algorithm) #"The JWT algorithm is not currently supported: $($Algorithm)" - } - } - - return $sig -} - -function ConvertTo-PodeBase64UrlValue { - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value, - - [switch] - $NoConvert - ) - - if (!$NoConvert) { - $Value = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) - } - - $Value = ($Value -ireplace '\+', '-') - $Value = ($Value -ireplace '/', '_') - $Value = ($Value -ireplace '=', '') - - return $Value -} - -function ConvertFrom-PodeJwtBase64Value { - [CmdletBinding()] - [OutputType([pscustomobject])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value - ) - - # map chars - $Value = ($Value -ireplace '-', '+') - $Value = ($Value -ireplace '_', '/') - - # add padding - switch ($Value.Length % 4) { - 1 { - $Value = $Value.Substring(0, $Value.Length - 1) - } - - 2 { - $Value += '==' - } - - 3 { - $Value += '=' - } - } - - # convert base64 to string - try { - $Value = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value)) - } - catch { - # Invalid Base64 encoded value found in JWT - throw ($PodeLocale.invalidBase64JwtExceptionMessage) - } - - # return json - try { - return ($Value | ConvertFrom-Json) - } - catch { - # Invalid JSON value found in JWT - throw ($PodeLocale.invalidJsonJwtExceptionMessage) - } -} \ No newline at end of file + diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 15925c209..5311611dd 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1516,12 +1516,16 @@ function ConvertFrom-PodeRequestContent { $Content = $Request.Body } } - + # Add raw body content + $Result.RawData = $Content # if there is no content then do nothing if ([string]::IsNullOrWhiteSpace($Content)) { return $Result } + # Add raw body content + $Result.RawData = $Content + # check if there is a defined custom body parser if ($PodeContext.Server.BodyParsers.ContainsKey($ContentType)) { $parser = $PodeContext.Server.BodyParsers[$ContentType] @@ -1530,7 +1534,6 @@ function ConvertFrom-PodeRequestContent { return $Result } } - # run action for the content type switch ($ContentType) { { $_ -ilike '*/json' } { @@ -1944,31 +1947,145 @@ function Convert-PodePathPatternsToRegex { <# .SYNOPSIS - Gets the default SSL protocol(s) based on the operating system. + Determines the default allowed SSL/TLS protocols based on the operating system. .DESCRIPTION - This function determines the appropriate default SSL protocol(s) based on the operating system. On macOS, it returns TLS 1.2. On other platforms, it combines SSL 3.0 and TLS 1.2. + This function detects the operating system and determines the allowed SSL/TLS protocols + based on the system’s native support. The function returns an array of + [System.Security.Authentication.SslProtocols] enum values representing the supported protocols. .OUTPUTS - A [System.Security.Authentication.SslProtocols] enum value representing the default SSL protocol(s). + A [System.Security.Authentication.SslProtocols] enum array containing the allowed SSL/TLS protocols. .EXAMPLE - Get-PodeDefaultSslProtocol - # Returns [System.Security.Authentication.SslProtocols]::Ssl3, [System.Security.Authentication.SslProtocols]::Tls12 (on non-macOS systems) - # Returns [System.Security.Authentication.SslProtocols]::Tls12 (on macOS) + Get-PodeDefaultSslProtocol + [System.Security.Authentication.SslProtocols]::Tls12, [System.Security.Authentication.SslProtocols]::Tls13 .NOTES - This is an internal function and may change in future releases of Pode. + This is an internal function and may change in future releases of Pode. + Overriding the default allowed protocols in configuration does not guarantee their availability. + If a protocol is not natively supported by the OS, additional OS-level configuration may be required. #> function Get-PodeDefaultSslProtocol { [CmdletBinding()] [OutputType([System.Security.Authentication.SslProtocols])] param() - if (Test-PodeIsMacOS) { - return (ConvertTo-PodeSslProtocol -Protocol Tls12) + # Cross-platform detection in PowerShell 7.x + $AllowedProtocols = @() + + if (Test-PodeIsWindows) { + # Retrieve Windows OS info + + $osInfo = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' ` + | Select-Object ProductName, CurrentBuild, CurrentVersion + $osName = $osInfo.ProductName + $osVersion = [version]"$($osInfo.CurrentVersion).$($osInfo.CurrentBuild)" + + Write-Verbose "Detected OS: $osName, Version: $osVersion" + + # Determine allowed protocols based on Windows version/build + if ($osName -match 'Windows Vista') { + # Windows Vista / Server 2008 + $AllowedProtocols = @('Ssl2', 'Ssl3') + } + elseif ($osName -match 'Windows 7') { + # Windows 7 / Server 2008 R2 + $AllowedProtocols = @('Ssl2', 'Ssl3') + } + elseif ($osName -match 'Windows 8' -or $osName -match 'Server 2012') { + # Windows 8 / Server 2012 + # Note: SSL2 is disabled by default and not recommended. + $AllowedProtocols = @('Ssl3', 'Tls', 'Tls11', 'Tls12') + } + elseif ($osName -match 'Windows 10') { + # Windows 10 may support TLS 1.3 on later builds + if ($osVersion.Build -ge 20170) { + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + else { + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + } + elseif ($osName -match 'Windows 11' -or $osVersion.Build -ge 22000) { + # Windows 11 / Server 2022: Older protocols disabled + $AllowedProtocols = @('Tls12', 'Tls13') + } + else { + Write-Warning 'Unknown Windows version. Defaulting to modern protocols.' + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + } + elseif ($IsMacOS) { + # Use sw_vers to get macOS version info + $osName = $(sw_vers -productName) + $productVersion = $(sw_vers -productVersion).Trim() + Write-Verbose "Detected OS: $osName, Version: $productVersion" + $versionObj = [version]$productVersion + + # Determine allowed protocols for macOS + if ($versionObj -lt [version]'10.11') { + # macOS 10.8 - 10.10: SSL3 allowed, TLS 1.0/1.1/1.2 allowed, TLS1.3 not supported + $AllowedProtocols = @('Ssl3', 'Tls', 'Tls11', 'Tls12') + } + elseif ($versionObj -ge [version]'10.11' -and $versionObj -lt [version]'10.13') { + # macOS 10.11 (and likely 10.12): SSL3 disabled, TLS 1.0/1.1/1.2 allowed + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + else { + # macOS 10.13 and later: TLS 1.3 is supported in addition to TLS 1.0, 1.1, 1.2 + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + } + elseif ($IsLinux) { + # Read /etc/os-release for OS info if available + if (Test-Path '/etc/os-release') { + $osRelease = Get-Content '/etc/os-release' | ConvertFrom-StringData + $osName = $osRelease.NAME + $osVersion = $osRelease.VERSION_ID + Write-Verbose "Detected OS: $osName, Version: $osVersion" + } + else { + $osName = 'Linux' + Write-Verbose "Detected OS: $osName" + } + + # Determine allowed protocols based on the installed OpenSSL version. + try { + $opensslOutput = openssl version 2>&1 + if ($opensslOutput -match 'OpenSSL\s+([\d\.]+)') { + $opensslVersion = [version]$matches[1] + Write-Verbose "Detected OpenSSL version: $opensslVersion" + if ($opensslVersion -ge [version]'1.1.1') { + # OpenSSL 1.1.1 and later support TLS 1.3 + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + elseif ($opensslVersion -ge [version]'1.0.1g') { + # OpenSSL 1.0.1g up to before 1.1.1 disable SSL3 + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + else { + # OpenSSL 1.0.1 to 1.0.1f: SSL3 is allowed along with TLS 1.0/1.1/1.2 + $AllowedProtocols = @('Ssl3', 'Tls', 'Tls11', 'Tls12') + } + } + else { + Write-Warning 'Could not parse OpenSSL version. Defaulting to TLS 1.2.' + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + } + catch { + Write-Warning 'OpenSSL version check failed. Defaulting to TLS 1.2.' + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + } + else { + Write-Warning 'Unknown platform. No allowed protocols determined.' + $AllowedProtocols = @('Ssl3', 'Tls12') } - return (ConvertTo-PodeSslProtocol -Protocol Ssl3, Tls12) + Write-Verbose "Allowed protocols: $($AllowedProtocols -join ', ')" + + return (ConvertTo-PodeSslProtocol -Protocol $AllowedProtocols) } <# @@ -3942,6 +4059,168 @@ function Test-PodeIsISEHost { return ((Test-PodeIsWindows) -and ('Windows PowerShell ISE Host' -eq $Host.Name)) } + +<# +.SYNOPSIS + Retrieves the name of the main Pode application script. + +.DESCRIPTION + The `Get-PodeApplicationName` function determines the name of the primary script (`.ps1`) + that started execution. It does this by examining the PowerShell call stack and + extracting the first script file that appears. + + If no script file is found in the call stack, the function returns `"NoName"`. + +.OUTPUTS + [string] + Returns the filename of the main application script, or `"NoName"` if no script is found. + +.EXAMPLE + Get-PodeApplicationName + + This retrieves the name of the main script that launched the Pode application. + +.EXAMPLE + $AppName = Get-PodeApplicationName + Write-Host "Application Name: $AppName" + + This stores the retrieved application name in a variable and prints it. + +.NOTES + - This function relies on `Get-PSCallStack`, meaning it must be run within a script execution context. + - If called interactively or if no `.ps1` script is in the call stack, it will return `"NoName"`. + - This is an internal function and may change in future releases of Pode. +#> +function Get-PodeApplicationName { + $scriptFrame = (Get-PSCallStack | Where-Object { $_.Command -match '\.ps1$' } | Select-Object -First 1) + if ($scriptFrame) { + return [System.IO.Path]::GetFileNameWithoutExtension($scriptFrame.Command) + + } + else { + return 'NoName' + } +} + +<# + .SYNOPSIS + Displays a deprecation warning message for a function. + + .DESCRIPTION + The Write-PodeDeprecationWarning function generates a warning message indicating that + a specified function is deprecated and suggests the new replacement function. + + .PARAMETER OldFunction + The name of the deprecated function that is being replaced. + + .PARAMETER NewFunction + The name of the new function that should be used instead. + + .OUTPUTS + None. + + .EXAMPLE + Write-PodeDeprecationWarning -OldFunction "New-PodeLoggingMethod" -NewFunction "New-PodeLogger" + + This will display: + WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use 'New-PodeLogger' function instead. + + .NOTES + Internal function for Pode. + Subject to change in future releases. +#> +function Write-PodeDeprecationWarning { + param ( + [Parameter(Mandatory = $true)] + [string] + $OldFunction, + + [Parameter(Mandatory = $true)] + [string] + $NewFunction + ) + # WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use '{0}' function instead. + Write-PodeHost ($PodeLocale.deprecatedFunctionWarningMessage -f $OldFunction, $NewFunction) -ForegroundColor Yellow +} + +<# +.SYNOPSIS + Converts a SecureString to plain text. + +.DESCRIPTION + This function takes a SecureString input and converts it into a plain text string. + Supports pipeline input for seamless integration with other cmdlets. + +.PARAMETER SecureString + The SecureString that needs to be converted. + +.OUTPUTS + [string] Plain text representation of the SecureString. + +.NOTES + Internal Pode function - subject to change. +#> +function Convert-PodeSecureStringToPlainText { + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline)] + [securestring]$SecureString + ) + + process { + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + try { + [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } +} + +<# +.SYNOPSIS + Converts a SecureString to a UTF8 byte array. + +.DESCRIPTION + This function takes a SecureString input and converts it into a UTF8 encoded byte array. + Supports pipeline input for seamless integration with other cmdlets. + +.PARAMETER SecureString + The SecureString that needs to be converted. + +.OUTPUTS + [byte[]] A UTF8 encoded byte array representation of the SecureString. + +.NOTES + Internal Pode function - subject to change. +#> +function Convert-PodeSecureStringToByteArray { + [CmdletBinding()] + [OutputType([byte[]])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline)] + [securestring] + $SecureString + ) + + process { + if ($null -ne $SecureString) { + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + try { + [System.Text.Encoding]::UTF8.GetBytes([Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } + else { + return [byte[]]::new(0) # Return empty byte array instead of $null + } + } +} + <# .SYNOPSIS Retrieves the name of the main Pode application script. diff --git a/src/Private/Jwt.ps1 b/src/Private/Jwt.ps1 new file mode 100644 index 000000000..e8a41c3a8 --- /dev/null +++ b/src/Private/Jwt.ps1 @@ -0,0 +1,918 @@ + +<# +.SYNOPSIS + Validates and verifies the authenticity of a JSON Web Token (JWT). + + .DESCRIPTION + This function validates a JWT by: + - Splitting and decoding the token. + - Verifying the algorithm used. + - Performing signature validation using HMAC, RSA, or ECDSA. + - Supporting configurable verification modes. + - Returning the payload if valid. + + .PARAMETER Token + The JWT string to be validated in `header.payload.signature` format. + + .PARAMETER Algorithm + Supported JWT signing algorithms: HS256, RS256, ES256, etc. + + .PARAMETER Secret + SecureString key for HMAC algorithms. + + .PARAMETER X509Certificate + X509Certificate2 object for RSA/ECDSA verification. + + .OUTPUTS + Returns the JWT payload if the token is valid. + + .EXAMPLE + Confirm-PodeJwt -Token $jwt -Algorithm RS256 -Certificate $cert + +.NOTES + - Throws an exception if the JWT is invalid, expired, or tampered with. + - The function does not check the `exp`, `nbf`, or `iat` claims. + - Use `Test-PodeJwt` separately to validate JWT claims. +#> +function Confirm-PodeJwt { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$Token, + + [Parameter(Mandatory = $true)] + [ValidateSet('NONE', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')] + [string[]]$Algorithm, + + [Parameter()] + [securestring]$Secret, # Required for HMAC + + [Parameter()] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate + ) + + # Split JWT into header, payload, and signature + $parts = $Token -split '\.' + if (($parts.Length -ne 3)) { + throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) + } + + # Decode the JWT header + $header = ConvertFrom-PodeJwtBase64Value -Value $parts[0] + + # Decode the JWT payload + $payload = ConvertFrom-PodeJwtBase64Value -Value $parts[1] + + # Apply verification mode for algorithm enforcement + if ($Algorithm -notcontains $header.alg) { + throw ($PodeLocale.jwtAlgorithmMismatchExceptionMessage -f ($Algorithm -join ','), $header.alg) + } + + $Algorithm = $header.alg + + # Handle none algorithm cases + $isNoneAlg = ($header.alg -eq 'NONE') + if ([string]::IsNullOrEmpty($Algorithm)) { + throw ($PodeLocale.noAlgorithmInJwtHeaderExceptionMessage) + } + + # Ensure secret/certificate presence when required + if (($null -eq $Secret) -and ( $null -eq $X509Certificate) -and !$isNoneAlg) { + # No JWT signature supplied for {0} + throw ($PodeLocale.noJwtSignatureForAlgorithmExceptionMessage -f $header.alg) + } + if ((( $null -ne $X509Certificate) -or ($null -ne $Secret)) -and $isNoneAlg) { + # Expected no JWT signature to be supplied + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + if ((![string]::IsNullOrEmpty($parts[2]) -and $isNoneAlg)) { + throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) + } + + if ($isNoneAlg) { + return $payload + } + if ($null -ne $Secret) { + # Convert Secret to bytes if provided + $secretBytes = Convert-PodeSecureStringToByteArray -SecureString $Secret + } + + if ($isNoneAlg -and ($null -ne $SecretBytes) -and ($SecretBytes.Length -gt 0)) { + # Expected no JWT signature to be supplied + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + # Prepare data for signature verification + $headerPayloadBytes = [System.Text.Encoding]::UTF8.GetBytes("$($parts[0]).$($parts[1])") + # Convert JWT signature from Base64 URL to Byte Array + $fixedSignature = $parts[2].Replace('-', '+').Replace('_', '/') + # Add proper Base64 padding + switch ($fixedSignature.Length % 4) { + 1 { $fixedSignature = $fixedSignature.Substring(0, $fixedSignature.Length - 1); break } # Remove invalid character + 2 { $fixedSignature += '=='; break } # Add two padding characters + 3 { $fixedSignature += '='; break } # Add one padding character + } + $signatureBytes = [Convert]::FromBase64String($fixedSignature) + + # Verify Signature + + # Handle HMAC signature verification + if ($Algorithm -match '^HS(\d{3})$') { + if ($null -eq $SecretBytes) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Algorithm) + } + + # Compute HMAC Signature + $hmac = switch ($Algorithm) { + 'HS256' { [System.Security.Cryptography.HMACSHA256]::new($SecretBytes); break } + 'HS384' { [System.Security.Cryptography.HMACSHA384]::new($SecretBytes); break } + 'HS512' { [System.Security.Cryptography.HMACSHA512]::new($SecretBytes); break } + } + # Prepare JWT signing input + $expectedSignatureBytes = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("$($parts[0]).$($parts[1])")) + $expectedSignature = [Convert]::ToBase64String($expectedSignatureBytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') + + # Compare signatures + if ($expectedSignature -ne $parts[2]) { + throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) + } + } + elseif ($Algorithm -match '^(RS|PS)(\d{3})$') { + # Extract the RSA public key from the existing certificate object + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($X509Certificate) + + $hashAlgo = switch ($Algorithm) { + 'RS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'RS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'RS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + 'PS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'PS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'PS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + } + + $rsaPadding = if ($Algorithm -match '^PS') { + [System.Security.Cryptography.RSASignaturePadding]::Pss + } + else { + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + } + if (!($rsa.VerifyData($headerPayloadBytes, $signatureBytes, $hashAlgo, $rsaPadding))) { + throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) + } + } + elseif ($Algorithm -match '^ES(\d{3})$') { + # Extract the ECSDA public key from the existing certificate object + $ecdsa = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($X509Certificate) + + $hashAlgo = switch ($Algorithm) { + 'ES256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'ES384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'ES512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + } + if (!($ecdsa.VerifyData($headerPayloadBytes, $signatureBytes, $hashAlgo))) { + throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) + } + } + + return $payload +} + +function ConvertTo-PodeBase64UrlValue { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string] + $Value, + + [switch] + $NoConvert + ) + + if (!$NoConvert) { + $Value = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) + } + + return $Value.Replace('+', '-').Replace('/', '_').TrimEnd('=') +} + +function ConvertFrom-PodeJwtBase64Value { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string] + $Value + ) + + # map chars + $Value = $Value.Replace('-', '+').Replace('_', '/') + # Add proper Base64 padding + switch ($Value.Length % 4) { + 1 { $Value = $Value.Substring(0, $Value.Length - 1) } # Remove invalid character + 2 { $Value += '==' } # Add two padding characters + 3 { $Value += '=' } # Add one padding character + } + # convert base64 to string + try { + $Value = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value)) + } + catch { + # Invalid Base64 encoded value found in JWT + throw ($PodeLocale.invalidBase64JwtExceptionMessage) + } + # return json + try { + return ($Value | ConvertFrom-Json) + } + catch { + # Invalid JSON value found in JWT + throw ($PodeLocale.invalidJsonJwtExceptionMessage) + } +} + +<# +.SYNOPSIS + Computes a cryptographic hash using the specified algorithm. + +.DESCRIPTION + This function accepts a string and an algorithm name, computes the hash using the specified algorithm, + and returns the hash as a lowercase hexadecimal string. + +.PARAMETER Value + The input string to be hashed. + +.PARAMETER Algorithm + The hashing algorithm to use (SHA-1, SHA-256, SHA-512, SHA-512/256). + +.OUTPUTS + [string] - The computed hash in hexadecimal format. + +.NOTES + Internal Pode function for authentication hashing. +#> +function ConvertTo-PodeDigestHash { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + $Value, + + [Parameter(Mandatory = $true)] + [ValidateSet('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256')] + [string] + $Algorithm + ) + + # Select the appropriate hash algorithm + $crypto = switch ($Algorithm) { + 'MD5' { [System.Security.Cryptography.MD5]::Create(); break } + 'SHA-1' { [System.Security.Cryptography.SHA1]::Create(); break } + 'SHA-256' { [System.Security.Cryptography.SHA256]::Create(); break } + 'SHA-384' { [System.Security.Cryptography.SHA384]::Create(); break } + 'SHA-512' { [System.Security.Cryptography.SHA512]::Create(); break } + 'SHA-512/256' { + # Compute SHA-512 and truncate to 256 bits (first 32 bytes) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $fullHash = $sha512.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value)) + return [System.BitConverter]::ToString($fullHash[0..31]).Replace('-', '').ToLowerInvariant() + } + } + + return [System.BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))).Replace('-', '').ToLowerInvariant() +} + +<# +.SYNOPSIS + Determines the JWT signing algorithm based on the provided X.509 certificate. + +.DESCRIPTION + This function extracts the private key (RSA or ECDSA) from a given X.509 certificate (PFX) and determines the appropriate JSON Web Token (JWT) signing algorithm. + For RSA keys, the function attempts to read the key size using the `KeySize` property. On Linux with .NET 9, this property is write-only, so a reflection-based workaround is used to retrieve the private `KeySizeValue` field. + For ECDSA keys, the algorithm is selected directly based on the key size. + +.PARAMETER X509Certificate + A System.Security.Cryptography.X509Certificates.X509Certificate2 object representing the certificate (PFX) from which the private key is extracted. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use. Acceptable values are 'Pkcs1V15' (default) and 'Pss'. + +.EXAMPLE + PS> Get-PodeJwtSigningAlgorithm -X509Certificate $myCert -RsaPaddingScheme 'Pkcs1V15' + Determines and returns the appropriate JWT signing algorithm (e.g., 'RS256', 'RS384', 'RS512' for RSA or 'ES256', 'ES384', 'ES512' for ECDSA) based on the certificate's key. + +.NOTES + This function includes a reflection-based workaround for .NET 9 on Linux where the RSA `KeySize` property is write-only. Refer to https://github.com/dotnet/runtime/issues/112622 for more details. +#> +function Get-PodeJwtSigningAlgorithm { + param ( + + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, # PFX + + [ValidateSet('Pkcs1V15', 'Pss')] + [string]$RsaPaddingScheme = 'Pkcs1V15' # Default to PKCS#1 v1.5 unless specified + ) + # Extract Private Key (RSA or ECDSA) + $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($X509Certificate) + if ($null -ne $key) { + Write-Verbose 'RSA Private Key detected.' + try { + $keySize = $key.KeySize + } + catch { + # Exception is 'Cannot get property value because "KeySize" is a write-only property.' + # Use reflection to access the private 'KeySizeValue' field + $bindingFlags = [System.Reflection.BindingFlags] 'NonPublic, Instance' + $keySizeField = $key.GetType().GetField('KeySizeValue', $bindingFlags) + + # Retrieve the value of the 'KeySizeValue' field this is a workaround of an issue with .net for Linux + Write-Verbose "Keysize obtained by reflection $($keySizeField.GetValue($key))" + $keySize = $keySizeField.GetValue($key) + } + # Determine RSA key size + switch ($keySize) { + 2048 { return $(if ($RsaPaddingScheme -eq 'Pkcs1V15') { 'RS256' } else { 'PS256' }) } + 3072 { return $(if ($RsaPaddingScheme -eq 'Pkcs1V15') { 'RS384' } else { 'PS384' }) } + 4096 { return $(if ($RsaPaddingScheme -eq 'Pkcs1V15') { 'RS512' } else { 'PS512' }) } + default { throw ($PodeLocale.unknownAlgorithmWithKeySizeExceptionMessage -f 'RSA', $rsa.KeySize) } + } + } + else { + $key = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($X509Certificate) + if ($null -ne $key) { + Write-Verbose 'ECDSA Private Key detected.' + + # Determine ECDSA key size + switch ($key.KeySize) { + 256 { return 'ES256' } + 384 { return 'ES384' } + 521 { return 'ES512' } # JWT uses 521-bit, NOT 512-bit + default { throw ($PodeLocale.unknownAlgorithmWithKeySizeExceptionMessage -f 'ECDSA' , $ecdsa.KeySize) } + } + } + else { + throw $PodeLocale.unknownAlgorithmOrInvalidPfxExceptionMessage + } + } +} + + + + +<# +.SYNOPSIS + Generates a JSON Web Token (JWT) based on the specified headers, payload, and signing credentials. +.DESCRIPTION + This function creates a JWT by combining a Base64URL-encoded header and payload. Depending on the + configured parameters, it supports various signing algorithms, including HMAC- and certificate-based + signatures. You can also omit a signature by specifying 'none'. + +.PARAMETER Header + Additional header values for the JWT. Defaults to an empty hashtable if not specified. + +.PARAMETER Payload + The required hashtable specifying the token’s claims. + +.PARAMETER Algorithm + A string representing the signing algorithm to be used. Accepts 'NONE', 'HS256', 'HS384', or 'HS512'. + +.PARAMETER Secret + Used in conjunction with HMAC signing. Can be either a byte array or a SecureString. Required if you + select the 'SecretBytes' parameter set. + +.PARAMETER X509Certificate + An X509Certificate2 object used for RSA/ECDSA-based signing. Required if you select the 'CertRaw' parameter set. + +.PARAMETER Certificate + The path to a certificate file used for signing. Required if you select the 'CertFile' parameter set. + +.PARAMETER PrivateKeyPath + Optional path to an associated certificate key file. + +.PARAMETER CertificatePassword + An optional SecureString password for a certificate file. + +.PARAMETER CertificateThumbprint + A string thumbprint of a certificate in the local store. Required if you select the 'CertThumb' parameter set. + +.PARAMETER CertificateName + A string name of a certificate in the local store. Required if you select the 'CertName' parameter set. + +.PARAMETER CertificateStoreName + The store name to search for the specified certificate. Defaults to 'My'. + +.PARAMETER CertificateStoreLocation + The certificate store location for the specified certificate. Defaults to 'CurrentUser'. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use. Accepts 'Pkcs1V15' or 'Pss'. Defaults to 'Pkcs1V15'. + +.PARAMETER Authentication + The name of a configured authentication method in Pode. Required if you select the 'AuthenticationMethod' parameter set. + +.PARAMETER Expiration + Time in seconds until the token expires. Defaults to 3600 (1 hour). + +.PARAMETER NotBefore + Time in seconds to offset the NotBefore claim. Defaults to 0 for immediate use. + +.PARAMETER IssuedAt + Time in seconds to offset the IssuedAt claim. Defaults to 0 for current time. + +.PARAMETER Issuer + Identifies the principal that issued the token. + +.PARAMETER Subject + Identifies the principal that is the subject of the token. + +.PARAMETER Audience + Specifies the recipients that the token is intended for. + +.PARAMETER JwtId + A unique identifier for the token. + +.PARAMETER NoStandardClaims + A switch that, if used, prevents automatically adding iat, nbf, exp, iss, sub, aud, and jti claims. + +.OUTPUTS + System.String + The resulting JWT string. + + +.EXAMPLE + New-PodeJwt -Header [pscustomobject]@{ alg = 'none' } -Payload [pscustomobject]@{ sub = '123'; name = 'John' } + +.EXAMPLE + New-PodeJwt -Header [pscustomobject]@{ alg = 'HS256' } -Payload [pscustomobject]@{ sub = '123'; name = 'John' } -Secret 'abc' + +.EXAMPLE + New-PodeJwt -Header [pscustomobject]@{ alg = 'RS256' } -Payload [pscustomobject]@{ sub = '123' } -PrivateKey (Get-Content "private.pem" -Raw) -Issuer "auth.example.com" -Audience "myapi.example.com" +#> +function New-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([string])] + param( + [Parameter()] + [pscustomobject]$Header, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Payload, + + [Parameter(ParameterSetName = 'Default')] + [Parameter(ParameterSetName = 'Secret')] + [ValidateSet('NONE', 'HS256', 'HS384', 'HS512')] + [string]$Algorithm, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [byte[]] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication, + + [Parameter()] + [int] + $Expiration = 3600, # Default: 1 hour + + [Parameter()] + [int] + $NotBefore = 0, # Default: Immediate + + [Parameter()] + [int]$IssuedAt = 0, # Default: Current time + + [Parameter()] + [string]$Issuer, + + [Parameter()] + [string]$Subject, + + [Parameter()] + [string]$Audience, + + [Parameter()] + [string]$JwtId, + + [Parameter()] + [switch] + $NoStandardClaims + ) + if (!($Header.PSObject.Properties['alg'])) { + $Header | Add-Member -MemberType NoteProperty -Name 'alg' -Value '' + } + + # Determine actions based on parameter set + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + if (!(Test-Path -Path $Certificate -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Certificate) + } + + # Retrieve X509 certificate from a file + $X509Certificate = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath + break + } + + 'certthumb' { + # Retrieve X509 certificate from store by thumbprint + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'certname' { + # Retrieve X509 certificate from store by name + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'Secret' { + # If algorithm was already set in the header, default to it if none provided + if (!([string]::IsNullOrWhiteSpace($Header.alg))) { + if ([string]::IsNullOrWhiteSpace($Algorithm)) { + $Algorithm = $Header.alg.ToUpper() + } + } + + # Validate that 'none' has no secret + if (($Algorithm -ieq 'none')) { + throw ($PodeLocale.noSecretExpectedForNoSignatureExceptionMessage) + } + + # Convert secret to a byte array if needed + if ($null -eq $Secret) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Header.alg) + } + + + if ([string]::IsNullOrWhiteSpace($Algorithm)) { + $Algorithm = 'HS256' + } + + $Header.alg = $Algorithm.ToUpper() + $params = @{ + Algorithm = $Algorithm.ToUpper() + SecretBytes = $Secret + } + break + } + + 'CertRaw' { + # Validate that a raw certificate is present + if ($null -eq $X509Certificate) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'private', 'RSA/ECSDA', $Header.alg) + } + break + } + + 'AuthenticationMethod' { + # Retrieve authentication details from Pode's context + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + # If 'none' was set in the header but is not supported by the method, throw + if (($Header.alg -ieq 'none') -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication).Algorithm -notcontains 'none') { + throw ($PodeLocale.noSecretExpectedForNoSignatureExceptionMessage) + } + $Header.alg = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments.Algorithm[0] + $params = @{ + Authentication = $Authentication + } + } + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) + } + } + } + + # Configure the JWT header and parameters if using a certificate + if ($null -ne $X509Certificate) { + + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (Code Signing). + # 4. Meets strict Enhanced Key Usage (EKU) enforcement. + $null = Test-PodeCertificate -Certificate $X509Certificate -ExpectedPurpose CodeSigning -Strict -ErrorAction Stop + } + + $Header.alg = Get-PodeJwtSigningAlgorithm -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme + $params = @{ + X509Certificate = $X509Certificate + RsaPaddingScheme = $RsaPaddingScheme + } + } + + # Optionally add standard claims if not suppressed + if (!$NoStandardClaims) { + if (! $Header.PSObject.Properties['typ']) { + $Header | Add-Member -MemberType NoteProperty -Name 'typ' -Value 'JWT' + } + else { + $Header.typ = 'JWT' + } + + # Current Unix time + $currentUnix = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + + if (! $Payload.PSObject.Properties['iat']) { + $Payload | Add-Member -MemberType NoteProperty -Name 'iat' -Value $(if ($IssuedAt -gt 0) { $IssuedAt } else { $currentUnix }) + } + if (! $Payload.PSObject.Properties['nbf']) { + $Payload | Add-Member -MemberType NoteProperty -Name 'nbf' -Value ($currentUnix + $NotBefore) + } + if (! $Payload.PSObject.Properties['exp']) { + $Payload | Add-Member -MemberType NoteProperty -Name 'exp' -Value ($currentUnix + $Expiration) + } + + if (! $Payload.PSObject.Properties['iss']) { + if ([string]::IsNullOrEmpty($Issuer)) { + if ($null -ne $PodeContext) { + $Payload | Add-Member -MemberType NoteProperty -Name 'iss' -Value 'Pode' + } + } + else { + $Payload | Add-Member -MemberType NoteProperty -Name 'iss' -Value $Issuer + } + } + + if (! $Payload.PSObject.Properties['sub'] -and ![string]::IsNullOrEmpty($Subject)) { + $Payload | Add-Member -MemberType NoteProperty -Name 'sub' -Value $Subject + } + + if (! $Payload.PSObject.Properties['aud']) { + if ([string]::IsNullOrEmpty($Audience)) { + if (($null -ne $PodeContext) -and ($null -ne $PodeContext.Server.ApplicationName)) { + $Payload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $PodeContext.Server.ApplicationName + } + } + else { + $Payload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $Audience + } + } + + if (! $Payload.PSObject.Properties['jti'] ) { + if ([string]::IsNullOrEmpty($JwtId)) { + $Payload | Add-Member -MemberType NoteProperty -Name 'jti' -Value (New-PodeGuid) + } + else { + $Payload | Add-Member -MemberType NoteProperty -Name 'jti' -Value $JwtId + } + } + } + + # Encode header and payload as Base64URL + $header64 = ConvertTo-PodeBase64UrlValue -Value ($Header | ConvertTo-Json -Compress) + $payload64 = ConvertTo-PodeBase64UrlValue -Value ($Payload | ConvertTo-Json -Compress) + + # Combine header and payload + $jwt = "$($header64).$($payload64)" + + # Generate signature if not 'none' + $sig = if ($Header.alg -ne 'none') { + $params['Token'] = $jwt + New-PodeJwtSignature @params + } + else { + [string]::Empty + } + + # Concatenate signature to form the final JWT + $jwt += ".$($sig)" + return $jwt +} + + + + + + +<# +.SYNOPSIS + Generates a JWT-compatible signature using a specified RFC 7518 signing algorithm. + +.DESCRIPTION + This function creates a JWT signature for a given token using the provided algorithm and secret key bytes. + It ensures that a secret is supplied when required and throws an exception if constraints are violated. + The signature is computed using HMAC (HS256, HS384, HS512), RSA (RS256, RS384, RS512, PS256, PS384, PS512), or ECDSA (ES256, ES384, ES512). + +.PARAMETER Algorithm + The signing algorithm. Supported values: HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512. + +.PARAMETER Token + The JWT token to be signed. + +.PARAMETER SecretBytes + The secret key in byte array format used for signing the JWT using the HMAC algorithms. + This parameter is optional when using the 'none' algorithm. + +.PARAMETER X509Certificate + The private key certificate for RSA or ECDSA algorithms. + +.PARAMETER RsaPaddingScheme + RSA padding scheme to use, default is `Pkcs1V15`. + +.OUTPUTS + [string] - The JWT signature as a base64url-encoded string. + +.EXAMPLE + $token = "header.payload" + $key = [System.Text.Encoding]::UTF8.GetBytes("my-secret-key") + $signature = New-PodeJwtSignature -Algorithm "HS256" -Token $token -SecretBytes $key + + This example generates a JWT signature using the HMAC SHA-256 algorithm. + +.EXAMPLE + $privateKey = Get-Content "private_key.pem" -Raw + $signature = New-PodeJwtSignature -Algorithm RS256 -Token "header.payload" -X509Certificate $certificate + +.NOTES + This function is an internal Pode function and is subject to change. +#> +function New-PodeJwtSignature { + [CmdletBinding(DefaultParameterSetName = 'SecretBytes')] + [OutputType([string])] + param( + [Parameter(Mandatory = $true, ParameterSetName = 'SecretBytes')] + [Parameter(Mandatory = $true, ParameterSetName = 'SecretSecureString')] + [ValidateSet('HS256', 'HS384', 'HS512')] + [string] + $Algorithm, + + [Parameter(Mandatory = $true)] + [string] + $Token, + + [Parameter(Mandatory = $true, ParameterSetName = 'SecretBytes')] + [byte[]] + $SecretBytes, + + [Parameter(Mandatory = $true, ParameterSetName = 'SecretSecureString')] + [securestring] + $Secret, + + [Parameter( Mandatory = $true, ParameterSetName = 'X509Certificate')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'X509Certificate')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication + ) + $alg = $Algorithm + switch ($PSCmdlet.ParameterSetName) { + 'SecretBytes' { + if ($null -eq $SecretBytes) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Algorithm) + } + break + } + 'SecretSecureString' { + if ($null -eq $Secret) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Algorithm) + } + # Convert Secret to bytes if provided + $secretBytes = Convert-PodeSecureStringToByteArray -SecureString $Secret + break + } + 'X509Certificate' { + if ($null -eq $X509Certificate) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'private', 'RSA/ECSDA', $Algorithm) + } + $alg = Get-PodeJwtSigningAlgorithm -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme + + break + } + 'AuthenticationMethod' { + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $method = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments + $alg = $method.Algorithm + if ($null -ne $method.X509Certificate) { + $X509Certificate = $method.X509Certificate + } + if ($null -ne $method.Secret) { + $secretBytes = Convert-PodeSecureStringToByteArray -SecureString $method.Secret + } + } + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) + } + } + } + + $valueBytes = [System.Text.Encoding]::UTF8.GetBytes($Token) + + switch ($alg) { + + # HMAC-SHA (HS256, HS384, HS512) + { $_ -match '^HS(\d{3})$' } { + + # Map HS256, HS384, HS512 to their respective classes + $hmac = switch ($alg) { + 'HS256' { [System.Security.Cryptography.HMACSHA256]::new($SecretBytes); break } + 'HS384' { [System.Security.Cryptography.HMACSHA384]::new($SecretBytes); break } + 'HS512' { [System.Security.Cryptography.HMACSHA512]::new($SecretBytes); break } + default { throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) } + } + + $signature = $hmac.ComputeHash($valueBytes) + break + } + + # RSA (RS256, RS384, RS512, PS256, PS384, PS512) + { $_ -match '^(RS|PS)(\d{3})$' } { + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($X509Certificate) + + # Map RS256, RS384, RS512 to their correct SHA algorithm + $hashAlgo = switch ($alg) { + 'RS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'RS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'RS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + 'PS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'PS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'PS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + default { throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) } + } + + $rsaPadding = if ($alg -match '^PS') { + [System.Security.Cryptography.RSASignaturePadding]::Pss + } + else { + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + } + + $signature = $rsa.SignData($valueBytes, $hashAlgo, $rsaPadding) + + break + } + + # ECDSA (ES256, ES384, ES512) + { $_ -match '^ES(\d{3})$' } { + $ecdsa = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($X509Certificate) + + # Map ES256, ES384, ES512 to their correct SHA algorithm + $hashAlgo = switch ($alg) { + 'ES256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'ES384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'ES512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + default { throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) } + } + + $signature = $ecdsa.SignData($valueBytes, $hashAlgo) + break + } + + default { + throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) + } + } + return [System.Convert]::ToBase64String($signature).Replace('+', '-').Replace('/', '_').TrimEnd('=') +} \ No newline at end of file diff --git a/src/Private/Middleware.ps1 b/src/Private/Middleware.ps1 index b15fc5760..2d644999a 100644 --- a/src/Private/Middleware.ps1 +++ b/src/Private/Middleware.ps1 @@ -314,6 +314,7 @@ function Get-PodeBodyMiddleware { # set session data $WebEvent.Data = $result.Data $WebEvent.Files = $result.Files + $WebEvent.RawData = $result.RawData # payload parsed return $true @@ -425,7 +426,7 @@ function Initialize-PodeIISMiddleware { try { $value = Get-PodeHeader -Name $header - $WebEvent.Request.ClientCertificate = [X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($value)) + $WebEvent.Request.ClientCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($value)) } catch { $WebEvent.Request.ClientCertificateErrors = [System.Net.Security.SslPolicyErrors]::RemoteCertificateNotAvailable diff --git a/src/Private/Security.ps1 b/src/Private/Security.ps1 index 0d3f4df0b..857404986 100644 --- a/src/Private/Security.ps1 +++ b/src/Private/Security.ps1 @@ -134,6 +134,62 @@ function Test-PodeCsrfConfigured { return (!(Test-PodeIsEmpty $PodeContext.Server.Cookies.Csrf)) } + +<# +.SYNOPSIS + Loads an X.509 certificate from a file (PFX, PEM, or CER), optionally decrypting it with a password. + +.DESCRIPTION + This function reads an X.509 certificate from a file and loads it as an X509Certificate2 object. + It supports: + - PFX (PKCS#12) certificates with optional password decryption. + - PEM certificates with a separate private key file. + - CER (DER or Base64-encoded) certificates (public key only). + + It applies the appropriate key storage flags depending on the operating system and + ensures compatibility with Pode’s certificate handling utilities. + +.PARAMETER Certificate + The file path to the certificate (.pfx, .pem, or .cer) to load. + +.PARAMETER SecurePassword + A secure string containing the password for decrypting the certificate (only applicable for PFX files). + +.PARAMETER PrivateKeyPath + The path to a separate private key file (only applicable for PEM certificates). + Required if the PEM certificate does not contain the private key. + +.PARAMETER Ephemeral + If specified, the certificate will be created with `EphemeralKeySet`, meaning the private key + will **not be persisted** on disk or in the certificate store. + + This is useful for temporary certificates that should only exist in memory for the duration + of the current session. Once the process exits, the private key will be lost. + +.PARAMETER Exportable + If specified the certificate will be created with `Exportable`, meaning the certificate can be exported + +.OUTPUTS + [System.Security.Cryptography.X509Certificates.X509Certificate2] + Returns an X.509 certificate object. + +.EXAMPLE + $cert = Get-PodeCertificateByFile -Certificate "C:\Certs\mycert.pfx" -SecurePassword (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Loads a PFX certificate with a password. + +.EXAMPLE + $cert = Get-PodeCertificateByFile -Certificate "C:\Certs\mycert.pem" -PrivateKeyPath "C:\Certs\mykey.pem" + Loads a PEM certificate with a separate private key. + +.EXAMPLE + $cert = Get-PodeCertificateByFile -Certificate "C:\Certs\mycert.cer" + Loads a CER certificate (public key only). + +.NOTES + - CER files do not contain private keys and cannot be decrypted with a password. + - PEM certificates may require a separate private key file. + - Uses EphemeralKeySet storage on non-macOS platforms for security. +#> function Get-PodeCertificateByFile { param( [Parameter(Mandatory = $true)] @@ -141,28 +197,49 @@ function Get-PodeCertificateByFile { $Certificate, [Parameter()] - [string] - $Password = $null, + [securestring] + $SecurePassword = $null, [Parameter()] [string] - $Key = $null + $PrivateKeyPath = $null, + + [Parameter()] + [switch] + $Ephemeral, + + [Parameter()] + [switch] + $Exportable ) + $path = Get-PodeRelativePath -Path $Certificate -JoinRoot -Resolve # cert + key - if (![string]::IsNullOrWhiteSpace($Key)) { - return (Get-PodeCertificateByPemFile -Certificate $Certificate -Password $Password -Key $Key) + if (![string]::IsNullOrWhiteSpace($PrivateKeyPath) -or ( [System.IO.Path]::GetExtension($path).ToLower() -eq '.pem') ) { + return (Get-PodeCertificateByPemFile -Certificate $Certificate -SecurePassword $SecurePassword -PrivateKeyPath $PrivateKeyPath) } - $path = Get-PodeRelativePath -Path $Certificate -JoinRoot -Resolve + # read the cert bytes from the file to avoid the use of obsolete constructors + $certBytes = [System.IO.File]::ReadAllBytes($path) - # cert + password - if (![string]::IsNullOrWhiteSpace($Password)) { - return [X509Certificates.X509Certificate2]::new($path, $Password) + if ($Ephemeral -and !$IsMacOS) { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + } + else { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::DefaultKeySet } + if ($Exportable) { + $storageFlags = $storageFlags -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable + } + + if ( [System.IO.Path]::GetExtension($path).ToLower() -eq '.pfx') { + if ($null -ne $SecurePassword) { + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certBytes, $SecurePassword, $storageFlags) + } + } # plain cert - return [X509Certificates.X509Certificate2]::new($path) + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certBytes, $null, $storageFlags) } function Get-PodeCertificateByPemFile { @@ -172,60 +249,103 @@ function Get-PodeCertificateByPemFile { $Certificate, [Parameter()] - [string] - $Password = $null, + [securestring] + $SecurePassword = $null, - [Parameter()] + [Parameter(Mandatory = $true)] [string] - $Key = $null + $PrivateKeyPath ) + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + $cert = $null $certPath = Get-PodeRelativePath -Path $Certificate -JoinRoot -Resolve - $keyPath = Get-PodeRelativePath -Path $Key -JoinRoot -Resolve + $keyPath = Get-PodeRelativePath -Path $PrivateKeyPath -JoinRoot -Resolve # pem's kinda work in .NET3/.NET5 if ([version]$PSVersionTable.PSVersion -ge [version]'7.0.0') { - $cert = [X509Certificates.X509Certificate2]::new($certPath) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certPath) $keyText = [System.IO.File]::ReadAllText($keyPath) - $rsa = [RSA]::Create() - - # .NET5 - if ([version]$PSVersionTable.PSVersion -ge [version]'7.1.0') { - if ([string]::IsNullOrWhiteSpace($Password)) { - $rsa.ImportFromPem($keyText) - } + try { + $rsa = [RSA]::Create() + + # .NET5 + if ([version]$PSVersionTable.PSVersion -ge [version]'7.1.0') { + if ($null -eq $SecurePassword ) { + $rsa.ImportFromPem($keyText) + } + else { + $rsa.ImportFromEncryptedPem($keyText, (Convert-PodeSecureStringToPlainText -SecureString $SecurePassword)) + } + } # .NET3 else { - $rsa.ImportFromEncryptedPem($keyText, $Password) + $keyBlocks = $keyText.Split('-', [System.StringSplitOptions]::RemoveEmptyEntries) + $keyBytes = [System.Convert]::FromBase64String($keyBlocks[1]) + + if ($keyBlocks[0] -ieq 'BEGIN PRIVATE KEY') { + $rsa.ImportPkcs8PrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN RSA PRIVATE KEY') { + $rsa.ImportRSAPrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN ENCRYPTED PRIVATE KEY') { + if ($null -ne $SecurePassword) { + [int32]$bytesRead = 0 + $rsa.ImportEncryptedPkcs8PrivateKey( (Convert-PodeSecureStringToPlainText -SecureString $SecurePassword), $keyBytes, [ref]$bytesRead) + } + } + $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($cert, $rsa) } } - - # .NET3 - else { - $keyBlocks = $keyText.Split('-', [System.StringSplitOptions]::RemoveEmptyEntries) - $keyBytes = [System.Convert]::FromBase64String($keyBlocks[1]) - - if ($keyBlocks[0] -ieq 'BEGIN PRIVATE KEY') { - $rsa.ImportPkcs8PrivateKey($keyBytes, [ref]$null) - } - elseif ($keyBlocks[0] -ieq 'BEGIN RSA PRIVATE KEY') { - $rsa.ImportRSAPrivateKey($keyBytes, [ref]$null) - } - elseif ($keyBlocks[0] -ieq 'BEGIN ENCRYPTED PRIVATE KEY') { - $rsa.ImportEncryptedPkcs8PrivateKey($Password, $keyBytes, [ref]$null) + catch { + $ecsd = [ECDSA]::Create() + if ([version]$PSVersionTable.PSVersion -ge [version]'7.1.0') { + if ( $null -eq $SecurePassword ) { + $ecsd.ImportFromPem($keyText) + } + else { + $ecsd.ImportFromEncryptedPem($keyText, (Convert-PodeSecureStringToByteArray -SecureString $SecurePassword)) + + } + + + # .NET3 + else { + $keyBlocks = $keyText.Split('-', [System.StringSplitOptions]::RemoveEmptyEntries) + $keyBytes = [System.Convert]::FromBase64String($keyBlocks[1]) + + if ($keyBlocks[0] -ieq 'BEGIN PRIVATE KEY') { + $ecsd.ImportPkcs8PrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN RSA PRIVATE KEY') { + $ecsd.ImportRSAPrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN ENCRYPTED PRIVATE KEY') { + if ($null -ne $SecurePassword) { + [int32]$bytesRead = 0 + $ecsd.ImportEncryptedPkcs8PrivateKey( (Convert-PodeSecureStringToByteArray -SecureString $SecurePassword), $keyBytes, [ref]$bytesRead) + } + } + } + + + $cert = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::CopyWithPrivateKey($cert, $ecsd) } + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12)) } - - $cert = [X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($cert, $rsa) - $cert = [X509Certificates.X509Certificate2]::new($cert.Export([X509Certificates.X509ContentType]::Pkcs12)) } - # for everything else, there's the openssl way else { $tempFile = Join-Path (Split-Path -Parent -Path $certPath) 'temp.pfx' try { + if ($null -ne $SecurePassword) { + $Password = Convert-PodeSecureStringToPlainText -SecureString $SecurePassword + } if ([string]::IsNullOrWhiteSpace($Password)) { $Password = [string]::Empty } @@ -235,7 +355,7 @@ function Get-PodeCertificateByPemFile { throw ($PodeLocale.failedToCreateOpenSslCertExceptionMessage -f $result) #"Failed to create openssl cert: $($result)" } - $cert = [X509Certificates.X509Certificate2]::new($tempFile, $Password) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($tempFile, $Password) } finally { $null = Remove-Item $tempFile -Force @@ -248,7 +368,7 @@ function Get-PodeCertificateByPemFile { function Find-PodeCertificateInCertStore { param( [Parameter(Mandatory = $true)] - [X509Certificates.X509FindType] + [System.Security.Cryptography.X509Certificates.X509FindType] $FindType, [Parameter(Mandatory = $true)] @@ -256,11 +376,11 @@ function Find-PodeCertificateInCertStore { $Query, [Parameter(Mandatory = $true)] - [X509Certificates.StoreName] + [System.Security.Cryptography.X509Certificates.StoreName] $StoreName, [Parameter(Mandatory = $true)] - [X509Certificates.StoreLocation] + [System.Security.Cryptography.X509Certificates.StoreLocation] $StoreLocation ) @@ -271,11 +391,11 @@ function Find-PodeCertificateInCertStore { } # open the currentuser\my store - $x509store = [X509Certificates.X509Store]::new($StoreName, $StoreLocation) + $x509store = [System.Security.Cryptography.X509Certificates.X509Store]::new($StoreName, $StoreLocation) try { # attempt to find the cert - $x509store.Open([X509Certificates.OpenFlags]::ReadOnly) + $x509store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) $x509certs = $x509store.Certificates.Find($FindType, $Query, $false) } finally { @@ -290,7 +410,7 @@ function Find-PodeCertificateInCertStore { throw ($PodeLocale.noCertificateFoundExceptionMessage -f $StoreLocation, $StoreName, $Query) # "No certificate could be found in $($StoreLocation)\$($StoreName) for '$($Query)'" } - return ([X509Certificates.X509Certificate2]($x509certs[0])) + return ([System.Security.Cryptography.X509Certificates.X509Certificate2]($x509certs[0])) } function Get-PodeCertificateByThumbprint { @@ -300,16 +420,16 @@ function Get-PodeCertificateByThumbprint { $Thumbprint, [Parameter(Mandatory = $true)] - [X509Certificates.StoreName] + [System.Security.Cryptography.X509Certificates.StoreName] $StoreName, [Parameter(Mandatory = $true)] - [X509Certificates.StoreLocation] + [System.Security.Cryptography.X509Certificates.StoreLocation] $StoreLocation ) return Find-PodeCertificateInCertStore ` - -FindType ([X509Certificates.X509FindType]::FindByThumbprint) ` + -FindType ([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint) ` -Query $Thumbprint ` -StoreName $StoreName ` -StoreLocation $StoreLocation @@ -322,23 +442,23 @@ function Get-PodeCertificateByName { $Name, [Parameter(Mandatory = $true)] - [X509Certificates.StoreName] + [System.Security.Cryptography.X509Certificates.StoreName] $StoreName, [Parameter(Mandatory = $true)] - [X509Certificates.StoreLocation] + [System.Security.Cryptography.X509Certificates.StoreLocation] $StoreLocation ) return Find-PodeCertificateInCertStore ` - -FindType ([X509Certificates.X509FindType]::FindBySubjectName) ` + -FindType ([System.Security.Cryptography.X509Certificates.X509FindType]::FindBySubjectName) ` -Query $Name ` -StoreName $StoreName ` -StoreLocation $StoreLocation } -function New-PodeSelfSignedCertificate { - $sanBuilder = [X509Certificates.SubjectAlternativeNameBuilder]::new() +function New-PodeSelfSignedCertificate2 { + $sanBuilder = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new() $null = $sanBuilder.AddIpAddress([ipaddress]::Loopback) $null = $sanBuilder.AddIpAddress([ipaddress]::IPv6Loopback) $null = $sanBuilder.AddDnsName('localhost') @@ -350,7 +470,7 @@ function New-PodeSelfSignedCertificate { $rsa = [RSA]::Create(2048) $distinguishedName = [X500DistinguishedName]::new('CN=localhost') - $req = [X509Certificates.CertificateRequest]::new( + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( $distinguishedName, $rsa, [HashAlgorithmName]::SHA256, @@ -358,13 +478,13 @@ function New-PodeSelfSignedCertificate { ) $flags = ( - [X509Certificates.X509KeyUsageFlags]::DataEncipherment -bor - [X509Certificates.X509KeyUsageFlags]::KeyEncipherment -bor - [X509Certificates.X509KeyUsageFlags]::DigitalSignature + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DataEncipherment -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature ) $null = $req.CertificateExtensions.Add( - [X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( $flags, $false ) @@ -374,7 +494,7 @@ function New-PodeSelfSignedCertificate { $null = $oid.Add([Oid]::new('1.3.6.1.5.5.7.3.1')) $req.CertificateExtensions.Add( - [X509Certificates.X509EnhancedKeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new( $oid, $false ) @@ -391,8 +511,8 @@ function New-PodeSelfSignedCertificate { $cert.FriendlyName = 'localhost' } - $cert = [X509Certificates.X509Certificate2]::new( - $cert.Export([X509Certificates.X509ContentType]::Pfx, 'self-signed'), + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, 'self-signed'), 'self-signed' ) diff --git a/src/Public/Authentication.ps1 b/src/Public/Authentication.ps1 index 4b729f0e6..b8c0ee9c4 100644 --- a/src/Public/Authentication.ps1 +++ b/src/Public/Authentication.ps1 @@ -368,54 +368,43 @@ function New-PodeAuthScheme { } 'digest' { - return @{ - Name = 'Digest' - Realm = (Protect-PodeValue -Value $Realm -Default $_realm) - ScriptBlock = @{ - Script = (Get-PodeAuthDigestType) - UsingVariables = $null - } - PostValidator = @{ - Script = (Get-PodeAuthDigestPostValidator) - UsingVariables = $null - } - Middleware = $Middleware - InnerScheme = $InnerScheme - Scheme = 'http' - Arguments = @{ - HeaderTag = (Protect-PodeValue -Value $HeaderTag -Default 'Digest') - } + # Display a deprecation warning for the old function. + # This ensures users are informed that the function is obsolete and should transition to the new function. + Write-PodeDeprecationWarning -OldFunction 'New-PodeAuthScheme -Digest' -NewFunction 'New-PodeAuthDigestScheme' + + $params = @{ + HeaderTag = $HeaderTag + Scope = $Scope } + return New-PodeAuthDigestScheme $params } 'bearer' { - $secretBytes = $null - if (![string]::IsNullOrWhiteSpace($Secret)) { - $secretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) + # Display a deprecation warning for the old function. + # This ensures users are informed that the function is obsolete and should transition to the new function. + Write-PodeDeprecationWarning -OldFunction 'New-PodeAuthScheme -Bearer' -NewFunction 'New-PodeAuthBearerScheme' + + $params = @{ + BearerTag = $HeaderTag + Scope = $Scope + AsJWT = $AsJWT } - return @{ - Name = 'Bearer' - Realm = (Protect-PodeValue -Value $Realm -Default $_realm) - ScriptBlock = @{ - Script = (Get-PodeAuthBearerType) - UsingVariables = $null - } - PostValidator = @{ - Script = (Get-PodeAuthBearerPostValidator) - UsingVariables = $null + if ($Secret) { + if ($Secret -isnot [SecureString]) { + if ( $Secret -is [string]) { + # Convert plain string to SecureString + $params['Secret'] = ConvertTo-SecureString -String $Secret -AsPlainText -Force + } + else { + throw + } } - Middleware = $Middleware - Scheme = 'http' - InnerScheme = $InnerScheme - Arguments = @{ - Description = $Description - HeaderTag = (Protect-PodeValue -Value $HeaderTag -Default 'Bearer') - Scopes = $Scope - AsJWT = $AsJWT - Secret = $secretBytes + else { + $params['Secret'] = $Secret } } + return New-PodeAuthBearerScheme @params } 'form' { @@ -508,9 +497,13 @@ function New-PodeAuthScheme { })[$Location] } - $secretBytes = $null - if (![string]::IsNullOrWhiteSpace($Secret)) { - $secretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) + if (! ([string]::IsNullOrEmpty($Secret))) { + $SecretString = ConvertTo-SecureString -String $Secret -AsPlainText -Force + $alg = @( 'HS256', 'HS384', 'HS512' ) + } + else { + $SecretString = $null + $alg = 'NONE' } return @{ @@ -529,7 +522,8 @@ function New-PodeAuthScheme { Location = $Location LocationName = $LocationName AsJWT = $AsJWT - Secret = $secretBytes + Secret = $SecretString + Algorithm = $alg } } } @@ -2210,217 +2204,7 @@ function Add-PodeAuthWindowsLocal { } } -<# -.SYNOPSIS -Convert a Header/Payload into a JWT. -.DESCRIPTION -Convert a Header/Payload hashtable into a JWT, with the option to sign it. - -.PARAMETER Header -A Hashtable containing the Header information for the JWT. - -.PARAMETER Payload -A Hashtable containing the Payload information for the JWT. - -.PARAMETER Secret -An Optional Secret for signing the JWT, should be a string or byte[]. This is mandatory if the Header algorithm isn't "none". - -.EXAMPLE -ConvertTo-PodeJwt -Header @{ alg = 'none' } -Payload @{ sub = '123'; name = 'John' } - -.EXAMPLE -ConvertTo-PodeJwt -Header @{ alg = 'hs256' } -Payload @{ sub = '123'; name = 'John' } -Secret 'abc' -#> -function ConvertTo-PodeJwt { - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory = $true)] - [hashtable] - $Header, - - [Parameter(Mandatory = $true)] - [hashtable] - $Payload, - - [Parameter()] - $Secret = $null - ) - - # validate header - if ([string]::IsNullOrWhiteSpace($Header.alg)) { - # No algorithm supplied in JWT Header - throw ($PodeLocale.noAlgorithmInJwtHeaderExceptionMessage) - } - - # convert the header - $header64 = ConvertTo-PodeBase64UrlValue -Value ($Header | ConvertTo-Json -Compress) - - # convert the payload - $payload64 = ConvertTo-PodeBase64UrlValue -Value ($Payload | ConvertTo-Json -Compress) - - # combine - $jwt = "$($header64).$($payload64)" - - # convert secret to bytes - if (($null -ne $Secret) -and ($Secret -isnot [byte[]])) { - $Secret = [System.Text.Encoding]::UTF8.GetBytes([string]$Secret) - } - - # make the signature - $sig = New-PodeJwtSignature -Algorithm $Header.alg -Token $jwt -SecretBytes $Secret - - # add the signature and return - $jwt += ".$($sig)" - return $jwt -} - -<# -.SYNOPSIS -Convert and return the payload of a JWT token. - -.DESCRIPTION -Convert and return the payload of a JWT token, verifying the signature by default with support to ignore the signature. - -.PARAMETER Token -The JWT token. - -.PARAMETER Secret -The Secret, as a string or byte[], to verify the token's signature. - -.PARAMETER IgnoreSignature -Skip signature verification, and return the decoded payload. - -.EXAMPLE -ConvertFrom-PodeJwt -Token "eyJ0eXAiOiJKV1QiLCJhbGciOiJoczI1NiJ9.eyJleHAiOjE2MjI1NTMyMTQsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMyJ9.LP-O8OKwix91a-SZwVK35gEClLZQmsORbW0un2Z4RkY" -#> -function ConvertFrom-PodeJwt { - [CmdletBinding(DefaultParameterSetName = 'Secret')] - [OutputType([pscustomobject])] - param( - [Parameter(Mandatory = $true)] - [string] - $Token, - - [Parameter(ParameterSetName = 'Signed')] - $Secret = $null, - - [Parameter(ParameterSetName = 'Ignore')] - [switch] - $IgnoreSignature - ) - - # get the parts - $parts = ($Token -isplit '\.') - - # check number of parts (should be 3) - if ($parts.Length -ne 3) { - # Invalid JWT supplied - throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) - } - - # convert to header - $header = ConvertFrom-PodeJwtBase64Value -Value $parts[0] - if ([string]::IsNullOrWhiteSpace($header.alg)) { - # Invalid JWT header algorithm supplied - throw ($PodeLocale.invalidJwtHeaderAlgorithmSuppliedExceptionMessage) - } - - # convert to payload - $payload = ConvertFrom-PodeJwtBase64Value -Value $parts[1] - - # get signature - if ($IgnoreSignature) { - return $payload - } - - $signature = $parts[2] - - # check "none" signature, and return payload if no signature - $isNoneAlg = ($header.alg -ieq 'none') - - if ([string]::IsNullOrWhiteSpace($signature) -and !$isNoneAlg) { - # No JWT signature supplied for {0} - throw ($PodeLocale.noJwtSignatureForAlgorithmExceptionMessage -f $header.alg) - } - - if (![string]::IsNullOrWhiteSpace($signature) -and $isNoneAlg) { - # Expected no JWT signature to be supplied - throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) - } - - if ($isNoneAlg -and ($null -ne $Secret) -and ($Secret.Length -gt 0)) { - # Expected no JWT signature to be supplied - throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) - } - - if ($isNoneAlg) { - return $payload - } - - # otherwise, we have an alg for the signature, so we need to validate it - if (($null -ne $Secret) -and ($Secret -isnot [byte[]])) { - $Secret = [System.Text.Encoding]::UTF8.GetBytes([string]$Secret) - } - - $sig = "$($parts[0]).$($parts[1])" - $sig = New-PodeJwtSignature -Algorithm $header.alg -Token $sig -SecretBytes $Secret - - if ($sig -ne $parts[2]) { - # Invalid JWT signature supplied - throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) - } - - # it's valid return the payload! - return $payload -} - -<# -.SYNOPSIS -Validates JSON Web Tokens (JWT) claims. - -.DESCRIPTION -Validates JSON Web Tokens (JWT) claims. Checks time related claims: 'exp' and 'nbf'. - -.PARAMETER Payload -Object containing JWT claims. Some of them are: - - exp (expiration time) - - nbf (not before) - -.EXAMPLE -Test-PodeJwt @{exp = 2696258821 } - -.EXAMPLE -Test-PodeJwt -Payload @{nbf = 1696258821 } -#> -function Test-PodeJwt { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)] - [pscustomobject] - $Payload - ) - - $now = [datetime]::UtcNow - $unixStart = [datetime]::new(1970, 1, 1, 0, 0, [DateTimeKind]::Utc) - - # validate expiry - if (![string]::IsNullOrWhiteSpace($Payload.exp)) { - if ($now -gt $unixStart.AddSeconds($Payload.exp)) { - # The JWT has expired - throw ($PodeLocale.jwtExpiredExceptionMessage) - } - } - - # validate not-before - if (![string]::IsNullOrWhiteSpace($Payload.nbf)) { - if ($now -lt $unixStart.AddSeconds($Payload.nbf)) { - # The JWT is not yet valid for use - throw ($PodeLocale.jwtNotYetValidExceptionMessage) - } - } -} <# .SYNOPSIS @@ -2734,4 +2518,340 @@ function New-PodeAuthKeyTab { ) ktpass /princ HTTP/$Hostname@$DomainName /mapuser $Username /pass $Password /out $FilePath /crypto $Crypto /ptype KRB5_NT_PRINCIPAL /mapop set +} + +<# +.SYNOPSIS + Creates a new Bearer authentication scheme for Pode. + +.DESCRIPTION + Defines a Bearer authentication scheme that allows authentication using a raw Bearer token or JWT. + Supports JWT validation with configurable security levels and token extraction from headers or query parameters. + +.PARAMETER BearerTag + The header tag used for the Bearer token (default: "Bearer"). + +.PARAMETER Location + Specifies the token extraction location: `Header` (default) or `Query`. + +.PARAMETER Scope + A list of required scopes for the authentication scheme. + +.PARAMETER Algorithm + Accepted JWT signing algorithms: HS256, HS384, HS512. + +.PARAMETER AsJWT + Indicates if the Bearer token should be treated and validated as a JWT. + +.PARAMETER Secret + The HMAC secret key for JWT validation (required for HS256, HS384, HS512). + +.PARAMETER Certificate + The path to a certificate that can be use to enable HTTPS + +.PARAMETER Certificate + The path to a certificate used for RSA or ECDSA verification. + +.PARAMETER CertificatePassword + The password for the certificate file referenced in Certificate + +.PARAMETER PrivateKeyPath + A key file to be paired with a PEM certificate file referenced in Certificate + +.PARAMETER CertificateThumbprint + A certificate thumbprint to use for RSA or ECDSA verification. (Windows). + +.PARAMETER CertificateName + A certificate subject name to use for RSA or ECDSA verification. (Windows). + +.PARAMETER CertificateStoreName + The name of a certifcate store where a certificate can be found (Default: My) (Windows). + +.PARAMETER CertificateStoreLocation + The location of a certifcate store where a certificate can be found (Default: CurrentUser) (Windows). + +.PARAMETER SelfSigned + Create and bind a self-signed CodeSigning ECSDA 384 Certificate. + +.PARAMETER X509Certificate + The raw X509 certificate used for RSA or ECDSA verification. + +.PARAMETER RsaPaddingScheme + RSA padding scheme: `Pkcs1V15` (default) or `Pss`. + +.PARAMETER JwtVerificationMode + JWT validation strictness: `Strict`, `Moderate`, or `Lenient` (default). + +.OUTPUTS + [hashtable] - Returns the Bearer authentication scheme configuration. + +.EXAMPLE + New-PodeAuthBearerScheme -AsJWT -Algorithm "HS256" -Secret (ConvertTo-SecureString "MySecretKey" -AsPlainText -Force) + +.EXAMPLE + New-PodeAuthBearerScheme -AsJWT -Algorithm "RS256" -PrivateKey (Get-Content "private.pem" -Raw) -PublicKey (Get-Content "public.pem" -Raw) +#> +function New-PodeAuthBearerScheme { + [CmdletBinding(DefaultParameterSetName = 'Basic')] + [OutputType([hashtable])] + param( + [string] + $BearerTag, + + [ValidateSet('Header', 'Query', 'Body')] + [string] + $Location = 'Header', + + [string[]] + $Scope, + + [switch] + $AsJWT, + + [Parameter(Mandatory = $false, ParameterSetName = 'Bearer_HS')] + [ValidateSet('HS256', 'HS384', 'HS512')] + [string[]] + $Algorithm = @(), + + [Parameter(Mandatory = $true, ParameterSetName = 'Bearer_HS')] + [SecureString] + $Secret, + + # Certificate-based parameters for RSA/ECDSA + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [X509Certificate] + $X509Certificate = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertSelf')] + [switch] + $SelfSigned, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertSelf')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + # Mode for verifying the JWT claims if AsJWT is used + [Parameter(Mandatory = $false, ParameterSetName = 'Bearer_HS')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertSelf')] + [ValidateSet('Strict', 'Moderate', 'Lenient')] + [string] + $JwtVerificationMode = 'Lenient' + ) + + # The default authentication realm + $_realm = 'User' + + # Convert any middleware to valid hashtables, if used in Pode + # (Assumes ConvertTo-PodeMiddleware is a function available in your codebase) + $Middleware = @(ConvertTo-PodeMiddleware -Middleware $Middleware -PSSession $PSCmdlet.SessionState) + + # Determine parameter set behavior for different JWT signing methods + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + # If using a file-based certificate, ensure it exists, then load it + if (!(Test-Path -Path $Certificate -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Certificate) + } + $X509Certificate = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath + break + } + + 'certthumb' { + # Retrieve a certificate from the local store by thumbprint + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'certname' { + # Retrieve a certificate from the local store by name + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'Bearer_HS' { + # If no algorithm is provided for HMAC, default to HS256 + $alg = if ($Algorithm.Count -eq 0) { + @('HS256') + } + else { + $Algorithm + } + break + } + + 'CertSelf' { + $X509Certificate = New-PodeSelfSignedCertificate -CommonName 'JWT Signing Certificate' ` + -KeyType ECDSA -KeyLength 384 -CertificatePurpose CodeSigning -Ephemeral + break + } + + 'Bearer_NONE' { + # Use the 'NONE' algorithm, meaning no signature check + $alg = @('NONE') + break + } + } + + # If an X509 certificate is being used, detect the signing algorithm + if ($null -ne $X509Certificate) { + + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (Code Signing). + # 4. Meets strict Enhanced Key Usage (EKU) enforcement. + $null = Test-PodeCertificate -Certificate $X509Certificate -ExpectedPurpose CodeSigning -Strict -ErrorAction Stop + } + + # Retrieve appropriate JWT algorithms (e.g., RS256, ES256) from the provided certificate + $alg = @( Get-PodeJwtSigningAlgorithm -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme ) + } + + # Return the Bearer authentication scheme configuration as a hashtable + # This hashtable is how Pode recognizes and initializes the scheme + return @{ + Name = 'Bearer' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthBearerType) + UsingVariables = $null + } + PostValidator = @{ + Script = (Get-PodeAuthBearerPostValidator) + UsingVariables = $null + } + Middleware = $Middleware + Scheme = 'http' + InnerScheme = $InnerScheme + Arguments = @{ + Description = $Description + BearerTag = (Protect-PodeValue -Value $BearerTag -Default 'Bearer') + Scopes = $Scope + AsJWT = $AsJWT + Secret = $Secret + Location = $Location + JwtVerificationMode = $JwtVerificationMode + Algorithm = $alg + X509Certificate = $X509Certificate + } + } +} + +<# +.SYNOPSIS + Creates a new Digest authentication scheme for Pode. + +.DESCRIPTION + This function defines a Digest authentication scheme in Pode. It allows specifying + parameters such as the authentication algorithm, quality of protection, and an optional + header tag. The function ensures secure authentication by leveraging Pode’s built-in + digest authentication mechanisms. + +.PARAMETER HeaderTag + An optional custom header tag for the authentication scheme. Defaults to 'Digest'. + +.PARAMETER Algorithm + Specifies the digest algorithm used for authentication. The default is 'MD5'. + Other supported values include 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', and 'SHA-512/256'. + +.PARAMETER QualityOfProtection + Determines the Quality of Protection (QoP) setting for authentication. The default is 'auth'. + Available options are 'auth', 'auth-int', and 'auth,auth-int'. + +.OUTPUTS + Hashtable containing the defined Digest authentication scheme for Pode. + +.EXAMPLE + New-PodeAuthDigestScheme -Algorithm 'SHA-256' -QualityOfProtection 'auth-int' + + This example creates a new Digest authentication scheme using SHA-256 and sets + the Quality of Protection to 'auth-int'. + +.NOTES + Internal function for Pode authentication schemes. Subject to change in future updates. +#> +function New-PodeAuthDigestScheme { + [CmdletBinding(DefaultParameterSetName = 'Basic')] + [OutputType([hashtable])] + param( + + [Parameter(ParameterSetName = 'Digest')] + [string] + $HeaderTag, + + [Parameter(ParameterSetName = 'Digest')] + [ValidateSet('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256')] + [string[]] + $Algorithm = 'MD5', + + [Parameter(ParameterSetName = 'Digest')] + [ValidateSet('auth', 'auth-int', 'auth,auth-int' )] + [string[]] + $QualityOfProtection = 'auth' + ) + # default realm + $_realm = 'User' + + # convert any middleware into valid hashtables + $Middleware = @(ConvertTo-PodeMiddleware -Middleware $Middleware -PSSession $PSCmdlet.SessionState) + + return @{ + Name = 'Digest' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthDigestType) + UsingVariables = $null + } + PostValidator = @{ + Script = (Get-PodeAuthDigestPostValidator) + UsingVariables = $null + } + Middleware = $Middleware + InnerScheme = $InnerScheme + Scheme = 'http' + Arguments = @{ + HeaderTag = (Protect-PodeValue -Value $HeaderTag -Default 'Digest') + Algorithm = $Algorithm + QualityOfProtection = $QualityOfProtection + } + } } \ No newline at end of file diff --git a/src/Public/Certificate.ps1 b/src/Public/Certificate.ps1 new file mode 100644 index 000000000..0a7964fb4 --- /dev/null +++ b/src/Public/Certificate.ps1 @@ -0,0 +1,1052 @@ +<# +.SYNOPSIS + Generates a certificate signing request (CSR) and private key. + +.DESCRIPTION + This function creates a certificate signing request (CSR) using RSA or ECDSA key pairs. + It allows specifying subject details, key usage, enhanced key usage (EKU), and custom extensions. + The CSR and private key are automatically saved to files in the specified output directory. + +.PARAMETER DnsName + One or more DNS names (or IP addresses) to be included in the Subject Alternative Name (SAN). + +.PARAMETER CommonName + The Common Name (CN) for the certificate subject. Defaults to the first DNS name if not provided. + +.PARAMETER Organization + The organization (O) name to be included in the certificate subject. + +.PARAMETER Locality + The locality (L) name to be included in the certificate subject. + +.PARAMETER State + The state (S) name to be included in the certificate subject. + +.PARAMETER Country + The country (C) code (ISO 3166-1 alpha-2). Defaults to 'XX'. + +.PARAMETER KeyType + The cryptographic key type for the certificate request. Supported values: 'RSA', 'ECDSA'. Defaults to 'RSA'. + +.PARAMETER KeyLength + The key length for RSA (2048, 3072, 4096) or ECDSA (256, 384, 521). Defaults to 2048. + +.PARAMETER CertificatePurpose + The intended purpose of the certificate, which automatically sets the EKU. + Supported values: 'ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom'. + +.PARAMETER EnhancedKeyUsages + A list of OID strings for Enhanced Key Usage (EKU) if 'Custom' is selected as CertificatePurpose. + +.PARAMETER NotBefore + The NotBefore date for the certificate request. Defaults to the current UTC time. + +.PARAMETER CustomExtensions + An array of additional custom certificate extensions. + +.PARAMETER FriendlyName + An optional friendly name for the certificate request. + +.PARAMETER OutputPath + The directory where the CSR and private key files will be saved. Defaults to the current working directory. + +.OUTPUTS + [PSCustomObject] + Returns an object containing: + - CsrPath: The file path of the CSR. + - PrivateKeyPath: The file path of the private key. + +.EXAMPLE + $csr = New-PodeCertificateRequest -DnsName "example.com" -CommonName "example.com" -KeyType "RSA" -KeyLength 2048 + Generates an RSA CSR for "example.com" and saves it to the current working directory. + +.EXAMPLE + $csr = New-PodeCertificateRequest -DnsName "example.com" -KeyType "ECDSA" -KeyLength 384 -CertificatePurpose "ServerAuth" -OutputPath "C:\Certs" + Generates an ECDSA CSR with an automatically assigned EKU for server authentication and saves it to "C:\Certs". + +.NOTES + - This function integrates with Pode’s certificate handling utilities. + - The private key is exported in PKCS#8 format. + - Ensure the private key is stored securely. +#> +function New-PodeCertificateRequest { + [CmdletBinding(DefaultParameterSetName = 'CommonName')] + [OutputType([PSCustomObject])] + param ( + # Required: one or more DNS names (or IP addresses) + [Parameter()] + [string[]] + $DnsName, + + # Subject parts + [Parameter()] + [string] + $CommonName, + + [Parameter()] + [string] + $Organization, + + [Parameter()] + [string] + $Locality, + + [Parameter()] + [string] + $State, + + [Parameter()] + [string] + $Country = 'XX', + + # Key type and size + [Parameter()] + [ValidateSet('RSA', 'ECDSA')] + [string]$KeyType = 'RSA', + + [Parameter()] + [ValidateSet(2048, 3072, 4096, 256, 384, 521)] + [int]$KeyLength = 2048, + + #Automatically set EKUs based on intended purpose + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom')] + [string] + $CertificatePurpose, + + # Enhanced Key Usages (EKU) - supply one or more OID strings if desired. + [Parameter()] + [string[]]$EnhancedKeyUsages, + + # Optional NotBefore date for the certificate request. + [Parameter()] + [DateTime]$NotBefore = ([datetime]::UtcNow), + + # Additional custom extensions (as an array of certificate extension objects). + [Parameter()] + [object[]]$CustomExtensions, + + # Optional friendly name for display in certificate stores. + [Parameter()] + [string]$FriendlyName, + + [Parameter()] + [string]$OutputPath = $PWD + ) + # Call the certificate request function to generate the CSR and key pair. + $csrParams = @{ + DnsName = $DnsName + CommonName = $CommonName + Organization = $Organization + Locality = $Locality + State = $State + Country = $Country + KeyType = $KeyType + KeyLength = $KeyLength + EnhancedKeyUsages = $EnhancedKeyUsages + NotBefore = $NotBefore + CustomExtensions = $CustomExtensions + FriendlyName = $FriendlyName + } + + # Define the encoding based on the powershell edition + $encoding = if ($PSVersionTable.PSEdition -eq 'Core') { + 'utf8NoBOM' + } + else { + 'utf8' + } + + $csrObject = New-PodeCertificateRequestInternal @csrParams + + + $csrPath = Join-Path -Path $OutputPath -ChildPath "$CommonName.csr" + $keyPath = Join-Path -Path $OutputPath -ChildPath "$CommonName.key" + + $csrObject.Request | Out-File -FilePath $csrPath -Encoding $encoding + $privateKeyBytes = $csrObject.PrivateKey.ExportPkcs8PrivateKey() + $privateKeyBase64 = [Convert]::ToBase64String($privateKeyBytes) + + "-----BEGIN PRIVATE KEY-----`n$privateKeyBase64`n-----END PRIVATE KEY-----" | Out-File -FilePath $keyPath -Encoding $encoding + + Write-Verbose "CSR saved to: $csrPath" + Write-Verbose "Private Key saved to: $keyPath" + + return [PSCustomObject]@{ + PsTypeName = 'PodeCertificateRequestResult' + CsrPath = $csrPath + PrivateKeyPath = $keyPath + } + +} + +<# +.SYNOPSIS + Generates a self-signed X.509 certificate. + +.DESCRIPTION + This function creates a self-signed X.509 certificate using RSA or ECDSA key pairs. + It supports specifying subject details, key usage, enhanced key usage (EKU), + and custom extensions. The generated certificate is returned as an X509Certificate2 object. + + By default, the private key is exportable so the certificate can be saved and reused. + If the `-Ephemeral` parameter is specified, the certificate's private key **will not be persisted** + and will only exist in memory for the current session. + +.PARAMETER DnsName + One or more DNS names (or IP addresses) to be included in the Subject Alternative Name (SAN). + +.PARAMETER Loopback + If specified, automatically sets `DnsName` to include: + - `127.0.0.1`, `::1`, `localhost` + - The current machine's IP (if not local) + - The Pode server's hostname and FQDN (if available) + +.PARAMETER CommonName + The Common Name (CN) for the certificate subject. Defaults to "SelfSigned". + +.PARAMETER Organization + The organization (O) name to be included in the certificate subject. + +.PARAMETER Locality + The locality (L) name to be included in the certificate subject. + +.PARAMETER State + The state (S) name to be included in the certificate subject. + +.PARAMETER Country + The country (C) code (ISO 3166-1 alpha-2). Defaults to 'XX'. + +.PARAMETER KeyType + The cryptographic key type for the certificate request. Supported values: 'RSA', 'ECDSA'. Defaults to 'RSA'. + +.PARAMETER KeyLength + The key length for RSA (2048, 3072, 4096) or ECDSA (256, 384, 521). Defaults to 2048. + +.PARAMETER EnhancedKeyUsages + A list of OID strings for Enhanced Key Usage (EKU), e.g., '1.3.6.1.5.5.7.3.1' for server authentication. + +.PARAMETER CertificatePurpose + The intended purpose of the certificate, which automatically sets the EKU. + Supported values: 'ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom'. + Defaults to 'ServerAuth'. + +.PARAMETER NotBefore + The NotBefore date for the certificate validity start. Defaults to the current UTC time. + +.PARAMETER CustomExtensions + An array of additional custom certificate extensions. + +.PARAMETER FriendlyName + A friendly name for the certificate, used when storing it in a certificate store. Defaults to 'MyCertificate'. + +.PARAMETER ValidityDays + The number of days the certificate will remain valid. Defaults to 365 days. + +.PARAMETER Ephemeral + If specified, the certificate will be created with `EphemeralKeySet`, meaning the private key + will **not be persisted** on disk or in the certificate store. + + This is useful for temporary certificates that should only exist in memory for the duration + of the current session. Once the process exits, the private key will be lost. + +.PARAMETER Password + Specifies an optional password for protecting the exported PFX. If not provided, the PFX will be unprotected. + +.PARAMETER Exportable + If specified the certificate will be created with `Exportable`, meaning the certificate can be exported + +.OUTPUTS + [System.Security.Cryptography.X509Certificates.X509Certificate2] + Returns the generated self-signed certificate as an X509Certificate2 object. + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -Loopback + Creates a self-signed certificate for local addresses (`127.0.0.1`, `::1`, `localhost`, machine hostname). + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -DnsName "example.com" -KeyType "RSA" -KeyLength 2048 + Creates a self-signed RSA certificate for "example.com" with a 2048-bit key, valid for 365 days. + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -DnsName "internal.local" -Ephemeral + Creates a self-signed certificate with a private key that exists **only in memory** for the current session. + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -DnsName "testserver.local" -KeyType "ECDSA" -KeyLength 384 -CertificatePurpose "ClientAuth" -ValidityDays 730 + Generates a self-signed ECDSA certificate for "testserver.local" with client authentication EKU, valid for 730 days. + +.NOTES + - The private key is embedded in the generated certificate. + - By default, the certificate is **exportable** so it can be saved and reused. + - If `-Ephemeral` is used, the private key will **only exist in memory** and cannot be exported or stored. + - The `-Loopback` parameter is useful for local development, ensuring the certificate includes local identifiers. +#> +function New-PodeSelfSignedCertificate { + [CmdletBinding(DefaultParameterSetName = 'CommonName')] + [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] + param ( + # Required: one or more DNS names (or IP addresses) + [Parameter(Mandatory = $false, ParameterSetName = 'DnsName')] + [string[]] + $DnsName, + + [Parameter(Mandatory = $false, ParameterSetName = 'DnsName')] + [switch] + $Loopback, + + # Subject parts + [Parameter(Mandatory = $false, ParameterSetName = 'CommonName')] + [string] + $CommonName = 'SelfSigned', + + [Parameter()] + [securestring] + $Password = $null, + + [Parameter()] + [string]$Organization, + [Parameter()] + [string]$Locality, + [Parameter()] + [string]$State, + [Parameter()] + [string]$Country = 'XX', + + # Key type and size + [Parameter()] + [ValidateSet('RSA', 'ECDSA')] + [string] + $KeyType = 'RSA', + + [Parameter()] + [ValidateSet(2048, 3072, 4096, 256, 384, 521)] + [int] + $KeyLength = 2048, + + # Enhanced Key Usages (EKU) - e.g., '1.3.6.1.5.5.7.3.1' for server auth + [Parameter()] + [string[]]$EnhancedKeyUsages, + + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom')] + [string] + $CertificatePurpose = 'ServerAuth', + + # Optional NotBefore date for certificate validity start + [Parameter()] + [DateTime] + $NotBefore, + + # Additional custom extensions (as an array of extension objects) + [Parameter()] + [object[]] + $CustomExtensions, + + # Friendly name for display in certificate stores + [Parameter()] + [string] + $FriendlyName = 'MyCertificate', + + # Validity period (in days) + [Parameter()] + [int] + $ValidityDays = 365, + + [Parameter()] + [switch] + $Ephemeral, + + [Parameter()] + [switch] + $Exportable + + ) + + # Handle Loopback Parameter + if ($Loopback) { + if ($null -eq $DnsName) { + $DnsName = @() + } + if ($DnsName -notcontains '127.0.0.1') { + $DnsName += '127.0.0.1' + } + if ($DnsName -notcontains '::1') { + $DnsName += '::1' + } + if ($DnsName -notcontains 'localhost') { + $DnsName += 'localhost' + } + + # Add machine-specific names if available + if ((![string]::IsNullOrWhiteSpace($PodeContext.Server.ComputerName) ) -and ($DnsName -notcontains $PodeContext.Server.ComputerName)) { + $DnsName += $PodeContext.Server.ComputerName + } + # Add machine-specific fqdn if available + if ((![string]::IsNullOrWhiteSpace($PodeContext.Server.Fqdn)) -and + ($PodeContext.Server.Fqdn -ne $PodeContext.Server.ComputerName) -and ($DnsName -notcontains $PodeContext.Server.Fqdn)) { + $DnsName += $PodeContext.Server.Fqdn + } + } + + # Call the certificate request function to generate the CSR and key pair. + $csrParams = @{ + DnsName = $DnsName + CommonName = $CommonName + Organization = $Organization + Locality = $Locality + State = $State + Country = $Country + KeyType = $KeyType + KeyLength = $KeyLength + CertificatePurpose = $CertificatePurpose + EnhancedKeyUsages = $EnhancedKeyUsages + CustomExtensions = $CustomExtensions + } + + $csrObject = New-PodeCertificateRequestInternal @csrParams + + # Determine certificate validity dates. + if ($null -eq $NotBefore) { $NotBefore = ([datetime]::UtcNow) } + $startDate = $NotBefore + $endDate = $NotBefore.AddDays($ValidityDays) + + try { + # Create the self-signed certificate from the CSR. + $cert = $csrObject.CertificateRequest.CreateSelfSigned( + [System.DateTimeOffset]::new($startDate), + [System.DateTimeOffset]::new($endDate) + ) + + # Set the friendly name if provided. + if (![string]::IsNullOrEmpty($FriendlyName) -and (Test-PodeIsWindows)) { + $cert.FriendlyName = $FriendlyName + } + + # Export the certificate as a PFX (with a default password; adjust as needed). + $pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx , $Password) + + if ($Ephemeral -and !$IsMacOS) { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + } + else { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::DefaultKeySet + } + + if ($Exportable) { + $storageFlags = $storageFlags -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable + } + $finalCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $pfxBytes, + $Password, + $storageFlags + ) + return $finalCert + } + catch { + $_ | Write-PodeErrorLog + throw + } +} + +<# +.SYNOPSIS + Imports an X.509 certificate from a file (PFX, PEM, CER) or retrieves it from the Windows certificate store. + +.DESCRIPTION + This function imports an X.509 certificate using one of three methods: + - From a certificate file (PFX, PEM, or CER). + - From the Windows certificate store by thumbprint. + - From the Windows certificate store by subject name. + + By default, the certificate is imported with an **ephemeral key**, meaning the private key + exists **only for the current session** and is not persisted. If the `-Persistent` flag is + specified, the private key will be stored in an exportable format. + +.PARAMETER Path + The path to the certificate file (.pfx, .pem, or .cer) to import. + +.PARAMETER PrivateKeyPath + The path to a separate private key file (for PEM format). + Required if the certificate file does not contain the private key. + +.PARAMETER CertificatePassword + A secure string containing the password for decrypting a PFX certificate + or an encrypted private key in PEM format. + +.PARAMETER Exportable + If specified, the certificate will be imported with an **exportable** private key, + allowing it to be saved and reused across sessions. + + If not specified, the certificate will be imported **ephemerally**, meaning the + private key will exist **only in memory** and will be lost when the process exits. + +.PARAMETER CertificateThumbprint + The thumbprint of a certificate stored in the Windows certificate store. + +.PARAMETER CertificateName + The subject name of a certificate stored in the Windows certificate store. + +.PARAMETER CertificateStoreName + The name of the Windows certificate store to search in when retrieving a certificate + by thumbprint or subject name. Defaults to "My". + +.PARAMETER CertificateStoreLocation + The location of the Windows certificate store. Defaults to "CurrentUser". + +.OUTPUTS + [System.Security.Cryptography.X509Certificates.X509Certificate2] + Returns the imported certificate as an X509Certificate2 object. + +.EXAMPLE + $cert = Import-PodeCertificate -Path "C:\Certs\mycert.pfx" -CertificatePassword (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Imports a PFX certificate file with an ephemeral private key. + +.EXAMPLE + $cert = Import-PodeCertificate -Path "C:\Certs\mycert.pfx" -CertificatePassword (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) -Persistent + Imports a PFX certificate file **with a persistent private key**, allowing it to be saved. + +.EXAMPLE + $cert = Import-PodeCertificate -Path "C:\Certs\mycert.cer" + Imports a CER certificate file (public key only). + +.EXAMPLE + $cert = Import-PodeCertificate -CertificateThumbprint "D2C2F4F7A456B69D4F9E9F8C3D3D6E5A9C3EBA6F" + Retrieves a certificate from the Windows certificate store using its thumbprint. + +.EXAMPLE + $cert = Import-PodeCertificate -CertificateName "MyAppCert" -CertificateStoreName "Root" -CertificateStoreLocation "LocalMachine" + Retrieves a certificate by subject name from the LocalMachine\Root store. + +.NOTES + - The `-Persistent` flag should be used when you need to store the certificate for future use. + - The default behavior (`EphemeralKeySet`) ensures the private key does not persist in the system. + - When using a PEM certificate, ensure the private key is available if required. + - Windows certificate store retrieval is only supported on Windows systems. + - CER files contain only the public key and do not support private key decryption. + - The improrted Certificate is not validated and returned as is. +#> +function Import-PodeCertificate { + param ( + # Certificate-based parameters for RSA/ECDSA + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Path, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter()] + [switch]$Exportable, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser' + ) + + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + # If using a file-based certificate, ensure it exists, then load it + if (!(Test-Path -Path $Path -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Path) + } + if (![string]::IsNullOrEmpty($PrivateKeyPath) -and !(Test-Path -Path $PrivateKeyPath -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $PrivateKeyPath) + } + $X509Certificate = Get-PodeCertificateByFile -Certificate $Path -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath -Exportable:$Exportable + break + } + + 'certthumb' { + # Retrieve a certificate from the local store by thumbprint + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'certname' { + # Retrieve a certificate from the local store by name + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + } + + return $X509Certificate +} + + +<# +.SYNOPSIS + Exports an X.509 certificate to a file (PFX, PEM, or CER) or installs it into the Windows certificate store. + +.DESCRIPTION + This function exports an X.509 certificate in various formats: + - PFX (PKCS#12) with optional password protection. + - PEM (Base64-encoded format), optionally including the private key. + - CER (DER-encoded format). + It also allows storing the certificate in the Windows certificate store. + + The function supports exporting private keys (if available) for PEM format, encrypting them if a password is provided. + +.PARAMETER Certificate + The X509Certificate2 object to export. This must be a valid certificate. + +.PARAMETER Path + The output file path (without an extension) where the certificate will be saved. + Defaults to the current working directory with the certificate subject name. + +.PARAMETER Format + The format in which to export the certificate. Supported values: 'PFX', 'PEM', 'CER'. + Defaults to 'PFX'. + +.PARAMETER CertificatePassword + A secure string containing the password for exporting the PFX format + or encrypting the private key in PEM format. + +.PARAMETER IncludePrivateKey + When exporting in PEM format, this flag includes the private key in a separate `.key` file. + +.PARAMETER CertificateStoreName + The Windows certificate store name where the certificate should be installed. + This parameter is required when using the 'WindowsStore' parameter set. + +.PARAMETER CertificateStoreLocation + The location of the Windows certificate store. Defaults to 'CurrentUser'. + This parameter is required when using the 'WindowsStore' parameter set. + +.OUTPUTS + [string] or [hashtable] + - If exporting to a file, returns the full file path(s) of the exported certificate. + - If storing in Windows, returns `$true` if successful, `$false` otherwise. + +.EXAMPLE + $cert = Get-PodeCertificate -Path "mycert.pfx" -Password (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Export-PodeCertificate -Certificate $cert -Path "C:\Certs\mycert" -Format "PEM" -IncludePrivateKey + + Exports the certificate as a PEM file with a separate private key file. + +.EXAMPLE + $cert = Get-PodeCertificate -Path "mycert.pfx" -Password (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Export-PodeCertificate -Certificate $cert -CertificateStoreName "My" -CertificateStoreLocation "LocalMachine" + + Stores the certificate in the LocalMachine certificate store under "My". + +.NOTES + - This function integrates with Pode’s certificate handling utilities. + - Windows store installation is only available on Windows. + - PEM format supports exporting the private key separately, which can be encrypted with a password. +#> +function Export-PodeCertificate { + [CmdletBinding(DefaultParameterSetName = 'File')] + param ( + # The X509 Certificate object to export + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [string] + $Path, + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [ValidateSet('PFX', 'PEM', 'CER')] + [string] + $Format = 'PFX', + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [switch]$IncludePrivateKey, + + [Parameter(Mandatory = $true, ParameterSetName = 'WindowsStore')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName, + + [Parameter(Mandatory = $true, ParameterSetName = 'WindowsStore')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser' + ) + + process { + + + switch ($PSCmdlet.ParameterSetName) { + 'File' { + if (Test-Path -Path $Path -PathType Container) { + # Extract CN (Common Name) from Subject (ensures it only grabs CN=XX) + if ($Certificate.Subject -match 'CN=([^,]+)') { + $baseName = $matches[1] + } + else { + $baseName = $Certificate.Thumbprint # Fallback to thumbprint + } + + # Replace invalid filename characters and normalize spaces + $baseName = $baseName -replace '[\\/:*?"<>|]', '_' -replace '\s+', '_' + + $filePath = Join-Path -Path $($PodeContext.Server.Root) -ChildPath $baseName + } + else { + $filePath = $Path + } + + switch ($Format) { + 'PFX' { + $pfxBytes = if ($CertificatePassword) { + $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $CertificatePassword) + } + else { + $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx) + } + + $filePathWithExt = [PSCustomObject]@{ CertificateFile = "$FilePath.pfx" } + [System.IO.File]::WriteAllBytes($filePathWithExt.CertificateFile, $pfxBytes) + break + } + 'CER' { + $cerBytes = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + + $filePathWithExt = [PSCustomObject]@{ CertificateFile = "$FilePath.cer" } + [System.IO.File]::WriteAllBytes($filePathWithExt.CertificateFile, $cerBytes) + break + } + 'PEM' { + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + # Export the certificate in PEM format + $pemCert = "-----BEGIN CERTIFICATE-----`n" + $pemCert += [Convert]::ToBase64String($Certificate.RawData, 'InsertLineBreaks') + $pemCert += "`n-----END CERTIFICATE-----" + $certFilePath = "$FilePath.pem" + $pemCert | Out-File -FilePath $certFilePath -Encoding utf8NoBOM + + Write-Verbose "Certificate exported successfully: $certFilePath" + + # If requested, export the private key to a separate file + if ($IncludePrivateKey -and $Certificate.HasPrivateKey) { + $pemKey = Export-PodePrivateKeyPem -Key $Certificate.PrivateKey -Password $CertificatePassword + $keyFilePath = "$FilePath.key" + $pemKey | Out-File -FilePath $keyFilePath -Encoding utf8NoBOM + + Write-Verbose "Private key exported successfully: $keyFilePath" + } + + # Return the certificate file path (and key file path if applicable) + $filePathWithExt = if ($IncludePrivateKey -and $Certificate.HasPrivateKey) { + [PSCustomObject]@{ CertificateFile = $certFilePath; PrivateKeyFile = $keyFilePath } + } + else { + [PSCustomObject]@{ CertificateFile = $certFilePath } + } + break + } + } + + if ($Format -ne 'PEM') { + Write-Verbose "Certificate exported successfully: $($filePathWithExt.CertificateFile)" + } + return $filePathWithExt + } + + 'WindowsStore' { + if (Test-PodeIsWindows) { + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new($CertificateStoreName, $CertificateStoreLocation) + $store.Open('ReadWrite') + $store.Add($Certificate) + $store.Close() + + Write-Verbose "Certificate successfully stored in: $CertificateStoreLocation\$CertificateStoreName" + return [PSCustomObject]@{CertificateStore = "$CertificateStoreLocation\$CertificateStoreName" } + } + return $null + } + } + } +} + +<# +.SYNOPSIS + Retrieves the Enhanced Key Usage (EKU) purposes of an X.509 certificate. + +.DESCRIPTION + This internal function extracts the Enhanced Key Usage (EKU) extension (OID: 2.5.29.37) + from an X.509 certificate and returns the recognized purposes. + + If the certificate has no EKU extension, an empty array is returned, indicating + that the certificate has no usage restrictions. + +.PARAMETER Certificate + The X509Certificate2 object from which to retrieve the EKU purposes. + +.OUTPUTS + [object[]] + Returns an array of recognized EKU purposes. Supported values: + - 'ServerAuth' (1.3.6.1.5.5.7.3.1) + - 'ClientAuth' (1.3.6.1.5.5.7.3.2) + - 'CodeSigning' (1.3.6.1.5.5.7.3.3) + - 'EmailSecurity' (1.3.6.1.5.5.7.3.4) + + If an unrecognized EKU OID is found, it is returned as `"Unknown ()"`. + If no EKU extension is present, an empty array is returned. + +.EXAMPLE + $purposes = Get-PodeCertificatePurpose -Certificate $cert + Retrieves the list of EKU purposes assigned to the given certificate. + +#> +function Get-PodeCertificatePurpose { + [CmdletBinding()] + [OutputType([object[]])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + process { + # Define known EKU OIDs and their purposes + $purposeOids = @{ + '1.3.6.1.5.5.7.3.1' = 'ServerAuth' + '1.3.6.1.5.5.7.3.2' = 'ClientAuth' + '1.3.6.1.5.5.7.3.3' = 'CodeSigning' + '1.3.6.1.5.5.7.3.4' = 'EmailSecurity' + } + + # Retrieve the EKU extension (OID: 2.5.29.37) + $ekuExtension = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } + + if ($ekuExtension -and $ekuExtension -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + # Use the EnhancedKeyUsages property which returns an OidCollection + $purposes = @() + foreach ($oid in $ekuExtension.EnhancedKeyUsages) { + if ($purposeOids.ContainsKey($oid.Value)) { + $purposes += $purposeOids[$oid.Value] + } + else { + $purposes += "Unknown ($($oid.Value))" + } + } + return $purposes + } + + # If no EKU is present, return an empty array (no restrictions) + return @() + } +} + +<# +.SYNOPSIS + Validates an X.509 certificate for both general validity and intended usage. + +.DESCRIPTION + This function performs comprehensive validation on an X.509 certificate. It checks: + - That the certificate’s validity period (NotBefore and NotAfter) is current. + - That the certificate chain is valid (including optional revocation checking). + - That the certificate meets security criteria (e.g. not using weak algorithms). + - Optionally, that the certificate’s Enhanced Key Usage (EKU) includes the expected purpose. + + New parameters: + - **ExpectedPurpose**: When provided, the function checks if the certificate’s EKU includes this purpose. + Valid values: ServerAuth, ClientAuth, CodeSigning, EmailSecurity. + - **Strict**: When used with ExpectedPurpose, if any unknown EKU is present, validation fails. + - **AllowWeakAlgorithms**: When specified, certificates using weak algorithms are allowed. + - **DenySelfSigned**: When specified, self-signed certificates are rejected. + + If any validation step fails, the function writes an error and returns `$false`. Otherwise, it returns `$true`. + +.PARAMETER Certificate + The X509Certificate2 object to validate. + +.PARAMETER CheckRevocation + A switch that enables revocation checking (online or offline). + +.PARAMETER OfflineRevocation + A switch that forces revocation checking to use only cached CRLs. + +.PARAMETER AllowWeakAlgorithms + A switch that, when provided, allows certificates with weak signature algorithms. + +.PARAMETER DenySelfSigned + A switch that, when provided, rejects self-signed certificates. + +.PARAMETER ExpectedPurpose + An optional string specifying the expected Enhanced Key Usage (EKU) for the certificate. + Valid values: ServerAuth, ClientAuth, CodeSigning, EmailSecurity. + - 'ServerAuth' (1.3.6.1.5.5.7.3.1) + - 'ClientAuth' (1.3.6.1.5.5.7.3.2) + - 'CodeSigning' (1.3.6.1.5.5.7.3.3) + - 'EmailSecurity' (1.3.6.1.5.5.7.3.4) + +.PARAMETER Strict + A switch that, when used with ExpectedPurpose, enforces that no unknown EKUs are present. + +.OUTPUTS + [boolean] Returns `$true` if the certificate passes all validation and restriction checks, otherwise `$false`. + +.EXAMPLE + Test-PodeCertificate -Certificate $cert + Performs basic validity and chain checks on the certificate. + +.EXAMPLE + Test-PodeCertificate -Certificate $cert -CheckRevocation + Also performs online revocation checking. + +.EXAMPLE + Test-PodeCertificate -Certificate $cert -ExpectedPurpose CodeSigning -Strict + Validates the certificate and ensures it is explicitly intended for CodeSigning. +#> +function Test-PodeCertificate { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter()] + [switch]$CheckRevocation, + + [Parameter()] + [switch]$OfflineRevocation, + + [Parameter()] + [switch]$AllowWeakAlgorithms, + + [Parameter()] + [switch]$DenySelfSigned, + + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity')] + [string]$ExpectedPurpose, + + [Parameter()] + [switch]$Strict + ) + process { + # Validate certificate validity period + $currentDate = [System.DateTime]::UtcNow + $notBefore = $Certificate.NotBefore.ToUniversalTime() + $notAfter = $Certificate.NotAfter.ToUniversalTime() + + if ($currentDate -lt $notBefore) { + Write-Error ($PodeLocale.certificateNotValidYetExceptionMessage -f $Certificate.Subject, $notBefore) + return $false + } + if ($currentDate -gt $notAfter) { + Write-Error ($PodeLocale.certificateExpiredExceptionMessage -f $Certificate.Subject, $notAfter) + return $false + } + Write-Verbose "Certificate $($Certificate.Subject) is within its valid period." + + # Option: Deny self-signed certificates if requested. + if ($DenySelfSigned -and ($Certificate.Subject -eq $Certificate.Issuer)) { + Write-Error $PodeLocale.selfSignedCertificatesNotAllowedExceptionMessage + return $false + } + + # For CA-issued certificates, check signature validity. + # Self-signed certificates: skip signature verification but log a message. + if ($Certificate.Subject -ne $Certificate.Issuer) { + if (! $Certificate.Verify()) { + Write-Error ($PodeLocale.certificateSignatureInvalidExceptionMessage -f $Certificate.Subject) + return $false + } + } + else { + Write-Verbose 'Self-signed certificate detected: skipping signature verification.' + } + + # Initialize the certificate chain. + $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() + + # For self-signed certificates, allow an unknown certificate authority and disable revocation checks. + if ($Certificate.Subject -eq $Certificate.Issuer) { + $chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority + $CheckRevocation = $false + Write-Verbose 'Self-signed certificate detected: revocation check disabled.' + } + + # Apply revocation policy. + if ($CheckRevocation) { + $chain.ChainPolicy.RevocationMode = if ($OfflineRevocation) { + [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Offline + } + else { + [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online + } + Write-Verbose "Revocation checking set to: $($chain.ChainPolicy.RevocationMode)" + } + else { + $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck + } + + # Build the certificate chain. + $isValidChain = $chain.Build($Certificate) + if (-not $isValidChain) { + foreach ($status in $chain.ChainStatus) { + if ($status.Status -eq [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::UntrustedRoot) { + Write-Error ($PodeLocale.certificateUntrustedRootExceptionMessage -f $Certificate.Subject) + return $false + } + if ($status.Status -eq [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::Revoked) { + Write-Error ($PodeLocale.certificateRevokedExceptionMessage -f $Certificate.Subject, $status.StatusInformation) + return $false + } + if ($status.Status -eq [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::NotTimeValid) { + Write-Error ($PodeLocale.certificateExpiredIntermediateExceptionMessage -f $Certificate.Subject) + return $false + } + } + Write-Error ($PodeLocale.certificateValidationFailedExceptionMessage -f $Certificate.Subject) + return $false + } + Write-Verbose 'Certificate chain validation successful.' + + # Check for weak algorithms unless weak ones are allowed. + if (-not $AllowWeakAlgorithms) { + $weakAlgorithms = @('md5RSA', 'sha1RSA', 'sha1ECDSA', 'RSA-1024') + if ($Certificate.SignatureAlgorithm.FriendlyName -in $weakAlgorithms) { + Write-Error ($PodeLocale.certificateWeakAlgorithmExceptionMessage -f $Certificate.Subject, $Certificate.SignatureAlgorithm.FriendlyName) + return $false + } + } + + # If an ExpectedPurpose is provided, check the certificate's EKU restrictions. + if ($ExpectedPurpose) { + # Retrieve the EKU values via a helper function. + $purposes = Get-PodeCertificatePurpose -Certificate $Certificate + if ($purposes.Count -eq 0 -and ! $Strict) { + Write-Verbose 'Certificate has no EKU restrictions; it can be used for any purpose.' + } + elseif ($ExpectedPurpose -notin $purposes) { + Write-Error ($PodeLocale.certificateNotValidForPurposeExceptionMessage -f $ExpectedPurpose, ($purposes -join ', ')) + return $false + } + if ($Strict -and ($purposes -match '^Unknown')) { + Write-Error ($PodeLocale.certificateUnknownEkusStrictModeExceptionMessage -f ($purposes -join ', ')) + return $false + } + Write-Verbose "Certificate is valid for the expected purpose '$ExpectedPurpose'. Found purposes: $($purposes -join ', ')" + } + + return $true + } +} \ No newline at end of file diff --git a/src/Public/Core.ps1 b/src/Public/Core.ps1 index 1c013f539..c8bd2b49a 100644 --- a/src/Public/Core.ps1 +++ b/src/Public/Core.ps1 @@ -85,6 +85,9 @@ .PARAMETER Daemon Configures the server to run as a daemon with minimal console interaction and output. +.PARAMETER ApplicationName + Specifies the name of the Pode application. If not provided, the default is the script's file name (excluding the extension). + .EXAMPLE Start-PodeServer { /* server logic */ } Starts a Pode server using the supplied script block. @@ -208,7 +211,10 @@ function Start-PodeServer { [Parameter(Mandatory = $true, ParameterSetName = 'FileDaemon')] [Parameter(Mandatory = $true, ParameterSetName = 'ScriptDaemon')] [switch] - $Daemon + $Daemon, + + [string] + $ApplicationName ) begin { @@ -224,8 +230,13 @@ function Start-PodeServer { throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) } # Store the name of the current runspace $previousRunspaceName = Get-PodeCurrentRunspaceName + + if ([string]::IsNullOrEmpty($ApplicationName)) { + $ApplicationName = (Get-PodeApplicationName) + } + # Sets the name of the current runspace - Set-PodeCurrentRunspaceName -Name 'PodeServer' + Set-PodeCurrentRunspaceName -Name $ApplicationName # ensure the session is clean $Script:PodeContext = $null @@ -268,6 +279,7 @@ function Start-PodeServer { EnableBreakpoints = $EnableBreakpoints IgnoreServerConfig = $IgnoreServerConfig ConfigFile = $ConfigFile + ApplicationName = $ApplicationName } diff --git a/src/Public/Endpoint.ps1 b/src/Public/Endpoint.ps1 index d2d9fa523..e0a1eeaee 100644 --- a/src/Public/Endpoint.ps1 +++ b/src/Public/Endpoint.ps1 @@ -57,7 +57,7 @@ A quick description of the Endpoint - normally used in OpenAPI. An optional Acknowledge message to send to clients when they first connect, for TCP and SMTP endpoints only. .PARAMETER SslProtocol -One or more optional SSL Protocols this endpoint supports. (Default: SSL3/TLS12 - Just TLS12 on MacOS). +One or more optional SSL Protocols this endpoints supports. (Default: what the OS support by default). .PARAMETER CRLFMessageEnd If supplied, TCP endpoints will expect incoming data to end with CRLF. @@ -67,6 +67,7 @@ Ignore Administrator checks for non-localhost endpoints. .PARAMETER SelfSigned Create and bind a self-signed certificate for HTTPS endpoints. +Create and bind a self-signed certificate for HTTPS endpoints. .PARAMETER AllowClientCertificate Allow for client certificates to be sent on requests. @@ -131,7 +132,7 @@ function Add-PodeEndpoint { $Certificate = $null, [Parameter(ParameterSetName = 'CertFile')] - [string] + [object] $CertificatePassword = $null, [Parameter(ParameterSetName = 'CertFile')] @@ -412,7 +413,15 @@ function Add-PodeEndpoint { switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { 'certfile' { - $obj.Certificate.Raw = Get-PodeCertificateByFile -Certificate $Certificate -Password $CertificatePassword -Key $CertificateKey + if ($CertificatePassword -is [string]) { + $securePassword = ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force + } + elseif ($CertificatePassword -is [securestring]) { $securePassword = $CertificatePassword }else { + # 'Error: Invalid type for {0}. Expected {1}, but received [{2}]. + throw ($PodeLocale.invalidTypeExceptionMessage -f '-CertificatePassword', '[string] or [SecureString]', $CertificatePassword.GetType().Name) + } + + $obj.Certificate.Raw = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $securePassword -PrivateKeyPath $CertificateKey } 'certthumb' { @@ -424,14 +433,17 @@ function Add-PodeEndpoint { } 'certself' { - $obj.Certificate.Raw = New-PodeSelfSignedCertificate + $obj.Certificate.Raw = New-PodeSelfSignedCertificate -Loopback -CertificatePurpose ServerAuth -DnsName $obj.address.ToString() } } - # fail if the cert is expired - if ($obj.Certificate.Raw.NotAfter -lt [datetime]::Now) { - # The certificate has expired - throw ($PodeLocale.certificateExpiredExceptionMessage -f $obj.Certificate.Raw.Subject, $obj.Certificate.Raw.NotAfter) + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (ServerAuth). + $null = Test-PodeCertificate -Certificate $obj.Certificate.Raw -ExpectedPurpose ServerAuth -ErrorAction Stop } } diff --git a/src/Public/Jwt.ps1 b/src/Public/Jwt.ps1 new file mode 100644 index 000000000..4fb71b3e8 --- /dev/null +++ b/src/Public/Jwt.ps1 @@ -0,0 +1,979 @@ +using namespace System.Security.Cryptography +<# +.SYNOPSIS + Validates a JWT payload by checking its registered claims as defined in RFC 7519. + +.DESCRIPTION + This function verifies the validity of a JWT payload by ensuring: + - The `exp` (Expiration Time) has not passed. + - The `nbf` (Not Before) time is not in the future. + - The `iat` (Issued At) time is not in the future. + - The `iss` (Issuer) claim is valid based on the verification mode. + - The `sub` (Subject) claim is a valid string. + - The `aud` (Audience) claim is valid based on the verification mode. + - The `jti` (JWT ID) claim is a valid string. + +.PARAMETER Payload + The JWT payload as a [pscustomobject] containing registered claims such as `exp`, `nbf`, `iat`, `iss`, `sub`, `aud`, and `jti`. + +.PARAMETER Issuer + The expected JWT Issuer. If omitted, uses 'Pode'. + +.PARAMETER JwtVerificationMode + Defines how aggressively JWT claims should be checked: + - `Strict`: Requires all standard claims to be valid (`exp`, `nbf`, `iat`, `iss`, `aud`, `jti`). + - `Moderate`: Allows missing `iss` and `aud` but still checks expiration. + - `Lenient`: Ignores missing `iss` and `aud`, only verifies `exp`, `nbf`, and `iat`. + +.EXAMPLE + $payload = [pscustomobject]@{ + iss = "auth.example.com" + sub = "1234567890" + aud = "myapi.example.com" + exp = 1700000000 + nbf = 1690000000 + iat = 1690000000 + jti = "unique-token-id" + } + + Test-PodeJwt -Payload $payload -JwtVerificationMode "Strict" + + This example validates a JWT payload with full claim verification. +#> +function Test-PodeJwt { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] + $Payload, + + [Parameter()] + [string] + $Issuer = 'Pode', + + [Parameter()] + [ValidateSet('Strict', 'Moderate', 'Lenient')] + [string] + $JwtVerificationMode = 'Lenient' + ) + + # Get the current Unix timestamp for time-based checks + $currentUnix = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + + # Validate Expiration (`exp`) - applies to all verification modes + if ($Payload.exp) { + $expUnix = [long]$Payload.exp + if ($currentUnix -ge $expUnix) { + throw ($PodeLocale.jwtExpiredExceptionMessage) + } + } + + # Validate Not Before (`nbf`) - applies to all verification modes + if ($Payload.nbf) { + $nbfUnix = [long]$Payload.nbf + if ($currentUnix -lt $nbfUnix) { + throw ($PodeLocale.jwtNotYetValidExceptionMessage) + } + } + + # Validate Issued At (`iat`) - applies to all verification modes + if ($Payload.iat) { + $iatUnix = [long]$Payload.iat + if ($iatUnix -gt $currentUnix) { + throw ($PodeLocale.jwtIssuedInFutureExceptionMessage) + } + } + + # Validate Issuer (`iss`) if mode is Strict or Moderate + if ($JwtVerificationMode -eq 'Strict' -or $JwtVerificationMode -eq 'Moderate') { + if ($Payload.iss) { + # Check that the Issuer is a valid string and matches the expected Issuer + if (!$Payload.iss -or $Payload.iss -isnot [string] -or $Payload.iss -ne $Issuer) { + throw ($PodeLocale.jwtInvalidIssuerExceptionMessage -f $Issuer) + } + } + elseif ($JwtVerificationMode -eq 'Strict') { + # If the claim is missing in Strict mode, throw an error + throw ($PodeLocale.jwtMissingIssuerExceptionMessage) + } + } + + # Validate Audience (`aud`) if mode is Strict or Moderate + if ($JwtVerificationMode -eq 'Strict' -or $JwtVerificationMode -eq 'Moderate') { + if ($Payload.aud) { + # Ensure `aud` is either a string or an array of strings + if (!$Payload.aud -or ($Payload.aud -isnot [string] -and $Payload.aud -isnot [array])) { + throw ($PodeLocale.jwtInvalidAudienceExceptionMessage -f $PodeContext.Server.ApplicationName) + } + + # In Pode, check the application's name against `aud` + if ($Payload.aud -is [string]) { + if ($Payload.aud -ne $PodeContext.Server.ApplicationName) { + throw ($PodeLocale.jwtInvalidAudienceExceptionMessage -f $PodeContext.Server.ApplicationName) + } + } + elseif ($Payload.aud -is [array]) { + if ($Payload.aud -notcontains $PodeContext.Server.ApplicationName) { + throw ($PodeLocale.jwtInvalidAudienceExceptionMessage -f $PodeContext.Server.ApplicationName) + } + } + } + elseif ($JwtVerificationMode -eq 'Strict') { + # If `aud` is missing in Strict mode, throw an error + throw ($PodeLocale.jwtMissingAudienceExceptionMessage) + } + } + + # Validate Subject (`sub`) - applies to all verification modes + if ($Payload.sub) { + if (!$Payload.sub -or $Payload.sub -isnot [string]) { + throw ($PodeLocale.jwtInvalidSubjectExceptionMessage) + } + } + + # Validate JWT ID (`jti`) - only in Strict mode + if ($JwtVerificationMode -eq 'Strict') { + if ($Payload.jti) { + # Check that `jti` is a valid string + if (!$Payload.jti -or $Payload.jti -isnot [string]) { + throw ($PodeLocale.jwtInvalidJtiExceptionMessage) + } + } + else { + # `jti` must exist in Strict mode + throw ($PodeLocale.jwtMissingJtiExceptionMessage) + } + } +} + +<# +.SYNOPSIS + Converts a JWT token into a PowerShell object, optionally verifying its signature. + +.DESCRIPTION + The ConvertFrom-PodeJwt function takes a JWT token and decodes its header, payload, + and signature. By default, it verifies the signature using a specified secret, + certificate, or Pode authentication method. If IgnoreSignature is specified, + the function decodes and returns the token payload without verification. + +.PARAMETER Token + The JWT token to be decoded and optionally verified. + +.PARAMETER IgnoreSignature + Indicates that the JWT token signature should be ignored + and the payload returned directly without verification. + +.PARAMETER Outputs + Determines which parts of the JWT should be returned: + Header, Payload, Signature, or any combination thereof. Defaults to 'Payload'. + +.PARAMETER HumanReadable + Converts UNIX timestamps (e.g., iat, nbf, exp) into DateTime objects for easier reading. + +.PARAMETER Secret + A string or byte array used for HMAC-based signature verification. + +.PARAMETER Certificate + The path to a file containing an X.509 certificate for RSA/ECDSA signature verification. + +.PARAMETER PrivateKeyPath + The path to a PEM key file that pairs with the certificate + for RSA/ECDSA signature verification. + +.PARAMETER CertificatePassword + A SecureString containing a password for the certificate file, if required. + +.PARAMETER CertificateThumbprint + A thumbprint to retrieve a certificate from the Windows certificate store. + +.PARAMETER CertificateName + A subject name to retrieve a certificate from the Windows certificate store. + +.PARAMETER CertificateStoreName + The name of the Windows certificate store to search (default: My). + +.PARAMETER CertificateStoreLocation + The location of the Windows certificate store to search (default: CurrentUser). + +.PARAMETER X509Certificate + A raw X.509 certificate object used for RSA/ECDSA signature verification. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use (Pkcs1V15 or Pss). + Defaults to Pkcs1V15. + +.PARAMETER Authentication + A Pode authentication method name whose configuration is used + for signature verification. + +.OUTPUTS + [pscustomobject] or [System.Collections.Specialized.OrderedDictionary]. + Returns one or more parts of the JWT (Header, Payload, Signature) + as PowerShell objects or dictionaries. + +.EXAMPLE + ConvertFrom-PodeJwt -Token $jwtToken -Secret 'mysecret' + Decodes and verifies the JWT token using an HMAC secret. + +.EXAMPLE + ConvertFrom-PodeJwt -Token $jwtToken -Certificate './certs/myCert.pem' + Decodes and verifies the JWT token using an X.509 certificate from a file. + +.NOTES + - This function is tailored for use with Pode, a PowerShell web server framework. + - When signature verification is enabled, the appropriate key or certificate must be provided. + - Use HTTPS in production to safeguard tokens. +#> + +function ConvertFrom-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([pscustomobject])] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $true, ParameterSetName = 'Ignore')] + [Parameter(Mandatory = $false, ParameterSetName = 'AuthenticationMethod')] + [string] + $Token, + + [Parameter(Mandatory = $false, ParameterSetName = 'Default')] + [Parameter(ParameterSetName = 'Ignore')] + [switch] + $IgnoreSignature, + + [ValidateSet('Header', 'Payload', 'Signature', 'Header,Payload', 'Header,Signature', 'Payload,Signature', 'Header,Payload,Signature')] + [string] + $Outputs = 'Payload', + + [switch] + $HumanReadable, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [object] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Certificate, + + [Parameter(ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication + ) + + # Identify which parameter set was chosen at runtime + $parameterSetName = $PSCmdlet.ParameterSetName + + # If set to 'Default', but a WebEvent context has an authentication name, switch to 'AuthenticationMethod' + if ($parameterSetName -eq 'Default') { + if ($null -ne $WebEvent -and $null -ne $WebEvent.Auth.Name) { + $parameterSetName = 'AuthenticationMethod' + $Authentication = $WebEvent.Auth.Name + } + } + + # Prepare a hashtable for parameters required for validation (e.g., certificate, secret, etc.) + # We'll populate it in the following switch statement. + $params = @{} + + # Depending on the chosen parameter set, load/prepare the resources for signature validation. + switch ($parameterSetName) { + 'CertFile' { + if (!(Test-Path -Path $Certificate -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Certificate) + } + $X509Certificate = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath + } + 'CertThumb' { + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + 'CertName' { + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + 'Secret' { + if ($null -ne $Secret) { + if ($Secret -is [string]) { + $params = @{ Secret = ConvertTo-SecureString -String $Secret -AsPlainText -Force } + } + elseif ($Secret -is [byte[]]) { + $params = @{ Secret = [System.Text.Encoding]::UTF8.GetString($Secret) } + } + else { + $params = @{ Secret = $Secret } + } + } + else { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Header['alg']) + } + } + 'CertRaw' { + if ($null -eq $X509Certificate) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'private', 'RSA/ECSDA', $Header['alg']) + } + } + 'AuthenticationMethod' { + # Validate that the specified authentication method exists in the current Pode context + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $token = Get-PodeBearenToken + $authArgs = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments + if ($null -ne $authArgs.X509Certificate) { + $X509Certificate = $authArgs.X509Certificate + } + if ($null -ne $method.Secret) { + $params['Secret'] = $method.Secret + } + } + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) + } + } + 'Ignore' { + # If ignoring signature, no additional data is needed. + } + } + + if ($X509Certificate) { + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (Code Signing). + # 4. Meets strict Enhanced Key Usage (EKU) enforcement. + $null = Test-PodeCertificate -Certificate $X509Certificate -ExpectedPurpose CodeSigning -Strict -ErrorAction Stop + } + + $params['X509Certificate'] = $X509Certificate + } + + $params['Token'] = $Token + + $parts = ($Token -split '\.') + # Verify that the token has exactly three parts + if ($parts.Length -ne 3) { + throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) + } + + # Decode the header; this should contain the algorithm type (alg) + $header = ConvertFrom-PodeJwtBase64Value -Value $parts[0] + if ([string]::IsNullOrWhiteSpace($header.alg)) { + throw ($PodeLocale.invalidJwtHeaderAlgorithmSuppliedExceptionMessage) + } + + # Decode the payload; contains claims like sub, exp, iat, etc. + $payload = ConvertFrom-PodeJwtBase64Value -Value $parts[1] + + # Retrieve the signature part + $signature = $parts[2] + + # If ignoring the signature, return the payload immediately + if (! $IgnoreSignature) { + + + + # Some JWTs may specify "none" as the algorithm (no signature) + $isNoneAlg = ($header.alg -ieq 'none') + + # If signature is missing but an algorithm is expected, throw an error + if ([string]::IsNullOrWhiteSpace($signature) -and !$isNoneAlg) { + throw ($PodeLocale.noJwtSignatureForAlgorithmExceptionMessage -f $header.alg) + } + + # If "none" is indicated but a signature was supplied, throw an error + if (![string]::IsNullOrWhiteSpace($signature) -and $isNoneAlg) { + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + # If alg is "none" but a secret was provided, throw an error + if ($isNoneAlg -and ($null -ne $Secret) -and ($Secret.Length -gt 0)) { + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + # If "none" signature, return the payload since there's nothing to verify + if ($isNoneAlg) { + return $payload + } + + # At this point, we have a valid signature with a known algorithm + $params['Algorithm'] = $header.alg + + # Confirm-PodeJwt will finalize verification based on the algorithm and parameters + $null = Confirm-PodeJwt @params + } + + if ($HumanReadable) { + if ($payload.iat) { + $payload.iat = [System.DateTimeOffset]::FromUnixTimeSeconds($payload.iat).UtcDateTime + } + if ($payload.nbf) { + $payload.nbf = [System.DateTimeOffset]::FromUnixTimeSeconds($payload.nbf).UtcDateTime + } + if ($payload.exp) { + $payload.exp = [System.DateTimeOffset]::FromUnixTimeSeconds($payload.exp).UtcDateTime + } + } + + switch ($Outputs) { + 'Header' { + return $header + } + 'Payload' { + return $payload + } + 'Signature' { + return $signature + } + 'Header,Payload' { + return [ordered]@{Header = $header; Payload = $payload } + } + 'Header,Signature' { + return [ordered]@{Header = $header; Signature = $signature } + } + 'Payload,Signature' { + return [ordered]@{Payload = $payload; Signature = $signature } + } + 'Header,Payload,Signature' { + return [ordered]@{Header = $header; Payload = $payload; Signature = $signature } + } + default { + return $payload + } + } +} + +<# +.SYNOPSIS + Generates a JSON Web Token (JWT) based on the specified headers, payload, and signing credentials. +.DESCRIPTION + This function creates a JWT by combining a Base64URL-encoded header and payload. Depending on the + configured parameters, it supports various signing algorithms, including HMAC- and certificate-based + signatures. You can also omit a signature by specifying 'none'. + +.PARAMETER Header + Additional header values for the JWT. Defaults to an empty hashtable if not specified. + +.PARAMETER Payload + The required hashtable specifying the token’s claims. + +.PARAMETER Algorithm + A string representing the signing algorithm to be used. Accepts 'NONE', 'HS256', 'HS384', or 'HS512'. + +.PARAMETER Secret + Used in conjunction with HMAC signing. Can be either a byte array or a SecureString. Required if you + select the 'Secret' parameter set. + +.PARAMETER X509Certificate + An X509Certificate2 object used for RSA/ECDSA-based signing. Required if you select the 'CertRaw' parameter set. + +.PARAMETER Certificate + The path to a certificate file used for signing. Required if you select the 'CertFile' parameter set. + +.PARAMETER PrivateKeyPath + Optional path to an associated certificate key file. + +.PARAMETER CertificatePassword + An optional SecureString password for a certificate file. + +.PARAMETER CertificateThumbprint + A string thumbprint of a certificate in the local store. Required if you select the 'CertThumb' parameter set. + +.PARAMETER CertificateName + A string name of a certificate in the local store. Required if you select the 'CertName' parameter set. + +.PARAMETER CertificateStoreName + The store name to search for the specified certificate. Defaults to 'My'. + +.PARAMETER CertificateStoreLocation + The certificate store location for the specified certificate. Defaults to 'CurrentUser'. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use. Accepts 'Pkcs1V15' or 'Pss'. Defaults to 'Pkcs1V15'. + +.PARAMETER Authentication + The name of a configured authentication method in Pode. Required if you select the 'AuthenticationMethod' parameter set. + +.PARAMETER Expiration + Time in seconds until the token expires. Defaults to 3600 (1 hour). + +.PARAMETER NotBefore + Time in seconds to offset the NotBefore claim. Defaults to 0 for immediate use. + +.PARAMETER IssuedAt + Time in seconds to offset the IssuedAt claim. Defaults to 0 for current time. + +.PARAMETER Issuer + Identifies the principal that issued the token. + +.PARAMETER Subject + Identifies the principal that is the subject of the token. + +.PARAMETER Audience + Specifies the recipients that the token is intended for. + +.PARAMETER JwtId + A unique identifier for the token. + +.PARAMETER NoStandardClaims + A switch that, if used, prevents automatically adding iat, nbf, exp, iss, sub, aud, and jti claims. + +.OUTPUTS + System.String + The resulting JWT string. + + +.EXAMPLE + ConvertTo-PodeJwt -Header @{ alg = 'none' } -Payload @{ sub = '123'; name = 'John' } + +.EXAMPLE + ConvertTo-PodeJwt -Header @{ alg = 'HS256' } -Payload @{ sub = '123'; name = 'John' } -Secret 'abc' + +.EXAMPLE + ConvertTo-PodeJwt -Header @{ alg = 'RS256' } -Payload @{ sub = '123' } -PrivateKey (Get-Content "private.pem" -Raw) -Issuer "auth.example.com" -Audience "myapi.example.com" +#> +function ConvertTo-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([string])] + param( + [Parameter()] + [hashtable]$Header = @{}, + + [Parameter(Mandatory = $true)] + [hashtable]$Payload, + + [Parameter(ParameterSetName = 'Default')] + [Parameter(ParameterSetName = 'Secret')] + [ValidateSet('NONE', 'HS256', 'HS384', 'HS512')] + [string]$Algorithm, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string]$Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string]$PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString]$CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string]$CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string]$CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication, + + [Parameter()] + [int] + $Expiration = 3600, # Default: 1 hour + + [Parameter()] + [int] + $NotBefore = 0, # Default: Immediate + + [Parameter()] + [int]$IssuedAt = 0, # Default: Current time + + [Parameter()] + [string]$Issuer, + + [Parameter()] + [string]$Subject, + + [Parameter()] + [string]$Audience, + + [Parameter()] + [string]$JwtId, + + [Parameter()] + [switch] + $NoStandardClaims + ) + + $psHeader = [PSCustomObject]$Header + $psPayload = [PSCustomObject]$Payload + # Optionally add standard claims if not suppressed + if (!$NoStandardClaims) { + if (! $psHeader.PSObject.Properties['typ']) { + $psHeader | Add-Member -MemberType NoteProperty -Name 'typ' -Value 'JWT' + } + else { + $psHeader.typ = 'JWT' + } + + # Current Unix time + $currentUnix = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + + if (! $psPayload.PSObject.Properties['iat']) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'iat' -Value $(if ($IssuedAt -gt 0) { $IssuedAt } else { $currentUnix }) + } + if (! $psPayload.PSObject.Properties['nbf']) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'nbf' -Value ($currentUnix + $NotBefore) + } + if (! $psPayload.PSObject.Properties['exp']) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'exp' -Value ($currentUnix + $Expiration) + } + + if (! $psPayload.PSObject.Properties['iss']) { + if ([string]::IsNullOrEmpty($Issuer)) { + if ($null -ne $PodeContext) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'iss' -Value 'Pode' + } + } + else { + $psPayload | Add-Member -MemberType NoteProperty -Name 'iss' -Value $Issuer + } + } + + if (! $psPayload.PSObject.Properties['sub'] -and ![string]::IsNullOrEmpty($Subject)) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'sub' -Value $Subject + } + + if (! $psPayload.PSObject.Properties['aud']) { + if ([string]::IsNullOrEmpty($Audience)) { + if (($null -ne $PodeContext) -and ($null -ne $PodeContext.Server.ApplicationName)) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $PodeContext.Server.ApplicationName + } + } + else { + $psPayload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $Audience + } + } + + if (! $psPayload.PSObject.Properties['jti'] ) { + if ([string]::IsNullOrEmpty($JwtId)) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'jti' -Value (New-PodeGuid) + } + else { + $psPayload | Add-Member -MemberType NoteProperty -Name 'jti' -Value $JwtId + } + } + } + # Determine actions based on parameter set + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + return New-PodeJwt -Certificate $Certificate -CertificatePassword $CertificatePassword ` + -PrivateKeyPath $PrivateKeyPath -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'certthumb' { + return New-PodeJwt -CertificateThumbprint $CertificateThumbprint -CertificateStoreName $CertificateStoreName ` + -CertificateStoreLocation $CertificateStoreLocation -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'certname' { + return New-PodeJwt -CertificateName $CertificateName -CertificateStoreName $CertificateStoreName ` + -CertificateStoreLocation $CertificateStoreLocation -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'Secret' { + # Convert secret to a byte array if needed + if (($null -ne $Secret) -and ($Secret -isnot [byte[]])) { + $Secret = if ($Secret -is [SecureString]) { + Convert-PodeSecureStringToByteArray -SecureString $Secret + } + else { + [System.Text.Encoding]::UTF8.GetBytes([string]$Secret) + } + + if ($null -eq $Secret) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $psHeader['alg']) + } + } + + if ([string]::IsNullOrWhiteSpace($Algorithm)) { + $Algorithm = 'HS256' + } + + return New-PodeJwt -Secret $Secret -Algorithm $Algorithm ` + -Payload $psPayload -Header $psHeader + } + + 'CertRaw' { + return New-PodeJwt -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'AuthenticationMethod' { + return New-PodeJwt -Authentication $Authentication ` + -Payload $psPayload -Header $psHeader + } + default { + return New-PodeJwt -Algorithm 'None' ` + -Payload $psPayload -Header $psHeader + } + } +} + +<# +.SYNOPSIS + Updates the expiration time of a JWT token. + +.DESCRIPTION + This function updates the expiration time of a given JWT token by extending it with a specified duration. + It supports various signing methods including secret-based and certificate-based signing. + The function can handle different types of certificates and authentication methods for signing the updated token. + +.PARAMETER Token + The JWT token to be updated. + +.PARAMETER ExpirationExtension + The number of seconds to extend the expiration time by. If not specified, the original expiration duration is used. + +.PARAMETER Secret + The secret key used for HMAC signing (string or byte array). + +.PARAMETER X509Certificate + The raw X509 certificate used for RSA or ECDSA signing. + +.PARAMETER Certificate + The path to a certificate file used for signing. + +.PARAMETER CertificatePassword + The password for the certificate file referenced in Certificate. + +.PARAMETER PrivateKeyPath + A key file to be paired with a PEM certificate file referenced in Certificate. + +.PARAMETER CertificateThumbprint + A certificate thumbprint to use for RSA or ECDSA signing. (Windows). + +.PARAMETER CertificateName + A certificate subject name to use for RSA or ECDSA signing. (Windows). + +.PARAMETER CertificateStoreName + The name of a certificate store where a certificate can be found (Default: My) (Windows). + +.PARAMETER CertificateStoreLocation + The location of a certificate store where a certificate can be found (Default: CurrentUser) (Windows). + +.PARAMETER Authentication + The authentication method from Pode's context used for JWT signing. + +.EXAMPLE + Update-PodeJwt -Token "" -ExpirationExtension 3600 -Secret "MySecretKey" + This example updates the expiration time of a JWT token by extending it by 1 hour using an HMAC secret. + +.EXAMPLE + Update-PodeJwt -Token "" -ExpirationExtension 3600 -X509Certificate $Certificate + This example updates the expiration time of a JWT token by extending it by 1 hour using an X509 certificate. +#> +function Update-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + param ( + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Token, + + [Parameter()] + [int] + $ExpirationExtension = 0, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string]$Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string]$PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString]$CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string]$CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string]$CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication + ) + + $parameterSetName = $PSCmdlet.ParameterSetName + + if ($parameterSetName -eq 'Default') { + if ($null -ne $WebEvent -and $null -ne $WebEvent.Auth.Name) { + $parameterSetName = 'AuthenticationMethod' + $Authentication = $WebEvent.Auth.Name + } + } + + if ($parameterSetName -eq 'AuthenticationMethod') { + $token = Get-PodeBearenToken + } + + $jwt = ConvertFrom-PodeJwt -Token $Token -IgnoreSignature -Outputs 'Header,Payload' + if ($null -eq $jwt.Payload.exp) { + throw ($PodeLocale.jwtNoExpirationExceptionMessage) + } + + if ($ExpirationExtension -eq 0 -and $jwt.Payload.exp -and $jwt.Payload.iat) { + $ExpirationExtension = $jwt.Payload.exp - $jwt.Payload.iat + } + # if the token has an expiration time, update it + if ($ExpirationExtension -gt 0) { + $jwt.Payload.exp = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + $ExpirationExtension + } + + if ('PS256', 'PS384', 'PS512' -ccontains $jwt.Header.alg) { + $rsaPaddingScheme = 'Pss' + } + else { + $rsaPaddingScheme = 'Pkcs1V15' + } + + $params = switch ($parameterSetName) { + # If the secret is provided as a byte array, use it for signing + 'CertFile' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + Certificate = $Certificate + PrivateKeyPath = $PrivateKeyPath + CertificatePassword = $CertificatePassword + RsaPaddingScheme = $rsaPaddingScheme + } + } + # If the certificate thumbprint is provided, use it for signing + 'CertThumb' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + CertificateThumbprint = $CertificateThumbprint + CertificateStoreName = $CertificateStoreName + CertificateStoreLocation = $CertificateStoreLocation + RsaPaddingScheme = $rsaPaddingScheme + } + } + # If the certificate name is provided, use it for signing + 'CertName' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + CertificateName = $CertificateName + CertificateStoreName = $CertificateStoreName + CertificateStoreLocation = $CertificateStoreLocation + RsaPaddingScheme = $rsaPaddingScheme + } + } + # If the secret is provided as a byte array, use it for signing + 'Secret' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + Secret = $Secret + } + } + # If the certificate is provided as a raw object, use it for signing + 'CertRaw' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + X509Certificate = $X509Certificate + } + } + 'AuthenticationMethod' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + Authentication = $Authentication + } + } + } + + # Update the JWT with the new expiration time + return New-PodeJwt @params +} \ No newline at end of file diff --git a/src/Public/Routes.ps1 b/src/Public/Routes.ps1 index 575fede7f..d6c2101d8 100644 --- a/src/Public/Routes.ps1 +++ b/src/Public/Routes.ps1 @@ -344,6 +344,10 @@ function Add-PodeRoute { throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage -f $Authentication) } + # Validate that the HTTP method supports a request body when using bearer token authentication. + # This ensures that only PUT, POST, and PATCH methods are used for body-based authentication. + Test-PodeBodyAuthMethod -Method $Method -Authentication $Authentication + $options = @{ Name = $Authentication Login = $Login @@ -828,6 +832,10 @@ function Add-PodeStaticRoute { throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) } + # Validate that the HTTP method supports a request body when using bearer token authentication. + # This ensures that only PUT, POST, and PATCH methods are used for body-based authentication. + Test-PodeBodyAuthMethod -Method $Method -Authentication $Authentication + $options = @{ Name = $Authentication Anon = $AllowAnon diff --git a/tests/integration/Authentication.Tests.ps1 b/tests/integration/Authentication.Tests.ps1 index 67f4147c3..ad4a3a2b4 100644 --- a/tests/integration/Authentication.Tests.ps1 +++ b/tests/integration/Authentication.Tests.ps1 @@ -146,8 +146,8 @@ Describe 'Authentication Requests' { { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer" -Method Get -Headers @{ Authorization = 'Bearer fake-token' } -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' } - It 'bearer - returns 400 for no token' { - { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer" -Method Get -Headers @{ Authorization = 'Bearer' } -ErrorAction Stop } | Should -Throw -ExpectedMessage '*400*' + It 'bearer - returns 401 for no token' { + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer" -Method Get -Headers @{ Authorization = 'Bearer' } -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' } diff --git a/tests/integration/DigestAuthentication.Tests.ps1 b/tests/integration/DigestAuthentication.Tests.ps1 new file mode 100644 index 000000000..6b2db38ca --- /dev/null +++ b/tests/integration/DigestAuthentication.Tests.ps1 @@ -0,0 +1,444 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/src/' + $CertsPath = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/tests/certs/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + + # load assemblies + Add-Type -AssemblyName System.Web -ErrorAction Stop + Add-Type -AssemblyName System.Net.Http -ErrorAction Stop + + $module = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/examples/Authentication/Modules' + Import-Module "$module/Invoke-Digest.psm1" + + function ConvertTo-Hash { + param ( + [string]$Value, + [string]$Algorithm + ) + + $crypto = switch ($Algorithm) { + 'MD5' { [System.Security.Cryptography.MD5]::Create() } + 'SHA-1' { [System.Security.Cryptography.SHA1]::Create() } + 'SHA-256' { [System.Security.Cryptography.SHA256]::Create() } + 'SHA-384' { [System.Security.Cryptography.SHA384]::Create() } + 'SHA-512' { [System.Security.Cryptography.SHA512]::Create() } + 'SHA-512/256' { + # Compute SHA-512 and take first 32 bytes (256 bits) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $fullHash = $sha512.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value)) + return [System.BitConverter]::ToString($fullHash[0..31]).Replace('-', '').ToLowerInvariant() + } + } + + return [System.BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))).Replace('-', '').ToLowerInvariant() + } + + function ChallengeDigest { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Connect', 'Delete', 'Get', 'Head', 'Merge', 'Options', 'Patch', 'Post', 'Put', 'Trace' )] + [string]$Method, + + [Parameter(Mandatory = $true)] + [string]$Uri + + ) + # Create an HTTP client + $handler = [System.Net.Http.HttpClientHandler]::new() + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + # Step 1: Send an initial request to get the challenge + $initialRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$Method, $Uri) + $initialResponse = $httpClient.SendAsync($initialRequest).Result + if ($null -eq $initialResponse) { + Throw "Server $uri is not responding" + } + + # Extract WWW-Authenticate headers safely + $wwwAuthHeaders = $initialResponse.Headers.GetValues('WWW-Authenticate') + + # Filter to get only the Digest authentication scheme + $wwwAuthHeader = $wwwAuthHeaders | Where-Object { $_ -match '^Digest' } + + # Debug output + Write-Verbose 'Extracted WWW-Authenticate headers:' + $wwwAuthHeaders | ForEach-Object { Write-Verbose " - $_" } + + # Ensure we have a Digest header before continuing + if (! $wwwAuthHeader) { + Throw 'Digest authentication not supported by server!' + } + + ## Extract Digest Authentication challenge values correctly + $challenge = @{} + + # Ensure the header contains "Digest" + if ($wwwAuthHeader -match '^Digest ') { + # Remove "Digest " prefix + $headerContent = $wwwAuthHeader -replace '^Digest ', '' + + Write-Verbose "RAW HEADER: $headerContent" + + # 1) CAPTURE + if ($headerContent -match 'algorithm=((?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*)') { + + $algorithms = ($matches[1] -split '\s*,\s*') + Write-Verbose "Supported Algorithms: $algorithms" + $challenge['algorithm'] = $algorithms + } + + # 2) REMOVE + $headerContent = $headerContent -replace 'algorithm=(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*\s*,?', '' + + # 3) CLEAN UP ANY EXTRA COMMAS/WHITESPACE + $headerContent = $headerContent -replace ',\s*,', ',' + $headerContent = $headerContent -replace '^\s*,', '' + + # Now split the rest of the parameters safely + $headerContent -split ', ' | ForEach-Object { + $key, $value = $_ -split '=', 2 + if ($key -and $value) { + $challenge[$key.Trim()] = $value.Trim('"') + } + } + } + + # Output the parsed challenge + Write-Verbose 'Extracted Digest Authentication Challenge:' + $challenge | ForEach-Object { Write-Verbose "$($_.Key) = $($_.Value)" } + + # Display parsed challenge values + Write-Verbose $challenge + + # Extract necessary parameters from the challenge + + $realm = $challenge['realm'] + $nonce = $challenge['nonce'] + $qop = $challenge['qop'] + $algorithm = $challenge['algorithm'] + + # Ensure qop is an array + # $qopOptions = $qop -split '\s*,\s*' + + if (('Post', 'Put', 'Patch') -contains $Method) { + if ($qop -eq 'auth-int' -or $qop -eq 'auth,auth-int') { + $qop = 'auth-int' + } + else { + $qop = 'auth' + } + } + else { + if ($qop -eq 'auth' -or $qop -eq 'auth,auth-int') { + $qop = 'auth' + } + else { + throw "$Method doesn't support QualityOfProtection 'auth-int'" + } + } + + Write-Verbose "Selected QOP: $qop" + + # Define the preferred algorithm order (strongest to weakest) + $preferredAlgorithms = @('SHA-512/256', 'SHA-512', 'SHA-384', 'SHA-256', 'SHA-1', 'MD5') + + # Ensure serverAlgorithms is an array + if ($algorithm -isnot [System.Array]) { + $algorithm = @($algorithm) + } + + # Select the strongest algorithm that both client and server support + $algorithm = ($preferredAlgorithms | Where-Object { $algorithm -contains $_ } | Select-Object -First 1) + + if (-not $algorithm) { + Throw "No supported algorithms found! Server supports: $algorithm" + } + return [PSCustomObject]@{ + realm = $realm + nonce = $nonce + qop = $qop + algorithm = $algorithm + wwwAuthHeader = $wwwAuthHeader + uri = $Uri + httpClient = $httpClient + method = $Method + } + } + + + function ResponseDigest { + param( + [Parameter(Mandatory = $true)] + [psobject]$Challenge, + + [Parameter(Mandatory = $true)] + [string]$Username, + [Parameter(Mandatory = $true)] + [string]$Password, + [hashtable]$Body + + ) + $nc = '00000001' # Nonce Count + $cnonce = (New-Guid).Guid.Substring(0, 8) # Generate a random client nonce + + + $Method = $Challenge.Method.ToUpper() + + Write-Verbose "Using method: $method" + + # Build the URI path + $uriPath = [System.Uri]$Challenge.uri + $uriPath = $uriPath.AbsolutePath # "/users" + + # Compute HA1 + $HA1 = ConvertTo-Hash -Value "$($Username):$($Challenge.realm):$($Password)" -Algorithm $Challenge.algorithm + + # <--- MODIFIED: Handle HA2 for auth-int + if ($Challenge.qop -eq 'auth-int') { + if (('Post', 'Put', 'Patch') -notcontains $Method) { + Throw "'auth-int' doens't support $Method" + } + # Sample request body + $requestBody = $Body | ConvertTo-Json + $entityBodyHash = ConvertTo-Hash -Value $requestBody -Algorithm $Challenge.algorithm + $HA2 = ConvertTo-Hash -Value "$($method):$($uriPath):$($entityBodyHash)" -Algorithm $Challenge.algorithm + + } + else { + # Standard auth + $HA2 = ConvertTo-Hash -Value "$($method):$($uriPath)" -Algorithm $Challenge.algorithm + } + + # Compute final response hash + $response = ConvertTo-Hash -Value "$($HA1):$($Challenge.nonce):$($nc):$($cnonce):$($Challenge.qop):$($HA2)" -Algorithm $Challenge.algorithm + + + + # Step 3: Construct the Authorization header + $authHeader = @" +Digest username="$username", realm="$($Challenge.realm)", nonce="$($Challenge.nonce)", uri="$uriPath", algorithm=$($Challenge.algorithm), response="$response", qop="$($Challenge.qop)", nc=$nc, cnonce="$cnonce" +"@ + + Write-Verbose "Authorization Header: $authHeader" + + # Step 4: Send the authenticated request + $authRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$method , $Challenge.uri) + $authRequest.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Digest', $authHeader) + + # <--- MODIFIED: If auth-int, attach the request body + if ($Challenge.qop -eq 'auth-int') { + $authRequest.Content = [System.Net.Http.StringContent]::new($requestBody, [System.Text.Encoding]::UTF8, 'application/json') + } + + $response = $Challenge.httpClient.SendAsync($authRequest).Result + + # Optionally, get content as string if needed + $content = $response.Content.ReadAsStringAsync().Result + + return [PSCustomObject]@{ + # Extract and display the response headers + Header = $response.Headers | ForEach-Object { "$($_.Key): $($_.Value)" } + Content = $content + AuthHeader = $authHeader + } + } + + +} + +Describe 'Digest Authentication Requests' { + + BeforeAll { + + $Port = 8080 + $Endpoint = "http://127.0.0.1:$($Port)" + + Start-Job -Name 'Pode' -ErrorAction Stop -ScriptBlock { + Import-Module -Name "$($using:PSScriptRoot)\..\..\src\Pode.psm1" + + Start-PodeServer -Quiet -ScriptBlock { + Add-PodeEndpoint -Address localhost -Port $using:Port -Protocol Http + + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + Add-PodeRoute -Method Get -Path '/close' -ScriptBlock { + Close-PodeServer + } + + foreach ($alg in ('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256')) { + foreach ($qop in ('auth', 'auth-int', 'auth,auth-int' )) { + + New-PodeAuthDigestScheme -Algorithm $alg -QualityOfProtection $qop | Add-PodeAuth -Name "digest_$($alg)_$qop" -Sessionless -ScriptBlock { + param($username, $params) + + # here you'd check a real user storage, this is just for example + if ($username -ieq 'morty') { + return @{ + User = @{ + ID = 'M0R7Y302' + Name = 'Morty' + Type = 'Human' + } + Password = 'pickle' + } + } + + return $null + } + + # If QualityOfProtection is 'auth-int' skip GET because it is not supported + if ($qop -ne 'auth-int') { + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path "/auth/$alg/$qop" -Authentication "digest_$($alg)_$qop" -ErrorContentType 'application/json' -ScriptBlock { + Write-PodeJsonResponse -Value @{ + success = $true + } + } + } + + Add-PodeRoute -Method Post -Path "/auth/$alg/$qop" -Authentication "digest_$($alg)_$qop" -ErrorContentType 'application/json' -ScriptBlock { + if ($WebEvent.data) { + Write-PodeJsonResponse -Value @{success = $true } -StatusCode 200 + } + else { + Write-PodeJsonResponse -Value @{success = $false } -StatusCode 400 + } + } + } + } + } + } + Start-Sleep -Seconds 10 + + } + + AfterAll { + + Receive-Job -Name 'Pode' | Out-Default + Invoke-RestMethod -Uri "$($Endpoint)/close" -Method Get | Out-Null + Get-Job -Name 'Pode' | Remove-Job -Force + } + + + + Describe 'Digest Authentication' { + + Context 'Digest - Algorithm <_> - Path /auth/<_>' -ForEach ('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256') { + BeforeDiscovery { + $alg_qop = @() + ForEach ($qop in 'auth', 'auth-int', 'auth,auth-int') { + $alg_qop += @{ + qop = $qop + algorithm = $_ + } + } + } + It 'Digest - Method Get - Algorithm: - QOP:' -ForEach $alg_qop { + + #Write-PodeHost "Testing Algorithm: $algorithm with QOP: $qop" + if ($qop -eq 'auth-int') { + { ChallengeDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get } | Should -Throw + } + else { + $challenge = ChallengeDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get + + # Validate challenge structure + $challenge | Should -Not -BeNullOrEmpty + $challenge | Should -BeOfType 'PSCustomObject' + + $challenge.realm | Should -Be 'User' + $qop.contains( $challenge.qop) | Should -BeTrue + $challenge.algorithm | Should -Be $algorithm + + # Check that nonce matches a hex pattern (example pattern for 32 hex characters) + $challenge.nonce | Should -Match '^[0-9a-f]{32}$' + + # Check that wwwAuthHeader contains the expected error info + $challenge.wwwAuthHeader | Should -Not -BeNullOrEmpty + $challenge.wwwAuthHeader | Should -Match 'error="invalid_request"' + $challenge.wwwAuthHeader | Should -Match 'error_description="No Authorization header found"' + + + + $response = ResponseDigest -Challenge $challenge -Username 'morty' -Password 'pickle' + # Validate challenge structure + $response | Should -Not -BeNullOrEmpty + $response | Should -BeOfType 'PSCustomObject' + $response.Content | Should -Be '{"success":true}' + + } + } + It 'Digest - Method Post - Algorithm: - QOP:' -ForEach $alg_qop { + + $challenge = ChallengeDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Post + + # Validate challenge structure + $challenge | Should -Not -BeNullOrEmpty + $challenge | Should -BeOfType 'PSCustomObject' + + $challenge.realm | Should -Be 'User' + $qop.contains( $challenge.qop) | Should -BeTrue + $challenge.algorithm | Should -Be $algorithm + + # Check that nonce matches a hex pattern (example pattern for 32 hex characters) + $challenge.nonce | Should -Match '^[0-9a-f]{32}$' + + # Check that wwwAuthHeader contains the expected error info + $challenge.wwwAuthHeader | Should -Not -BeNullOrEmpty + $challenge.wwwAuthHeader | Should -Match 'error="invalid_request"' + $challenge.wwwAuthHeader | Should -Match 'error_description="No Authorization header found"' + + $response = ResponseDigest -Challenge $challenge -Username 'morty' -Password 'pickle' -body @{message = 'test message' } + # Validate challenge structure + $response | Should -Not -BeNullOrEmpty + $response | Should -BeOfType 'PSCustomObject' + $response.Content | Should -Be '{"success":true}' + + } + + } + + Context 'Invoke-Digest module - Algorithm <_> - Path /auth/<_>' -ForEach ('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256') -Tag 'Exclude_DesktopEdition' { + BeforeDiscovery { + $alg_qop = @() + ForEach ($qop in 'auth', 'auth-int', 'auth,auth-int') { + $alg_qop += @{ + qop = $qop + algorithm = $_ + } + } + } + BeforeAll { + $username = 'morty' + $password = 'pickle' + + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + } + + It 'Digest - Method Get - Algorithm: - QOP:' -ForEach $alg_qop { + + #Write-PodeHost "Testing Algorithm: $algorithm with QOP: $qop" + if ($qop -eq 'auth-int') { + { Invoke-RestMethodDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get -Credential $credential } | Should -Throw + } + else { + $response = Invoke-RestMethodDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get -Credential $credential + # Validate challenge structure + $response | Should -Not -BeNullOrEmpty + $response | Should -BeOfType 'PSCustomObject' + $response.success | Should -BeTrue + + } + } + } + } +} + + + + + diff --git a/tests/integration/JWTAuthentication.Tests.ps1 b/tests/integration/JWTAuthentication.Tests.ps1 new file mode 100644 index 000000000..2094a493b --- /dev/null +++ b/tests/integration/JWTAuthentication.Tests.ps1 @@ -0,0 +1,600 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/src/' + $CertsPath = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/tests/certs/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } +} + +Describe 'JWT Bearer Authentication Requests' { + + BeforeAll { + $Port = 8080 + $Endpoint = "http://127.0.0.1:$($Port)" + $secret = (ConvertTo-SecureString 'MySecretKey' -AsPlainText -Force) + $applicationName = 'JWTAuthentication' + + Start-Job -Name 'Pode' -ErrorAction Stop -ScriptBlock { + Import-Module -Name "$($using:PSScriptRoot)\..\..\src\Pode.psm1" + + Start-PodeServer -Quiet -ApplicationName $using:applicationName -ScriptBlock { + Add-PodeEndpoint -Address localhost -Port $using:Port -Protocol Http + + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + Add-PodeRoute -Method Get -Path '/close' -ScriptBlock { + Close-PodeServer + } + + foreach ($alg in ('HS256', 'HS384', 'HS512')) { + New-PodeAuthBearerScheme -AsJWT -Secret $using:secret -Algorithm $alg -JwtVerificationMode Strict | Add-PodeAuth -Name "Bearer_JWT_Secret_strict_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/secret/strict/$alg" -Authentication "Bearer_JWT_Secret_strict_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + + New-PodeAuthBearerScheme -AsJWT -Secret $using:secret -Algorithm $alg -JwtVerificationMode Lenient | Add-PodeAuth -Name "Bearer_JWT_Secret_lenient_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/secret/lenient/$alg" -Authentication "Bearer_JWT_Secret_lenient_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + } + if (!(Test-Path -Path $using:CertsPath -PathType Container)) { + New-Item -Path $using:CertsPath -ItemType Directory + } + + # $securePassword = ConvertTo-SecureString 'MySecurePassword' -AsPlainText -Force + + $certificateTypes = @{ + 'RS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pkcs1V15' + } + 'PS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pss' + } + 'PS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pss' + } + 'PS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pss' + } + 'ES256' = @{ + KeyType = 'ECDSA' + KeyLength = 256 + } + 'ES384' = @{ + KeyType = 'ECDSA' + KeyLength = 384 + } + 'ES512' = @{ + KeyType = 'ECDSA' + KeyLength = 521 + } + } + foreach ($alg in $certificateTypes.Keys) { + $x509Certificate = New-PodeSelfSignedCertificate -Loopback -KeyType $certificateTypes[$alg].KeyType -KeyLength $certificateTypes[$alg].KeyLength -CertificatePurpose CodeSigning -Ephemeral -Exportable + + Export-PodeCertificate -Certificate $x509Certificate -Format PFX -Path (join-path -path $using:CertsPath -ChildPath $alg) | Out-File -FilePath "$using:CertsPath/a.txt" -Append + + $rsaPaddingScheme = if ($alg.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + + + # Define the authentication location dynamically (e.g., `/auth/bearer/jwt/{algorithm}`) + $pathRoute = "/auth/bearer/jwt/key/lenient/$alg" + # Register Pode Bearer Authentication + $param = @{ + AsJWT = $true + RsaPaddingScheme = $rsaPaddingScheme + JwtVerificationMode = 'Lenient' + X509Certificate = $x509Certificate + # CertificatePassword = $securePassword + } + + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_lenient_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/key/lenient/$alg" -Authentication "Bearer_JWT_lenient_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + + $param.JwtVerificationMode = 'Strict' + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_strict_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/key/strict/$alg" -Authentication "Bearer_JWT_strict_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + } + + + #lifecycle + + # Register Pode Bearer Authentication + New-PodeAuthBearerScheme -AsJWT -JwtVerificationMode Strict -SelfSigned | + Add-PodeAuth -Name 'Bearer_JWT_SelfSigned' -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.id -ieq 'M0R7Y302') { + return @{ + User = @{ + ID = $jWt.id + Name = $jWt.name + Type = $jWt.type + sub = $jWt.Id + username = $jWt.Username + groups = $jWt.Groups + } + } + } + else { + write-podehost $jwt -Explode + } + + return $null + } + + + Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/login' -ScriptBlock { + try { + # In a real scenario, you'd validate the incoming credentials from $WebEvent.data + $username = $WebEvent.Data.username + $password = $WebEvent.Data.password + $user = if ($username -eq 'morty' -and $password -eq 'pickle') { + @{ + Id = 'M0R7Y302' + Username = 'morty.smith' + Name = 'Morty Smith' + Groups = 'Domain Users' + } + } + if (!$user) { + throw 'Invalid credentials' + } + $payload = @{ + sub = $user.Id + name = $user.Name + username = $user.Username + id = $user.Id + groups = $user.Groups + type = 'human' + } + + # If valid, generate a JWT that matches the 'ExampleApiKeyCert' scheme + $jwt = ConvertTo-PodeJwt -Payload $payload -Authentication 'Bearer_JWT_SelfSigned' -Expiration 600 + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'user' = $user + 'token' = $jwt + } + + } + catch { + write-podehost $_.Exception.Message + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid credentials' } + } + } + + Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/renew' -Authentication 'Bearer_JWT_SelfSigned' -ScriptBlock { + try { + + $jwt = Update-PodeJwt -ExpirationExtension 6000 + + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'token' = $jwt + } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } + + Add-PodeRoute -Method Get -Path '/auth/bearer/jwt/info' -Authentication 'Bearer_JWT_SelfSigned' -ScriptBlock { + try { + $jwtInfo = ConvertFrom-PodeJwt -Outputs 'Header,Payload,Signature' -HumanReadable + $jwtInfo.success = $true + Write-PodeJsonResponse -StatusCode 200 -Value $jwtInfo + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } + + } + } + + Start-Sleep -Seconds 20 + } + + AfterAll { + + Receive-Job -Name 'Pode' | Out-Default + Invoke-RestMethod -Uri "$($Endpoint)/close" -Method Get | Out-Null + Get-Job -Name 'Pode' | Remove-Job -Force + if ( (Test-Path -Path $CertsPath -PathType Container)) { + Remove-Item -Path $CertsPath -Recurse -Force + Write-Output "$CertsPath removed." + } + } + + + + Describe 'Bearer Authentication - JWT Algorithms' { + + Context 'Bearer - Algorithm <_> - Lenient - Path /auth/bearer/jwt/key/<_>' -ForEach (('RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')) { + It "Bearer - Algorithm $_ - returns OK for valid key" { + + # Define corresponding private key path + $privateKeyPath = "$CertsPath/$_.pfx" + # Ensure the matching private key exists + (Test-Path $privateKeyPath) | Should -BeTrue + + $rsaPaddingScheme = if ($_.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + # Read key contents + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Certificate $privateKeyPath -RsaPaddingScheme $rsaPaddingScheme -Payload $payload + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + + # Make request to correct algorithm path + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/key/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + } + + Context 'Bearer - Algorithm <_> - Strict - Path /auth/bearer/jwt/key/strict<_>' -ForEach (('RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')) { + It "Bearer - Algorithm $_ - returns OK for valid key" { + # Define corresponding private key path + $privateKeyPath = "$CertsPath/$_.pfx" + # Ensure the matching private key exists + (Test-Path $privateKeyPath) | Should -BeTrue + + $rsaPaddingScheme = if ($_.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + $payload = @{ sub = '123'; username = 'morty' } + $params = @{ + Payload = $payload + Certificate = $privateKeyPath + RsaPaddingScheme = $rsaPaddingScheme + Issuer = 'Pode' + Audience = $applicationName + } + $jwt = ConvertTo-PodeJwt @params + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + + # Make request to correct algorithm path + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/key/strict/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + } + } + + Describe 'Bearer - Algorithm <_> - Lenient - Path /auth/bearer/jwt/secret/lenient/<_>' -ForEach ('HS256', 'HS384', 'HS512') { + It "Bearer - Algorithm $_ - returns OK for valid key" { + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + + It 'Bearer - Algorithm <_> - returns OK without issuer in lenient mode' { + $payload = @{ sub = '123'; username = 'morty'; aud = $applicationName } # Missing 'iss' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns OK without audience in lenient mode' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode' } # Missing 'aud' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns OK with incorrect issuer' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'FakeIssuer'; aud = $applicationName } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'FakeIssuer' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns OK with incorrect audience' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode'; aud = 'WrongApp' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' -Audience 'WrongApp' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + } + + # Strict mode for HS256 + Describe 'Bearer - Algorithm <_> - Strict - Path /auth/bearer/jwt/secret/strict/<_>' -ForEach ('HS256', 'HS384', 'HS512') { + It "Bearer - Algorithm $_ - returns OK for valid key" { + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns 401 for invalid algorithm' { + foreach ($invalidAlg in ('HS256', 'HS384', 'HS512')) { + if ($invalidAlg -eq $_) { continue } + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $invalidAlg -Secret $secret -Issuer 'Pode' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + } + + It 'Bearer - Algorithm <_> - rejects token without issuer in strict mode' { + $payload = @{ sub = '123'; username = 'morty'; aud = $applicationName } # Missing 'iss' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + + It 'Bearer - Algorithm <_> - rejects token without audience in strict mode' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode' } # Missing 'aud' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + + It 'Bearer - Algorithm <_> - rejects token with incorrect issuer' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'FakeIssuer'; aud = $applicationName } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'FakeIssuer' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + + It 'Bearer - Algorithm <_> - rejects token with incorrect audience' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode'; aud = 'WrongApp' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' -Audience 'WrongApp' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + } + + Describe 'JWT Authentication Workflow' { + BeforeAll { + $Headers = @{ + 'accept' = 'application/json' + 'Content-Type' = 'application/json' + } + } + + It 'Logs in and retrieves a JWT token' { + $Body = @{ + username = 'morty' + password = 'pickle' + } | ConvertTo-Json -Depth 10 + + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/login" ` + -Method Post ` + -Headers $Headers ` + -Body $Body + + # Validate response + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + $Response.success | Should -Be $true + + # Validate JWT token format + $Response.token | Should -Not -BeNullOrEmpty + $Response.token | Should -Match '^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$' + + # Validate user details + $Response.User | Should -Not -BeNullOrEmpty + $Response.User.Username | Should -Be 'morty.smith' + $Response.User.Groups | Should -Be 'Domain Users' + $Response.User.Name | Should -Be 'Morty Smith' + $Response.User.Id | Should -Be 'M0R7Y302' + + # Store JWT for subsequent tests + $script:JwtToken = $Response.token + } + + It 'Validates JWT Token Structure and Claims' { + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/info" ` + -Method Get ` + -Headers @{ + 'accept' = 'application/json' + 'Authorization' = "Bearer $($script:JwtToken)" + } + + # Validate response structure + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + + # Validate JWT Header + $Response.Header | Should -Not -BeNullOrEmpty + $Response.Header.typ | Should -Be 'JWT' + $Response.Header.alg | Should -Be 'ES384' + + # Validate JWT Payload + $Response.Payload | Should -Not -BeNullOrEmpty + $Response.Payload.type | Should -Be 'human' + $Response.Payload.username | Should -Be 'morty.smith' + $Response.Payload.sub | Should -Be 'M0R7Y302' + $Response.Payload.groups | Should -Be 'Domain Users' + $Response.Payload.name | Should -Be 'Morty Smith' + $Response.Payload.id | Should -Be 'M0R7Y302' + + # Validate JWT Timestamps + $Response.Payload.iat | Should -BeOfType 'datetime' + $Response.Payload.nbf | Should -BeOfType 'datetime' + $Response.Payload.exp | Should -BeOfType 'datetime' + $Response.Payload.iss | Should -Be 'Pode' + $Response.Payload.aud | Should -Be 'JWTAuthentication' + $Response.Payload.jti | Should -Match '^[0-9a-f\-]+$' + + # Validate JWT Signature + $Response.Signature | Should -Not -BeNullOrEmpty + $Response.Signature | Should -Match '^[A-Za-z0-9_\-]+$' + + # Store expiration for comparison + $script:JwtExpiration = $Response.Payload.exp + } + + It 'Renews JWT Token Successfully' { + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/renew" ` + -Method Post ` + -Headers @{ + 'accept' = 'application/json' + 'Authorization' = "Bearer $($script:JwtToken)" + } ` + -Body '' + + # Validate response structure + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + $Response.success | Should -Be $true + + # Validate JWT token format + $Response.token | Should -Not -BeNullOrEmpty + $Response.token | Should -Match '^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$' + + # Store previous token for comparison + $script:PreviousJwtToken = $script:JwtToken + $script:JwtToken = $Response.token + } + + It 'Validates Renewed JWT Token and Claims' { + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/info" ` + -Method Get ` + -Headers @{ + 'accept' = 'application/json' + 'Authorization' = "Bearer $($script:JwtToken)" + } + + # Validate response structure + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + $Response.success | Should -Be $true + + # Validate JWT Header + $Response.Header | Should -Not -BeNullOrEmpty + $Response.Header.typ | Should -Be 'JWT' + $Response.Header.alg | Should -Be 'ES384' + + # Validate JWT Payload + $Response.Payload | Should -Not -BeNullOrEmpty + $Response.Payload.type | Should -Be 'human' + $Response.Payload.username | Should -Be 'morty.smith' + $Response.Payload.sub | Should -Be 'M0R7Y302' + $Response.Payload.groups | Should -Be 'Domain Users' + $Response.Payload.name | Should -Be 'Morty Smith' + $Response.Payload.id | Should -Be 'M0R7Y302' + + # Validate JWT Timestamps + $Response.Payload.iat | Should -BeOfType 'datetime' + $Response.Payload.nbf | Should -BeOfType 'datetime' + $Response.Payload.exp | Should -BeOfType 'datetime' + $Response.Payload.iss | Should -Be 'Pode' + $Response.Payload.aud | Should -Be 'JWTAuthentication' + $Response.Payload.jti | Should -Match '^[0-9a-f\-]+$' + + # Validate JWT Signature + $Response.Signature | Should -Not -BeNullOrEmpty + $Response.Signature | Should -Match '^[A-Za-z0-9_\-]+$' + + # Ensure the new token is different from the previous one + $script:JwtToken | Should -Not -BeExactly $script:PreviousJwtToken + + # Validate expiration time increased + $Response.Payload.exp | Should -BeGreaterThan $script:JwtExpiration + } + } + + +} \ No newline at end of file diff --git a/tests/shared/TestHelper.ps1 b/tests/shared/TestHelper.ps1 index 2d4bc638b..7c72f4b3c 100644 --- a/tests/shared/TestHelper.ps1 +++ b/tests/shared/TestHelper.ps1 @@ -49,6 +49,8 @@ function Import-PodeAssembly { } + + function Compare-Hashtable { param ( [object]$Hashtable1, diff --git a/tests/unit/Certificate.Tests.ps1 b/tests/unit/Certificate.Tests.ps1 new file mode 100644 index 000000000..8a61491f0 --- /dev/null +++ b/tests/unit/Certificate.Tests.ps1 @@ -0,0 +1,381 @@ +using namespace System.Security.Cryptography + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' +} + +Describe 'New-PodeCertificateRequest Function' { + + BeforeAll { + # Create a temporary directory for output files. + $tempOutput = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) + New-Item -Path $tempOutput -ItemType Directory | Out-Null + } + + AfterAll { + # Clean up the temporary directory after tests. + Remove-Item $tempOutput -Recurse -Force -ErrorAction SilentlyContinue + } + + BeforeEach { + # Override the internal function with a dummy implementation. + function New-PodeCertificateRequestInternal { + param ( + $DnsName, $CommonName, $Organization, $Locality, $State, $Country, + $KeyType, $KeyLength, $EnhancedKeyUsages, $NotBefore, $CustomExtensions, $FriendlyName + ) + + # Create a dummy private key object with a script method. + $privateKey = [PSCustomObject]@{} + $privateKey | Add-Member -MemberType ScriptMethod -Name ExportPkcs8PrivateKey -Value { + return [System.Text.Encoding]::UTF8.GetBytes('dummykey') + } + + return [PSCustomObject]@{ + Request = 'Dummy CSR Content' + PrivateKey = $privateKey + } + } + } + + It 'Generates a CSR and Private Key and saves them to the specified OutputPath' { + # Define test input values. + $dnsName = 'test.example.com' + $commonName = 'test.example.com' + $org = 'Test Organization' + $locality = 'Test City' + $state = 'Test State' + $country = 'US' + $keyType = 'RSA' + $keyLength = 2048 + + # Call the function. + $result = New-PodeCertificateRequest ` + -DnsName $dnsName ` + -CommonName $commonName ` + -Organization $org ` + -Locality $locality ` + -State $state ` + -Country $country ` + -KeyType $keyType ` + -KeyLength $keyLength ` + -OutputPath $tempOutput + + # Expected file paths. + $expectedCsrPath = Join-Path $tempOutput "$commonName.csr" + $expectedKeyPath = Join-Path $tempOutput "$commonName.key" + + # Validate the returned object. + $result | Should -BeOfType 'PSCustomObject' + $result.CsrPath | Should -Be $expectedCsrPath + $result.PrivateKeyPath | Should -Be $expectedKeyPath + + # Verify that the files have been created. + (Test-Path $result.CsrPath) | Should -BeTrue + (Test-Path $result.PrivateKeyPath) | Should -BeTrue + + # Validate file contents. + $csrContent = Get-Content -Path $result.CsrPath -Raw + $csrContent.Trim() | Should -Be 'Dummy CSR Content' + + $keyContent = Get-Content -Path $result.PrivateKeyPath -Raw + $keyContent | Should -Match '-----BEGIN PRIVATE KEY-----' + $keyContent | Should -Match '-----END PRIVATE KEY-----' + $keyContent | Should -Match 'ZHVtbXlrZXk=' + } +} + + +Describe 'New-PodeSelfSignedCertificate Function' { + + + It 'Generates a valid self-signed certificate with specified parameters' { + # Define test parameters. + $dnsName = @('test.example.com') + $commonName = 'test.example.com' + $org = 'TestOrg' + $locality = 'TestCity' + $state = 'TestState' + $country = 'US' + $keyType = 'RSA' + $keyLength = 2048 + $purpose = 'ServerAuth' + $notBefore = (Get-Date).ToUniversalTime() + $script:friendlyName = 'MyTestCertificate' + $validityDays = 365 + + # Optionally, supply a secure string password for PFX protection. + $script:dummyPassword = ConvertTo-SecureString 'TestPassword' -AsPlainText -Force + + # Call the certificate function. + $script:dummyCert = New-PodeSelfSignedCertificate -DnsName $dnsName ` + -Organization $org -Locality $locality -State $state -Country $country ` + -KeyType $keyType -KeyLength $keyLength -CertificatePurpose $purpose ` + -NotBefore $notBefore -FriendlyName $script:friendlyName -ValidityDays $validityDays ` + -Password $script:dummyPassword -Exportable + + # Validate that a certificate is returned. + $script:dummyCert | Should -BeOfType 'System.Security.Cryptography.X509Certificates.X509Certificate2' + + # Validate the certificate's subject contains the common name. + $script:dummyCert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + + # Check certificate validity period. + $expectedNotBefore = $notBefore.Date + $expectedNotAfter = $notBefore.AddDays($validityDays).Date + + $script:dummyCert.NotBefore.ToUniversalTime().Date | Should -Be $expectedNotBefore + $script:dummyCert.NotAfter.ToUniversalTime().Date | Should -Be $expectedNotAfter + + # On Windows, verify the FriendlyName is set. + if ($IsWindows) { + $script:dummyCert.FriendlyName | Should -Be $script:friendlyName + } + } + + It 'Generates an ephemeral certificate when -Ephemeral is specified' { + # Define minimal parameters. + $commonName = 'ephemeral.example.com' + + # Call the function with the Ephemeral switch. + $cert = New-PodeSelfSignedCertificate -CommonName $commonName -Ephemeral + + # Validate that a certificate object is returned. + $cert | Should -BeOfType 'System.Security.Cryptography.X509Certificates.X509Certificate2' + + # Check that the certificate has a private key. + $cert.HasPrivateKey | Should -BeTrue + + # Note: Ephemeral certificates are created with non-persistent private keys. + # This test ensures the private key exists, though verifying non-persistence across sessions is out of scope. + } +} + +Describe 'Export-PodeCertificate Function' { + BeforeAll { + # Create a temporary directory for exported files. + $script:tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + New-Item -Path $script:tempDir -ItemType Directory -Force | Out-Null + + } + + Context 'File Export - PFX format' { + It 'Exports certificate to a PFX file' { + $filePathBase = Join-Path $script:tempDir 'dummycertPFX' + $script:pfxCertPath = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PFX' -CertificatePassword $script:dummyPassword + $script:pfxCertPath | Should -BeOfType pscustomobject + $script:pfxCertPath.CertificateFile | Should -Match '\.pfx' + (Test-Path $script:pfxCertPath.CertificateFile) | Should -BeTrue + } + } + + Context 'File Export - CER format' { + It 'Exports certificate to a CER file' { + $filePathBase = Join-Path $script:tempDir 'dummycertCER' + $script:cerCertPath = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'CER' -CertificatePassword $script:dummyPassword + $script:cerCertPath | Should -BeOfType pscustomobject + $script:cerCertPath.CertificateFile | Should -Match '\.cer' + (Test-Path $script:cerCertPath.CertificateFile) | Should -BeTrue + } + } + + Context 'File Export - PEM format without private key' -Tag 'Exclude_DesktopEdition' { + It 'Exports certificate to a PEM file without private key' { + $filePathBase = Join-Path $script:tempDir 'dummycertPEM_NoKey' + if ($PSVersionTable.PSEdition -eq 'Desktop') { + { Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -CertificatePassword $script:dummyPassword } | Should -Throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + else { + $output = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -CertificatePassword $script:dummyPassword + # The output for PEM (without key) is a string containing the file path. + $output | Should -BeOfType pscustomobject + $output.CertificateFile | Should -Match '\.pem' + (Test-Path -Path $output.CertificateFile) | Should -BeTrue + (Get-Content -Path $output.CertificateFile -Raw) | Should -Match '-----BEGIN CERTIFICATE-----' + } + } + } + + Context 'File Export - PEM format with private key' { + + It 'Exports certificate to a PEM file and exports the private key separately' { + $filePathBase = Join-Path $script:tempDir 'dummycertPEM_WithKey' + if ($PSVersionTable.PSEdition -eq 'Desktop') { + { Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -IncludePrivateKey -CertificatePassword $script:dummyPassword } | Should -Throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + else { + $script:pemCertPath = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -IncludePrivateKey -CertificatePassword $script:dummyPassword + # When IncludePrivateKey is used, output is a hashtable. + $script:pemCertPath | Should -BeOfType 'pscustomobject' + $script:pemCertPath.CertificateFile | Should -Match '\.pem$' + $script:pemCertPath.PrivateKeyFile | Should -Match '\.key$' + (Test-Path $script:pemCertPath.CertificateFile) | Should -BeTrue + (Test-Path $script:pemCertPath.PrivateKeyFile) | Should -BeTrue + + (Get-Content -Path $script:pemCertPath.CertificateFile -Raw) | Should -Match '-----BEGIN CERTIFICATE-----' + (Get-Content -Path $script:pemCertPath.PrivateKeyFile -Raw) | Should -Match '-----BEGIN ENCRYPTED PRIVATE KEY-----' + } + } + } + + Context 'Windows Store Export' { + It 'Stores certificate in the Windows certificate store' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + $script:thumbprint = $script:dummyCert.Thumbprint + + $result = Export-PodeCertificate -Certificate $script:dummyCert -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' + $result | Should -BeTrue + } + } +} + + + +Describe 'Import-PodeCertificate Function' { + Describe 'Sanity Check' { + BeforeAll { + # Create a dummy certificate using New-PodeSelfSignedCertificate. + # This call should work on PS 5.1 as well as Core. + $script:dummyCert = New-PodeSelfSignedCertificate -CommonName 'dummy.test' -ValidityDays 365 -Exportable + + + # Simulate Test-Path so that paths containing "exists" return true, others false. + Mock -CommandName Test-Path -MockWith { + param($Path, $PathType) + if ($Path[0].Contains('notexists')) { return $false } else { return $true } + } + + # Mock certificate import helper functions to return our dummy certificate. + Mock -CommandName Get-PodeCertificateByFile -MockWith { + param($Certificate, $SecurePassword, $PrivateKeyPath, $Persistent) + return $script:dummyCert + } + Mock -CommandName Get-PodeCertificateByThumbprint -MockWith { + param($Thumbprint, $StoreName, $StoreLocation) + return $script:dummyCert + } + Mock -CommandName Get-PodeCertificateByName -MockWith { + param($Name, $StoreName, $StoreLocation) + return $script:dummyCert + } + } + + Context 'When importing from a certificate file' { + It 'Throws an error if the certificate file does not exist' { + { + Import-PodeCertificate -Path 'C:\Certs\notexists.pfx' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) + } | Should -Throw + } + + It 'Throws an error if a PrivateKeyPath is provided but does not exist' { + { + Import-PodeCertificate -Path 'C:\Certs\exists.pfx' ` + -PrivateKeyPath 'C:\Certs\notexists.key' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) + } | Should -Throw + } + + It 'Imports a certificate from file when the certificate file exists' { + $cert = Import-PodeCertificate -Path 'C:\Certs\exists.pfx' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) + $cert | Should -Be $script:dummyCert + } + + It 'Imports a certificate from file with the persistent flag when both files exist' { + $cert = Import-PodeCertificate -Path 'C:\Certs\exists.pfx' ` + -PrivateKeyPath 'C:\Certs\exists.key' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) ` + -Exportable + $cert | Should -Be $script:dummyCert + } + } + + Context 'When importing from the certificate store by thumbprint' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + It 'Retrieves a certificate using its thumbprint' { + $thumbprint = 'DUMMYTHUMBPRINT' + $cert = Import-PodeCertificate -CertificateThumbprint $thumbprint ` + -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' + $cert | Should -Be $script:dummyCert + } + } + + Context 'When importing from the certificate store by name' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + It 'Retrieves a certificate using its subject name' { + $name = 'DummyCert' + $cert = Import-PodeCertificate -CertificateName $name ` + -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' + $cert | Should -Be $script:dummyCert + } + } + } + + Describe 'Import Functionality' { + AfterAll { + # Cleanup the temporary directory. + Remove-Item -Path $script:tempDir -Recurse -Force + } + + Context 'File Import - PFX format' { + It 'Imports certificate to a PFX file' { + + $cert = Import-PodeCertificate -Path $script:pfxCertPath.CertificateFile -CertificatePassword $script:dummyPassword + + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + # On Windows, verify the FriendlyName is set. + if ($IsWindows) { + $cert.FriendlyName | Should -Be $script:friendlyName + } + } + } + + Context 'File Import - CER format' { + It 'Imports certificate to a CER file' { + $cert = Import-PodeCertificate -Path $script:cerCertPath.CertificateFile -CertificatePassword $script:dummyPassword + + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + + } + } + + Context 'File Import - PEM format with private key' -Tag 'Exclude_DesktopEdition' { + It 'Imports certificate to a PEM file with private key' { + if ($PSVersionTable.PSEdition -eq 'Desktop') { + Mock Test-Path { $true } + { $cert = Import-PodeCertificate -Path ( Join-Path $script:tempDir 'dummycertPEM.pem') -CertificatePassword $script:dummyPassword -PrivateKeyPath ( Join-Path $script:tempDir 'dummycertPEM.key') } | + Should -Throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + else { + $cert = Import-PodeCertificate -Path $script:pemCertPath.CertificateFile -CertificatePassword $script:dummyPassword -PrivateKeyPath $script:pemCertPath.PrivateKeyFile + # The output for PEM (without key) is a string containing the file path. + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + } + } + } + + Context 'Windows Store Import' { + It 'Stores certificate in the Windows certificate store' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + $cert = Import-PodeCertificate -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' -CertificateThumbprint $script:thumbprint + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + $cert.FriendlyName | Should -Be $script:friendlyName + } + } + } +} + diff --git a/tests/unit/Cryptography.Tests.ps1 b/tests/unit/Cryptography.Tests.ps1 index 423446f27..94566ce25 100644 --- a/tests/unit/Cryptography.Tests.ps1 +++ b/tests/unit/Cryptography.Tests.ps1 @@ -1,3 +1,7 @@ +using namespace System.Security.Cryptography + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() BeforeAll { $path = $PSCommandPath $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' @@ -55,4 +59,5 @@ Describe 'New-PodeSalt' { Mock Get-PodeRandomByte { return @(10, 10, 10) } New-PodeSalt -Length 3 | Should -Be 'CgoK' } -} \ No newline at end of file +} + \ No newline at end of file diff --git a/tests/unit/Jwt.Tests.ps1 b/tests/unit/Jwt.Tests.ps1 new file mode 100644 index 000000000..821e1e9d6 --- /dev/null +++ b/tests/unit/Jwt.Tests.ps1 @@ -0,0 +1,132 @@ +using namespace System.Security.Cryptography + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' +} + +Describe 'New-PodeJwtSignature Function Tests' -Tags 'JWT' { + BeforeAll { + # Sample data + $testValue = 'TestData' + + $testSecret = [System.Text.Encoding]::UTF8.GetBytes('SuperSecretKey') + + $testPath = $(Split-Path -Parent -Path $(Split-Path -Parent -Path $path)) + $certificateTypes = @{ + 'RS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pkcs1V15' + } + 'PS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pss' + } + 'PS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pss' + } + 'PS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pss' + } + 'ES256' = @{ + KeyType = 'ECDSA' + KeyLength = 256 + } + 'ES384' = @{ + KeyType = 'ECDSA' + KeyLength = 384 + } + 'ES512' = @{ + KeyType = 'ECDSA' + KeyLength = 521 + } + } + + $PrivateKey = @{} + + foreach ($alg in $certificateTypes.keys) { + $PrivateKey[$alg] = New-PodeSelfSignedCertificate -Loopback -KeyType $certificateTypes[$alg].KeyType -KeyLength $certificateTypes[$alg].KeyLength -CertificatePurpose CodeSigning -Ephemeral + } + + } + + Context 'HMAC Signing Tests' { + It 'Should generate a valid HMAC-SHA256 signature' { + $result = New-PodeJwtSignature -Token $testValue -Algorithm HS256 -SecretBytes $testSecret + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid HMAC-SHA384 signature' { + $result = New-PodeJwtSignature -Token $testValue -Algorithm HS384 -SecretBytes $testSecret + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid HMAC-SHA512 signature' { + $result = New-PodeJwtSignature -Token $testValue -Algorithm HS512 -SecretBytes $testSecret + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + } + + Context 'RSA Signing Tests' -Tag 'No_DesktopEdition' { + It 'Should generate a valid RSA-SHA256 signature' { + $alg = 'RS256' + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey[$alg] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid RSA-SHA384 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['RS384'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid RSA-SHA512 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['RS512'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + } + + Context 'ECDSA Signing Tests' -Tag 'No_DesktopEdition' { + It 'Should generate a valid ECDSA-SHA256 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['ES256'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid ECDSA-SHA384 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['ES384'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid ECDSA-SHA512 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['ES512'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + } + + Context 'Algorithm NONE Tests' { + It 'Should throw an error if a secret is provided with NONE' { + { New-PodeJwtSignature -Token $testValue -Algorithm NONE -SecretBytes $testSecret } | Should -Throw + } + + } + +} \ No newline at end of file From f233553a16463b81036a4441be82cccec46267cc Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:43:15 -0800 Subject: [PATCH 08/12] Update Helpers.ps1 --- src/Private/Helpers.ps1 | 46 +---------------------------------------- 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 5311611dd..d39c69576 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1518,14 +1518,12 @@ function ConvertFrom-PodeRequestContent { } # Add raw body content $Result.RawData = $Content + # if there is no content then do nothing if ([string]::IsNullOrWhiteSpace($Content)) { return $Result } - # Add raw body content - $Result.RawData = $Content - # check if there is a defined custom body parser if ($PodeContext.Server.BodyParsers.ContainsKey($ContentType)) { $parser = $PodeContext.Server.BodyParsers[$ContentType] @@ -4221,48 +4219,6 @@ function Convert-PodeSecureStringToByteArray { } } -<# -.SYNOPSIS - Retrieves the name of the main Pode application script. - -.DESCRIPTION - The `Get-PodeApplicationName` function determines the name of the primary script (`.ps1`) - that started execution. It does this by examining the PowerShell call stack and - extracting the first script file that appears. - - If no script file is found in the call stack, the function returns `"NoName"`. - -.OUTPUTS - [string] - Returns the filename of the main application script, or `"NoName"` if no script is found. - -.EXAMPLE - Get-PodeApplicationName - - This retrieves the name of the main script that launched the Pode application. - -.EXAMPLE - $AppName = Get-PodeApplicationName - Write-Host "Application Name: $AppName" - - This stores the retrieved application name in a variable and prints it. - -.NOTES - - This function relies on `Get-PSCallStack`, meaning it must be run within a script execution context. - - If called interactively or if no `.ps1` script is in the call stack, it will return `"NoName"`. - - This is an internal function and may change in future releases of Pode. -#> -function Get-PodeApplicationName { - $scriptFrame = (Get-PSCallStack | Where-Object { $_.Command -match '\.ps1$' } | Select-Object -First 1) - if ($scriptFrame) { - return [System.IO.Path]::GetFileName($scriptFrame.Command) - } - else { - return 'NoName' - } -} - - <# .SYNOPSIS Returns the current date and time in UTC format. From 1cc7bb6566eec3f8aa1041ec2482579b39602b2b Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 07:53:26 -0800 Subject: [PATCH 09/12] Squashed commit of the following: commit f3c3aaca5a51aadec51354f662a2f2b20c7be56a Merge: 30e7223a 67505f56 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Feb 23 07:30:56 2025 -0800 Merge branch 'develop' into service commit 30e7223ae93c087668dbd7b37b6c4e84ca509ffc Merge: c559b8e9 cbdc62fe Author: mdaneri Date: Sat Feb 22 09:18:44 2025 -0800 Merge remote-tracking branch 'upstream/develop' into service commit c559b8e92e35cc8024b77d0001b9e9eb1dde15b1 Merge: 4ef1b847 fbf6ecfb Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sat Feb 22 06:33:02 2025 -0800 Merge branch 'develop' into service commit 4ef1b8471cb1dcec715f6af52474443c779c2d46 Merge: a25119b3 a76741bc Author: mdaneri Date: Sun Feb 16 07:08:44 2025 -0800 Merge remote-tracking branch 'upstream/develop' into service commit a25119b3ddf20c7b9d769569f45095719d262262 Merge: adc01aa8 a236a1a0 Author: mdaneri Date: Tue Feb 11 18:51:30 2025 -0800 Merge remote-tracking branch 'upstream/develop' into service commit adc01aa8ee07ee0b71723e72bd2767e77cba9df8 Merge: 060f313a 75e29626 Author: mdaneri Date: Sun Feb 9 07:24:08 2025 -0800 Merge remote-tracking branch 'upstream/develop' into service commit 060f313a1ebf3b0686b6c4082e2ce39e4f22c6bb Merge: 752cbabe 6b23fc33 Author: mdaneri Date: Wed Feb 5 14:35:21 2025 -0800 Merge remote-tracking branch 'upstream/develop' into service commit 752cbabe06f69f96bbdbe2d642b8747c3aadf5b7 Author: mdaneri Date: Wed Jan 29 07:16:16 2025 -0800 minor fixes commit 609d71bcce56f296846ff8a6134766e48e05529f Author: mdaneri Date: Tue Jan 28 13:40:54 2025 -0800 udate service test to include suspend commit 1c9d486fa9d489dd95d996328f490562cd4393fa Author: mdaneri Date: Tue Jan 28 07:47:49 2025 -0800 states improvement commit 5864d56378ecec9858539c3fbebd4ffe3e741dfd Author: mdaneri Date: Tue Jan 28 07:23:51 2025 -0800 enum changes commit e5317b813880fd11ebf3ec8295f29a7d6613cc25 Author: mdaneri Date: Mon Jan 27 10:23:15 2025 -0800 update commit 285026c97052cab793f8f31cd7f5717e76a00696 Author: mdaneri Date: Sun Jan 26 11:40:44 2025 -0800 Fix a resume issue commit 717c8d883ca53eea034a2e1a447d4bf767de65ee Author: mdaneri Date: Sun Jan 26 08:49:22 2025 -0800 first fixes commit 886b8a7cf580d3912d4158f71877e44d4eb433fc Merge: 5c8bb3df f4db4b62 Author: mdaneri Date: Sun Jan 26 08:20:14 2025 -0800 Merge remote-tracking branch 'upstream/develop' into service commit 5c8bb3df8d0f653190d4c1ae55b4df3615fc82ff Author: mdaneri Date: Fri Nov 29 05:28:44 2024 -0800 Fix examples commit ac4cbb9643dc1b955f632770923e12058592a449 Author: mdaneri Date: Thu Nov 28 07:25:56 2024 -0800 fix pipename generator on mac/linux+ log errors commit d2b5b49143bb988516ab93faada731ce527bccf0 Author: mdaneri Date: Thu Nov 28 06:37:06 2024 -0800 add full support for macos agents commit 1761077e5071899e8bbe5690f5fc65dc5b14e428 Author: mdaneri Date: Wed Nov 27 15:46:16 2024 -0800 Added log rotation and additional features commit 40d5bbc403fc9f2d04ebc366354340ed5ac3c9a8 Author: mdaneri Date: Wed Nov 27 14:30:29 2024 -0800 fixes for Powershell 5.1 commit d6a026f4aafbec4ef597331bf3beb2705958a843 Author: MDaneri Date: Wed Nov 27 10:25:18 2024 -0800 fix linux commit c9c84f3d3efa00d8b8150eb3025703cf42ebaaab Author: mdaneri Date: Wed Nov 27 10:10:58 2024 -0800 Code Improvements commit 0c50dd1c96378d838aca2b4a917fa9945ca2b712 Author: mdaneri Date: Tue Nov 26 21:20:12 2024 -0800 Retry commit 10a272d25033f0f77af9fc6d1be436873fea4e08 Author: mdaneri Date: Tue Nov 26 17:49:17 2024 -0800 fix MacOS test (I hope) commit 6f817e6d3429cae3e57b910a63e603b4076983bc Author: mdaneri Date: Tue Nov 26 16:17:48 2024 -0800 fixes commit 79e5b2f9b0d116176a40a5edfa9a3a8a936af4aa Author: mdaneri Date: Tue Nov 26 15:56:33 2024 -0800 fix MacOS commit bc36b0f6b8270973f31adb6683c6cbd07c67a680 Author: MDaneri Date: Mon Nov 25 21:27:32 2024 -0800 test fixes commit 035cb12f51a2ade7acad6728344e9a4626529a04 Author: MDaneri Date: Mon Nov 25 17:58:12 2024 -0800 linux fixes commit 7f77d94de61ad6afe12b2f54a035748378aaed99 Author: mdaneri Date: Mon Nov 25 17:40:39 2024 -0800 fix windows test commit 12c33a4eeb633be0b8fc9544d6dbcf529011252c Author: mdaneri Date: Mon Nov 25 14:31:28 2024 -0800 improvements to the service commands commit 50b23a3766dfb1ded65e4543369bace5e71c2d0d Merge: 6af3213c 391bdfff Author: mdaneri Date: Sun Nov 24 16:50:42 2024 -0800 Merge remote-tracking branch 'upstream/develop' into service commit 6af3213c8cdc8c86268b26bf2ec75ae4954bfff8 Author: mdaneri Date: Sat Nov 23 17:37:52 2024 -0800 tests fix commit 304457d066f45c825d26b6982f8e7e360a4b168c Author: MDaneri Date: Sat Nov 23 15:50:24 2024 -0800 modified: tests/integration/Service.Tests.ps1 commit d18b1f6065a7b8ec349a6e00a017414435195dac Author: mdaneri Date: Sat Nov 23 14:49:54 2024 -0800 Fix the macos part commit ad7a7e87d694447d261f5c96ab1798378f2ddd51 Author: MDaneri Date: Sat Nov 23 11:20:40 2024 -0800 add group adm as sudo users commit d6946f3de2e5b6b9ace50ca481d3cd428ffe2d27 Author: MDaneri Date: Sat Nov 23 11:16:41 2024 -0800 replace Test-PodeBuildIsWindows with $iswindows commit 7d208c636d97837adaf6ff12e33bcb0b0238aae3 Author: MDaneri Date: Sat Nov 23 11:11:41 2024 -0800 build improvements commit 55df3ab5fe124fb1657588faf5292039c2a894d2 Author: MDaneri Date: Sat Nov 23 10:53:44 2024 -0800 adding delays commit 1b188e67680f356fb85e0de09477e401deb5fa36 Author: MDaneri Date: Sat Nov 23 10:11:38 2024 -0800 again commit f000a9eca1d1941794ff1d81678fd2f4d9a9527e Author: MDaneri Date: Sat Nov 23 10:06:27 2024 -0800 fix trhe workflow commit bb1fbb294b3bdb72c170235371c299455179f982 Author: MDaneri Date: Sat Nov 23 10:03:58 2024 -0800 try to catch $_ -like "*##[debug]*" commit ae24dd94db8c2641998cfeb5361eaaaa47a74172 Author: MDaneri Date: Sat Nov 23 09:41:36 2024 -0800 retry commit a57638659a884e93cb52052f3913b2480023a36d Author: MDaneri Date: Sat Nov 23 09:25:10 2024 -0800 support workflow debug commit 6ba641982c801452174b69d19c669d5be8a0bcd4 Author: MDaneri Date: Sat Nov 23 09:02:22 2024 -0800 linux fixes commit 604056379145ae73c2d0b433df3be6734a47e297 Author: mdaneri Date: Sat Nov 23 07:56:55 2024 -0800 Update pode.build.ps1 commit d568de198f415a8c8984e2fb7e4a73da7c3e85f4 Merge: 770702e6 7a2cf535 Author: mdaneri Date: Sat Nov 23 07:56:52 2024 -0800 Merge remote-tracking branch 'upstream/develop' into service commit 770702e6cee7ca55c0ea83e4393fd76a34fb2a25 Author: MDaneri Date: Fri Nov 22 17:04:55 2024 -0800 Added stopping and fix suspended report on linux commit 697cd8b8988d7a97eeea8e1b397c0540a7960e9f Author: mdaneri Date: Fri Nov 22 08:34:00 2024 -0800 adding tests commit b0bd2041825943a8c73c66cb9e1ddd8992e787a5 Author: mdaneri Date: Fri Nov 22 06:30:30 2024 -0800 Update Helpers.ps1 commit ad2827263ec8e3f5aa0dc871cc3c953cbca3c7fe Merge: 3cdfb0d7 508c2da0 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Fri Nov 22 05:40:18 2024 -0800 Merge branch 'develop' into service commit 3cdfb0d7cd6c8ddf66ee9b541779663e2c5955be Author: mdaneri Date: Thu Nov 21 21:16:04 2024 -0800 windows fixes commit 5ee22257dbfbbdb086e2aad661c677d2153516cc Author: mdaneri Date: Thu Nov 21 10:14:29 2024 -0800 improvements commit 44f87686b88c07af01b88f35a05555b8bb7c157d Author: mdaneri Date: Thu Nov 21 09:45:19 2024 -0800 improve workflow commit 66e48179ea675ad6bc7c30f01180f69fc7562737 Author: mdaneri Date: Wed Nov 20 12:10:13 2024 -0800 fix Test-PodeAdminPrivilege commit 6c2b883e1fbe084882df56a726fc3fa391fddb3c Author: mdaneri Date: Wed Nov 20 11:25:50 2024 -0800 add DisableLifecycleServiceOperations to build commit cc5787e7974059a2c33182d41263f362df97245f Author: mdaneri Date: Tue Nov 19 20:43:09 2024 -0800 Mac fixes commit 0978c68160e83b9aef1e1a14a4339cc89f3bd303 Author: mdaneri Date: Tue Nov 19 14:23:43 2024 -0800 fixes commit ac2fd828e00ff624cf79792bf838e140cb646512 Author: Max Daneri Date: Tue Nov 19 17:19:58 2024 -0500 Linux fixes commit 9bb9747f30d750e8c237d2c59c563cdc4ce7ab14 Author: mdaneri Date: Tue Nov 19 12:34:34 2024 -0800 minor fixes commit d959b34e8ac438f539d5debc90be5cf9064120c6 Author: mdaneri Date: Tue Nov 19 10:16:03 2024 -0800 fixes commit 0e5765dcb72aa973715dd0816428a7a82856b3d0 Author: mdaneri Date: Mon Nov 18 18:04:40 2024 -0800 Rename folders and fix windows service credential commit ab64da5cce60f5bca01a75f3cd482b557bf859cb Author: mdaneri Date: Mon Nov 18 09:51:36 2024 -0800 add EnableTransactions to mac plist commit d4ada0f7dd4996cb156fcf67abb134cf0cc90a18 Author: mdaneri Date: Mon Nov 18 09:45:00 2024 -0800 Add restart to windows using sc control 'Hello Service2' 128 commit b04f761c515eec5d99df2fe0530d01739da356a7 Author: mdaneri Date: Mon Nov 18 07:55:08 2024 -0800 fixes commit 93ca6141555ad7150b32c166b68d5c5b50b015ba Author: mdaneri Date: Sun Nov 17 10:32:09 2024 -0800 Added comments commit 4996642393a2b65ca30184cca04c4046d3dd7df9 Author: mdaneri Date: Sun Nov 17 10:23:14 2024 -0800 improvements commit c11eb7fc4e410ce6e22ac832ecfcdd2ce69d65f3 Author: mdaneri Date: Sun Nov 17 10:09:50 2024 -0800 revert to net8 commit ebf36a2a739660a57e74372f1b59b781159aa27d Author: mdaneri Date: Sun Nov 17 09:56:45 2024 -0800 Improvements commit 87254b33a3366da35ab01cbd761ef1853e770b83 Author: mdaneri Date: Sat Nov 16 19:25:45 2024 -0800 add suspend ,resume commit 756853ecd8d3d3fbf9de7f8413a6da1a382c33ae Merge: 6cc2da7f 5a0bee12 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Wed Nov 6 05:49:17 2024 -0800 Merge branch 'develop' into service commit 6cc2da7fc2a8bf56f05fb6898f175766c215a0b4 Merge: b3579ead 62bc7052 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Nov 3 13:26:46 2024 -0800 Merge branch 'develop' into service commit b3579eade84e88280a3add4fd075996ebff8c646 Merge: 96cc8a30 312654bf Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Nov 3 12:40:55 2024 -0800 Merge branch 'develop' into service commit 96cc8a307d91bd71b36cdd2a072191d4e51e3209 Merge: 2e6b1532 a37f33b3 Author: mdaneri Date: Sat Nov 2 14:10:16 2024 -0700 Merge remote-tracking branch 'upstream/develop' into service commit 2e6b153285377ca144e670d5f87e3b45080f0aed Merge: 49b45c02 308035d8 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Wed Oct 30 05:35:35 2024 -0700 Merge branch 'develop' into service commit 49b45c029487596d017b705121383a43935a64a3 Merge: b113821d c47ad6f7 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Mon Oct 28 04:03:56 2024 -0700 Merge branch 'develop' into service commit b113821d8c3d96c4ce8480520de17dc7c053ab61 Merge: fc3e1c3d 09d9ad02 Author: mdaneri Date: Sun Oct 27 16:45:42 2024 -0700 Merge remote-tracking branch 'upstream/develop' into service commit fc3e1c3db9133ac88b2a55d8b51d7e41b14d19dc Author: mdaneri Date: Wed Oct 23 15:50:03 2024 -0700 MacOS improvements commit d79fe5ffc56ca4eeb07ecdb305438cf422e405e1 Merge: 78e50f27 c8e23fba Author: mdaneri Date: Wed Oct 23 12:56:02 2024 -0700 Merge remote-tracking branch 'upstream/develop' into service commit 78e50f278e3f73d71c9b6abe3507ffb07e13c953 Author: mdaneri Date: Wed Oct 23 11:15:23 2024 -0700 minor log fixex commit 88246e89eaa842f4327749eedadaaa7ff8444b2e Merge: 9326e32b 0b08d675 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Wed Oct 23 06:46:21 2024 -0700 Merge branch 'develop' into service commit 9326e32b10d37a3a3879f0b92040f04e3662daf8 Author: mdaneri Date: Tue Oct 22 15:06:05 2024 -0700 fix the example commit afcd5c1bf7125cf2fe7af800c8246c72aa70dd8a Author: Max Daneri Date: Tue Oct 22 17:57:54 2024 -0400 fixes commit a67b3c3570d45a004d56d0e9bdf8d4d6c75fd98a Author: mdaneri Date: Tue Oct 22 13:30:39 2024 -0700 fixes commit 302bb09f8110d69552bca4dfd3de9a95752d14c3 Author: mdaneri Date: Tue Oct 22 12:49:32 2024 -0700 reinstated create user commit 00c525226bb7b7ade786fb707c1c89f5d7f0de8b Author: mdaneri Date: Tue Oct 22 09:57:47 2024 -0700 remove spaces between function Register-PodeService and header commit 8a314ea045bfc852944a604d298428aa65d6e662 Author: mdaneri Date: Tue Oct 22 09:50:20 2024 -0700 Code completed commit b19643d7dfa1f52807c7420d0d69525d5ac8071f Author: mdaneri Date: Mon Oct 21 18:56:28 2024 -0700 Update Service.ps1 commit 1e0cf3b474338eb51a9933da1bcfefaf9b99a7ea Author: mdaneri Date: Mon Oct 21 18:46:03 2024 -0700 Add logs commit 5c0c33db5f5b532edaa57d35afcdb8b1f13b8165 Author: Max Daneri Date: Mon Oct 21 18:17:39 2024 -0400 fixes commit 9e76fbb736fd51009fc7861e2eeb8f6313920292 Author: mdaneri Date: Mon Oct 21 13:14:34 2024 -0700 fix service path commit 36e14cc98c7459d0db7b227939ac482b2bbf70d6 Author: Max Daneri Date: Mon Oct 21 16:13:16 2024 -0400 fix commit 734f6b34d07505f2f53a0fa72a17946eca3720c2 Author: mdaneri Date: Mon Oct 21 13:06:19 2024 -0700 fixes commit 97d8e3c2eeeb94ddffcde7027fbfea58e31aaf2b Author: Max Daneri Date: Mon Oct 21 15:12:14 2024 -0400 fix linux 1 commit 1f55857a2c0fff286cebff41b990ea5d7f3d152a Author: mdaneri Date: Mon Oct 21 08:03:04 2024 -0700 Update Pode.psd1 commit 65b28d66016f20593b2a8a21413d50e5c93a9e08 Merge: c3073d25 79ec4681 Author: mdaneri Date: Mon Oct 21 08:03:01 2024 -0700 Merge remote-tracking branch 'upstream/develop' into service commit c3073d25de46db78dc00bf58515cf1dc8d7feafa Merge: 2ca36215 04115807 Author: mdaneri Date: Sun Oct 20 09:25:44 2024 -0700 Merge remote-tracking branch 'upstream/develop' into service commit 2ca3621540471c8349e63fb918631a71d303b48d Author: mdaneri Date: Sat Oct 19 10:57:50 2024 -0700 FIx Mac service commit 9d90c78f751b73e15b0a7bb779fd1e9607d29306 Author: mdaneri Date: Sat Oct 19 09:23:11 2024 -0700 Add UAC support commit 99f510941bb78359bec1068d8b71f3060c14c400 Merge: e29752ea a1811722 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Fri Oct 18 20:29:13 2024 -0400 Merge branch 'develop' into service commit e29752eaab9a224d948a66e177ac8c97a2e1a803 Author: Max Daneri Date: Fri Oct 18 15:58:52 2024 -0400 fixes commit 0091321c9df337221bb4a8a539869f4edbdb1c2d Author: mdaneri Date: Fri Oct 18 09:48:32 2024 -0700 Remove settingsfile with unregister commit 6c51a480e5433438594f1962c4b5c40385bddf4c Author: mdaneri Date: Fri Oct 18 08:48:28 2024 -0700 Fix tests commit b87561e559458fffca7ada7d7107159d3504f97f Author: mdaneri Date: Thu Oct 17 17:28:47 2024 -0700 Fix Windows commit 430ea27bcae47d5fc93fb74c5781b28698ea0b32 Author: mdaneri Date: Thu Oct 17 16:16:02 2024 -0700 FIx Mac service commit 3c3a5cddcf30c8087e3c467fda57d242dbe6e433 Author: mdaneri Date: Thu Oct 17 11:55:45 2024 -0700 fix Mac detection commit d60d1cc2b2e268714b180ae75b4a826982ea7571 Author: mdaneri Date: Thu Oct 17 10:21:14 2024 -0700 Update to linux, Mac commit 2791504be219ffa483b4e80589deb8b3dd4cbc8a Author: mdaneri Date: Wed Oct 16 19:50:14 2024 -0700 update commit f965cbf1bde4002615149d9aba9e3f3808ddee85 Author: mdaneri Date: Wed Oct 16 07:51:08 2024 -0700 Integrated in Pode commit d42b58dbb08677749067b183b962644a7f109421 Merge: f264e07e 696cc43a Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Wed Oct 16 04:01:08 2024 -0700 Merge branch 'develop' into service commit f264e07e7938c94e6643d9b0588a82570618d9e3 Author: mdaneri Date: Tue Oct 15 18:39:03 2024 -0700 First drop --- .github/workflows/ci-powershell.yml | 14 +- .github/workflows/ci-pwsh7_5.yml | 13 +- .github/workflows/ci-pwsh_lts.yml | 13 +- .github/workflows/ci-pwsh_preview.yml | 13 +- .gitignore | 2 + Pode.sln | 2 + docs/Hosting/PortsBelow1024.md | 55 + docs/Hosting/RunAsService.md | 286 ++- examples/HelloService/HelloService.ps1 | 196 ++ examples/HelloService/HelloServices.ps1 | 191 ++ examples/HelloWorld/servicesettings.json | 11 + pode.build.ps1 | 303 ++- src/Locales/ar/Pode.psd1 | 8 + src/Locales/de/Pode.psd1 | 8 + src/Locales/en-us/Pode.psd1 | 10 +- src/Locales/en/Pode.psd1 | 8 + src/Locales/es/Pode.psd1 | 8 + src/Locales/fr/Pode.psd1 | 8 + src/Locales/it/Pode.psd1 | 8 + src/Locales/ja/Pode.psd1 | 8 + src/Locales/ko/Pode.psd1 | 8 + src/Locales/nl/Pode.psd1 | 8 + src/Locales/pl/Pode.psd1 | 8 + src/Locales/pt/Pode.psd1 | 8 + src/Locales/zh/Pode.psd1 | 8 + src/Pode.psd1 | 11 + src/Pode.psm1 | 9 + src/PodeMonitor/IPausableHostedService.cs | 26 + src/PodeMonitor/PipeNameGenerator.cs | 40 + src/PodeMonitor/PodeLogLevel.cs | 15 + src/PodeMonitor/PodeMonitor.cs | 448 +++++ src/PodeMonitor/PodeMonitor.csproj | 26 + src/PodeMonitor/PodeMonitorLogger.cs | 219 +++ src/PodeMonitor/PodeMonitorMain.cs | 232 +++ src/PodeMonitor/PodeMonitorServiceState.cs | 51 + src/PodeMonitor/PodeMonitorWindowsService.cs | 135 ++ src/PodeMonitor/PodeMonitorWorker.cs | 226 +++ src/PodeMonitor/PodeMonitorWorkerOptions.cs | 109 ++ src/PodeMonitor/PodeServiceStateExtensions.cs | 46 + src/Private/Context.ps1 | 25 +- src/Private/Helpers.ps1 | 285 ++- src/Private/Logging.ps1 | 30 +- src/Private/Runspaces.ps1 | 1 - src/Private/Server.ps1 | 2 + src/Private/Service.ps1 | 1644 +++++++++++++++++ src/Public/Core.ps1 | 34 +- src/Public/Endpoint.ps1 | 2 +- src/Public/Metrics.ps1 | 15 +- src/Public/Service.ps1 | 1184 ++++++++++++ src/Public/Utilities.ps1 | 21 +- tests/integration/Service.Tests.ps1 | 107 ++ tests/unit/Context.Tests.ps1 | 6 +- tests/unit/Routes.Tests.ps1 | 2 +- tests/unit/Service.Tests.ps1 | 237 +++ 54 files changed, 6194 insertions(+), 199 deletions(-) create mode 100644 docs/Hosting/PortsBelow1024.md create mode 100644 examples/HelloService/HelloService.ps1 create mode 100644 examples/HelloService/HelloServices.ps1 create mode 100644 examples/HelloWorld/servicesettings.json create mode 100644 src/PodeMonitor/IPausableHostedService.cs create mode 100644 src/PodeMonitor/PipeNameGenerator.cs create mode 100644 src/PodeMonitor/PodeLogLevel.cs create mode 100644 src/PodeMonitor/PodeMonitor.cs create mode 100644 src/PodeMonitor/PodeMonitor.csproj create mode 100644 src/PodeMonitor/PodeMonitorLogger.cs create mode 100644 src/PodeMonitor/PodeMonitorMain.cs create mode 100644 src/PodeMonitor/PodeMonitorServiceState.cs create mode 100644 src/PodeMonitor/PodeMonitorWindowsService.cs create mode 100644 src/PodeMonitor/PodeMonitorWorker.cs create mode 100644 src/PodeMonitor/PodeMonitorWorkerOptions.cs create mode 100644 src/PodeMonitor/PodeServiceStateExtensions.cs create mode 100644 src/Private/Service.ps1 create mode 100644 src/Public/Service.ps1 create mode 100644 tests/integration/Service.Tests.ps1 create mode 100644 tests/unit/Service.Tests.ps1 diff --git a/.github/workflows/ci-powershell.yml b/.github/workflows/ci-powershell.yml index adc4cd1f2..900d1806b 100644 --- a/.github/workflows/ci-powershell.yml +++ b/.github/workflows/ci-powershell.yml @@ -51,8 +51,18 @@ jobs: - name: Run Pester Tests shell: powershell run: | - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: powershell diff --git a/.github/workflows/ci-pwsh7_5.yml b/.github/workflows/ci-pwsh7_5.yml index 1bf1704ea..9b0362d73 100644 --- a/.github/workflows/ci-pwsh7_5.yml +++ b/.github/workflows/ci-pwsh7_5.yml @@ -72,7 +72,18 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: pwsh diff --git a/.github/workflows/ci-pwsh_lts.yml b/.github/workflows/ci-pwsh_lts.yml index 70ab3d0d3..de2e79856 100644 --- a/.github/workflows/ci-pwsh_lts.yml +++ b/.github/workflows/ci-pwsh_lts.yml @@ -71,7 +71,18 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: pwsh diff --git a/.github/workflows/ci-pwsh_preview.yml b/.github/workflows/ci-pwsh_preview.yml index 43198540f..60c2e9c0d 100644 --- a/.github/workflows/ci-pwsh_preview.yml +++ b/.github/workflows/ci-pwsh_preview.yml @@ -71,7 +71,18 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: pwsh diff --git a/.gitignore b/.gitignore index 937948c82..081dfe316 100644 --- a/.gitignore +++ b/.gitignore @@ -267,6 +267,8 @@ examples/PetStore/data/PetData.json packers/choco/pode.nuspec packers/choco/tools/ChocolateyInstall.ps1 docs/Getting-Started/Samples.md +examples/HelloService/*_svcsettings.json +examples/HelloService/svc_settings # Dump Folder Dump diff --git a/Pode.sln b/Pode.sln index 66eb3805a..02438e28a 100644 --- a/Pode.sln +++ b/Pode.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{41F81369-868 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Pode", "src\Listener\Pode.csproj", "{772D5C9F-1B25-46A7-8977-412A5F7F77D1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PodeMonitor", "src\PodeMonitor\PodeMonitor.csproj", "{A927D6A5-A2AC-471A-9ABA-45916B597EB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/docs/Hosting/PortsBelow1024.md b/docs/Hosting/PortsBelow1024.md new file mode 100644 index 000000000..c5a1c31fa --- /dev/null +++ b/docs/Hosting/PortsBelow1024.md @@ -0,0 +1,55 @@ +# Using Ports Below 1024 + +#### Introduction + +Traditionally in Linux, binding to ports below 1024 requires root privileges. This is a security measure, as these low-numbered ports are considered privileged. However, running applications as the root user poses significant security risks. This article explores methods to use these privileged ports with PowerShell (`pwsh`) in Linux, without running it as the root user. +There are different methods to achieve the goals. +Reverse Proxy is the right approach for a production environment, primarily if the server is connected directly to the internet. +The other solutions are reasonable after an in-depth risk analysis. + +#### Using a Reverse Proxy + +A reverse proxy like Nginx can listen on the privileged port and forward requests to your application running on an unprivileged port. + +**Configuration:** + +* Configure Nginx to listen on port 443 and forward requests to the port where your PowerShell script is listening. +* This method is widely used in web applications for its additional benefits like load balancing and SSL termination. + +#### iptables Redirection + +Using iptables, you can redirect traffic from a privileged port to a higher, unprivileged port. + +**Implementation:** + +* Set up an iptables rule to redirect traffic from, say, port 443 to a higher port where your PowerShell script is listening. +* `sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080` + +**Benefits:** + +* This approach doesn't require changing the privileges of the PowerShell executable or script. + +#### Using `setcap` Command + +The `setcap` utility can grant specific capabilities to an executable, like `pwsh`, enabling it to bind to privileged ports. + +**How it Works:** + +* Run `sudo setcap 'cap_net_bind_service=+ep' $(which pwsh)`. This command sets the `CAP_NET_BIND_SERVICE` capability on the PowerShell executable, allowing it to bind to any port below 1024. + +**Security Consideration:** + +* This method enhances security by avoiding running PowerShell as root, but it still grants significant privileges to the PowerShell process. + +#### Utilizing Authbind + +Authbind is a tool that allows a non-root user to bind to privileged ports. + +**Setup:** + +* Install Authbind, configure it to allow the desired port, and then start your PowerShell script using Authbind. +* For instance, `authbind --deep pwsh yourscript.ps1` allows the script to bind to a privileged port. + +**Advantages:** + +* It provides a finer-grained control over port access and doesn't require setting special capabilities on the PowerShell binary itself. diff --git a/docs/Hosting/RunAsService.md b/docs/Hosting/RunAsService.md index 2872e7d0f..734a7fbb8 100644 --- a/docs/Hosting/RunAsService.md +++ b/docs/Hosting/RunAsService.md @@ -1,141 +1,275 @@ -# Service +# Using Pode as a Service -Rather than having to manually invoke your Pode server script each time, it's best if you can have it start automatically when your computer/server starts. Below you'll see how to set your script to run as either a Windows or a Linux service. +Pode provides built-in functions to easily manage services across platforms (Windows, Linux, macOS). These functions allow you to register, start, stop, suspend, resume, query, and unregister Pode services in a cross-platform way. -!!! Note - When running Pode as a service, it is recommended to use `Start-PodeServer` with the `-Daemon` parameter. This ensures the server operates in a detached and background-friendly mode suitable for long-running processes. The `-Daemon` parameter optimizes Pode's behavior for service execution by suppressing interactive output and allowing the process to run seamlessly in the background. +--- -## Windows +## Registering a Service -To run your Pode server as a Windows service, we recommend using the [`NSSM`](https://nssm.cc) tool. To install on Windows you can use Chocolatey: +The `Register-PodeService` function creates the necessary service files and configurations for your system. + +#### Example: ```powershell -choco install nssm -y +Register-PodeService -Name "HelloService" -Description "Example Pode Service" -ParameterString "-Verbose" -Start ``` -Once installed, you'll need to set the location of the `pwsh` or `powershell` executables as a variable: +This registers a service named "HelloService" and starts it immediately after registration. The service runs your Pode script with the specified parameters. -```powershell -$exe = (Get-Command pwsh.exe).Source +### `Register-PodeService` Parameters -# or +The `Register-PodeService` function offers several parameters to customize your service registration: -$exe = (Get-Command powershell.exe).Source -``` +- **`-Name`** *(string)*: + The name of the service to register. + **Mandatory**. + +- **`-Description`** *(string)*: + A brief description of the service. Defaults to `"This is a Pode service."`. + +- **`-DisplayName`** *(string)*: + The display name for the service (Windows only). Defaults to `"Pode Service($Name)"`. + +- **`-StartupType`** *(string)*: + Specifies the startup type of the service (`'Automatic'` or `'Manual'`). Defaults to `'Automatic'`. + +- **`-ParameterString`** *(string)*: + Additional parameters to pass to the worker script when the service is run. Defaults to an empty string. + +- **`-LogServicePodeHost`** *(switch)*: + Enables logging for the Pode service host. + +- **`-ShutdownWaitTimeMs`** *(int)*: + Maximum time in milliseconds to wait for the service to shut down gracefully before forcing termination. Defaults to `30,000 ms`. + +- **`-StartMaxRetryCount`** *(int)*: + Maximum number of retries to start the PowerShell process before giving up. Defaults to `3`. + +- **`-StartRetryDelayMs`** *(int)*: + Delay in milliseconds between retry attempts to start the PowerShell process. Defaults to `5,000 ms`. + +- **`-WindowsUser`** *(string)*: + Specifies the username under which the service will run. Defaults to the current user (Windows only). + +- **`-LinuxUser`** *(string)*: + Specifies the username under which the service will run. Defaults to the current user (Linux Only). + +- **`-Agent`** *(switch)*: + Create an Agent instead of a Daemon in MacOS (MacOS Only). -Next, define the name of the Windows service; as well as the full file path to your Pode server script, and the arguments to be supplied to PowerShell: +- **`-Start`** *(switch)*: + Starts the service immediately after registration. + +- **`-Password`** *(securestring)*: + A secure password for the service account (Windows only). If omitted, the service account will be `'NT AUTHORITY\SYSTEM'`. + +- **`-SecurityDescriptorSddl`** *(string)*: + A security descriptor in SDDL format specifying the permissions for the service (Windows only). + +- **`-SettingsPath`** *(string)*: + Directory to store the service configuration file (`_svcsettings.json`). Defaults to a directory under the script path. + +- **`-LogPath`** *(string)*: + Path for the service log files. Defaults to a directory under the script path. + +--- + +## Starting a Service + +You can start a registered service using the `Start-PodeService` function. + +#### Example: ```powershell -$name = 'Pode Web Server' -$file = 'C:\Pode\Server.ps1' -$arg = "-ExecutionPolicy Bypass -NoProfile -Command `"$($file)`"" +Start-PodeService -Name "HelloService" ``` -Finally, install and start the service: +This returns `$true` if the service starts successfully, `$false` otherwise. + +--- + +## Stopping a Service + +To stop a running service, use the `Stop-PodeService` function. + +#### Example: ```powershell -nssm install $name $exe $arg -nssm start $name +Stop-PodeService -Name "HelloService" ``` -!!! info - You can now navigate to your server, ie: `http://localhost:8080`. +This returns `$true` if the service stops successfully, `$false` otherwise. + +--- + +## Suspending a Service -To stop (or remove) the service afterwards, you can use the following: +Suspend a running service (Windows only) with the `Suspend-PodeService` function. + +#### Example: ```powershell -nssm stop $name -nssm remove $name confirm +Suspend-PodeService -Name "HelloService" ``` -## Linux +This pauses the service, returning `$true` if successful. + +--- + +## Resuming a Service + +Resume a suspended service (Windows only) using the `Resume-PodeService` function. -To run your Pode server as a Linux service you just need to create a `.service` file at `/etc/systemd/system`. The following is example content for an example `pode-server.service` file, which run PowerShell Core (`pwsh`), as well as you script: +#### Example: -```bash -sudo vim /etc/systemd/system/pode-server.service +```powershell +Resume-PodeService -Name "HelloService" ``` -```bash -[Unit] -Description=Pode Web Server -After=network.target +This resumes the service, returning `$true` if successful. + +--- + +## Querying a Service -[Service] -ExecStart=/usr/bin/pwsh -c /usr/src/pode/server.ps1 -nop -ep Bypass -Restart=always +To check the status of a service, use the `Get-PodeService` function. -[Install] -WantedBy=multi-user.target -Alias=pode-server.service +#### Example: + +```powershell +Get-PodeService -Name "HelloService" ``` -Finally, start the service: +This returns a hashtable with the service details: + +```powershell +Name Value +---- ----- +Status Running +Pid 17576 +Name HelloService +Sudo True +``` + +--- + +## Restarting a Service + +Restart a running service using the `Restart-PodeService` function. + +#### Example: ```powershell -sudo systemctl start pode-server +Restart-PodeService -Name "HelloService" ``` -!!! info - You can now navigate to your server, ie: `http://localhost:8080`. +This stops and starts the service, returning `$true` if successful. + +--- + +## Unregistering a Service -To stop the service afterwards, you can use the following: +When you no longer need a service, unregister it with the `Unregister-PodeService` function. + +#### Example: ```powershell -sudo systemctl stop pode-server +Unregister-PodeService -Name "HelloService" -Force ``` -### Using Ports Below 1024 -#### Introduction +This forcefully stops and removes the service, returning `$true` if successful. + +--- + +## Alternative Methods for Windows and Linux + +If the Pode functions are unavailable or you prefer manual management, you can use traditional methods to configure Pode as a service. + +### Windows (NSSM) + +To use NSSM for Pode as a Windows service: + +1. Install NSSM using Chocolatey: + + ```powershell + choco install nssm -y + ``` + +2. Configure the service: + + ```powershell + $exe = (Get-Command pwsh.exe).Source + $name = 'Pode Web Server' + $file = 'C:\Pode\Server.ps1' + $arg = "-ExecutionPolicy Bypass -NoProfile -Command `"$($file)`"" + nssm install $name $exe $arg + nssm start $name + ``` -Traditionally in Linux, binding to ports below 1024 requires root privileges. This is a security measure, as these low-numbered ports are considered privileged. However, running applications as the root user poses significant security risks. This article explores methods to use these privileged ports with PowerShell (`pwsh`) in Linux, without running it as the root user. -There are different methods to achieve the goals. -Reverse Proxy is the right approach for a production environment, primarily if the server is connected directly to the internet. -The other solutions are reasonable after an in-depth risk analysis. +3. Stop or remove the service: -#### Using a Reverse Proxy + ```powershell + nssm stop $name + nssm remove $name confirm + ``` -A reverse proxy like Nginx can listen on the privileged port and forward requests to your application running on an unprivileged port. +--- -**Configuration:** +### Linux (systemd) -* Configure Nginx to listen on port 443 and forward requests to the port where your PowerShell script is listening. -* This method is widely used in web applications for its additional benefits like load balancing and SSL termination. +To configure Pode as a Linux service: -#### iptables Redirection +1. Create a service file: -Using iptables, you can redirect traffic from a privileged port to a higher, unprivileged port. + ```bash + sudo vim /etc/systemd/system/pode-server.service + ``` -**Implementation:** +2. Add the following configuration: -* Set up an iptables rule to redirect traffic from, say, port 443 to a higher port where your PowerShell script is listening. -* `sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080` + ```bash + [Unit] + Description=Pode Web Server + After=network.target -**Benefits:** + [Service] + ExecStart=/usr/bin/pwsh -c /usr/src/pode/server.ps1 -nop -ep Bypass + Restart=always -* This approach doesn't require changing the privileges of the PowerShell executable or script. + [Install] + WantedBy=multi-user.target + Alias=pode-server.service + ``` -#### Using `setcap` Command +3. Start and stop the service: -The `setcap` utility can grant specific capabilities to an executable, like `pwsh`, enabling it to bind to privileged ports. + ```bash + sudo systemctl start pode-server + sudo systemctl stop pode-server + ``` -**How it Works:** +--- -* Run `sudo setcap 'cap_net_bind_service=+ep' $(which pwsh)`. This command sets the `CAP_NET_BIND_SERVICE` capability on the PowerShell executable, allowing it to bind to any port below 1024. +## Using Ports Below 1024 -**Security Consideration:** +For privileged ports, consider: -* This method enhances security by avoiding running PowerShell as root, but it still grants significant privileges to the PowerShell process. +1. **Reverse Proxy:** Use Nginx to forward traffic from port 443 to an unprivileged port. -#### Utilizing Authbind +2. **iptables Redirection:** Redirect port 443 to an unprivileged port: -Authbind is a tool that allows a non-root user to bind to privileged ports. + ```bash + sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080 + ``` -**Setup:** +3. **setcap Command:** Grant PowerShell permission to bind privileged ports: -* Install Authbind, configure it to allow the desired port, and then start your PowerShell script using Authbind. -* For instance, `authbind --deep pwsh yourscript.ps1` allows the script to bind to a privileged port. + ```bash + sudo setcap 'cap_net_bind_service=+ep' $(which pwsh) + ``` -**Advantages:** +4. **Authbind:** Configure Authbind to allow binding to privileged ports: -* It provides a finer-grained control over port access and doesn't require setting special capabilities on the PowerShell binary itself. + ```bash + authbind --deep pwsh yourscript.ps1 + ``` diff --git a/examples/HelloService/HelloService.ps1 b/examples/HelloService/HelloService.ps1 new file mode 100644 index 000000000..678c8bb99 --- /dev/null +++ b/examples/HelloService/HelloService.ps1 @@ -0,0 +1,196 @@ +<# +.SYNOPSIS + PowerShell script to register, manage, and set up a Pode service named '$ServiceName'. + +.DESCRIPTION + This script provides commands to register, start, stop, query, suspend, resume, restart, and unregister a Pode service named '$ServiceName'. + It also sets up a Pode server that listens on the specified port (default 8080) and includes a basic GET route that responds with 'Hello, Service!'. + + The script checks if the Pode module exists locally and imports it; otherwise, it imports Pode from the system. + + To test the Pode server's HTTP endpoint: + Invoke-RestMethod -Uri http://localhost:8080/ -Method Get + # Response: 'Hello, Service!' + +.PARAMETER ServiceName + Name of the service to register (Default 'Hello Service'). + +.PARAMETER Register + Registers the $ServiceName with Pode. + +.PARAMETER Password + A secure password for the service account (Windows only). If omitted, the service account will be 'NT AUTHORITY\SYSTEM'. + +.PARAMETER Agent + Defines the service as an Agent instead of a Daemon.(macOS only) + +.PARAMETER Unregister + Unregisters the $ServiceName from Pode. Use with the -Force switch to forcefully unregister the service. + +.PARAMETER Force + Used with the -Unregister parameter to forcefully unregister the service. + +.PARAMETER Start + Starts the $ServiceName. + +.PARAMETER Stop + Stops the $ServiceName. + +.PARAMETER Query + Queries the status of the $ServiceName. + +.PARAMETER Suspend + Suspends the $ServiceName. + +.PARAMETER Resume + Resumes the $ServiceName. + +.PARAMETER Restart + Restarts the $ServiceName. + +.EXAMPLE + Register the service: + ./HelloService.ps1 -Register + +.EXAMPLE + Start the service: + ./HelloService.ps1 -Start + +.EXAMPLE + Query the service: + ./HelloService.ps1 -Query + +.EXAMPLE + Stop the service: + ./HelloService.ps1 -Stop + +.EXAMPLE + Unregister the service: + ./HelloService.ps1 -Unregister -Force + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/HelloService/HelloService.ps1 + +.NOTES + Author: Pode Team + License: MIT License +#> + +[CmdletBinding(DefaultParameterSetName = 'Inbuilt')] +param( + [Parameter( ParameterSetName = 'Inbuilt')] + [int] + $Port = 8080, + + [Parameter( ParameterSetName = 'Inbuilt')] + [string] + $ServiceName = 'Hello Service', + + [Parameter(Mandatory = $true, ParameterSetName = 'Register')] + [switch] + $Register, + + [Parameter(Mandatory = $false, ParameterSetName = 'Register', ValueFromPipeline = $true )] + [securestring] + $Password, + + [switch] + $Agent, + + [Parameter(Mandatory = $true, ParameterSetName = 'Unregister')] + [switch] + $Unregister, + + [Parameter( ParameterSetName = 'Unregister')] + [switch] + $Force, + + [Parameter( ParameterSetName = 'Start')] + [switch] + $Start, + + [Parameter( ParameterSetName = 'Stop')] + [switch] + $Stop, + + [Parameter( ParameterSetName = 'Query')] + [switch] + $Query, + + [Parameter( ParameterSetName = 'Suspend')] + [switch] + $Suspend, + + [Parameter( ParameterSetName = 'Resume')] + [switch] + $Resume, + + [Parameter( ParameterSetName = 'Restart')] + [switch] + $Restart +) +try { + # Get the path of the script being executed + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + # Get the parent directory of the script's path + $podePath = Split-Path -Parent -Path $ScriptPath + + # Check if the Pode module file exists in the specified path + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + # If the Pode module file exists, import it + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + # If the Pode module file does not exist, import the Pode module from the system + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { + # If there is any error during the module import, throw the error + throw +} + + +if ( $Register.IsPresent) { + return Register-PodeService -Name $ServiceName -ParameterString "-Port $Port" -Password $Password -Agent:($Agent.IsPresent) +} +if ( $Unregister.IsPresent) { + return Unregister-PodeService -Name $ServiceName -Force:$Force -Agent:($Agent.IsPresent) +} +if ($Start.IsPresent) { + return Start-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Stop.IsPresent) { + return Stop-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Suspend.IsPresent) { + return Suspend-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Resume.IsPresent) { + return Resume-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Query.IsPresent) { + return Get-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Restart.IsPresent) { + return Restart-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +# Start the Pode server +Start-PodeServer { + New-PodeLoggingMethod -File -Name 'errors' -MaxDays 4 -Path './logs' | Enable-PodeErrorLogging -Levels Informational + + # Add an HTTP endpoint listening on localhost at port 8080 + Add-PodeEndpoint -Address localhost -Port $Port -Protocol Http + + # Add a route for GET requests to the root path '/' + Add-PodeRoute -Method Get -Path '/' -ScriptBlock { + # Send a text response with 'Hello, world!' + Write-PodeTextResponse -Value 'Hello, Service!' + } +} diff --git a/examples/HelloService/HelloServices.ps1 b/examples/HelloService/HelloServices.ps1 new file mode 100644 index 000000000..94b9730c3 --- /dev/null +++ b/examples/HelloService/HelloServices.ps1 @@ -0,0 +1,191 @@ +<# +.SYNOPSIS + Script to manage multiple Pode services and set up a basic Pode server. + +.DESCRIPTION + This script registers, starts, stops, queries, and unregisters multiple Pode services based on the specified hashtable. + Additionally, it sets up a Pode server that listens on a defined port and includes routes to handle incoming HTTP requests. + + The script checks if the Pode module exists in the local path and imports it; otherwise, it uses the system-wide Pode module. + +.PARAMETER Register + Registers all services specified in the hashtable. + +.PARAMETER Password + A secure password for the service account (Windows only). If omitted, the service account will be 'NT AUTHORITY\SYSTEM'. + +.PARAMETER Agent + Defines the service as an Agent instead of a Daemon.(macOS only) + +.PARAMETER Unregister + Unregisters all services specified in the hashtable. Use with -Force to force unregistration. + +.PARAMETER Force + Forces unregistration when used with the -Unregister parameter. + +.PARAMETER Start + Starts all services specified in the hashtable. + +.PARAMETER Stop + Stops all services specified in the hashtable. + +.PARAMETER Query + Queries the status of all services specified in the hashtable. + +.PARAMETER Suspend + Suspend the 'Hello Service'. + +.PARAMETER Resume + Resume the 'Hello Service'. + +.PARAMETER Restart + Restart the 'Hello Service'. + +.EXAMPLE + Register all services: + ./HelloServices.ps1 -Register + +.EXAMPLE + Start all services: + ./HelloServices.ps1 -Start + +.EXAMPLE + Query the status of all services: + ./HelloServices.ps1 -Query + +.EXAMPLE + Stop all services: + ./HelloServices.ps1 -Stop + +.EXAMPLE + Forcefully unregister all services: + ./HelloServices.ps1 -Unregister -Force + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/HelloService/HelloServices.ps1 + +.NOTES + Author: Pode Team + License: MIT License +#> + + +[CmdletBinding(DefaultParameterSetName = 'Inbuilt')] +param( + [Parameter( ParameterSetName = 'Inbuilt')] + [int] + $Port = 8080, + + [Parameter(Mandatory = $true, ParameterSetName = 'Register')] + [switch] + $Register, + + [Parameter(Mandatory = $false, ParameterSetName = 'Register', ValueFromPipeline = $true )] + [securestring] + $Password, + + [switch] + $Agent, + + [Parameter(Mandatory = $true, ParameterSetName = 'Unregister')] + [switch] + $Unregister, + + [Parameter( ParameterSetName = 'Unregister')] + [switch] + $Force, + + [Parameter( ParameterSetName = 'Start')] + [switch] + $Start, + + [Parameter( ParameterSetName = 'Stop')] + [switch] + $Stop, + + [Parameter( ParameterSetName = 'Query')] + [switch] + $Query, + + [Parameter( ParameterSetName = 'Suspend')] + [switch] + $Suspend, + + [Parameter( ParameterSetName = 'Resume')] + [switch] + $Resume, + + [Parameter( ParameterSetName = 'Restart')] + [switch] + $Restart +) +try { + # Get the path of the script being executed + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + # Get the parent directory of the script's path + $podePath = Split-Path -Parent -Path $ScriptPath + + # Check if the Pode module file exists in the specified path + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + # If the Pode module file exists, import it + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + # If the Pode module file does not exist, import the Pode module from the system + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { + # If there is any error during the module import, throw the error + throw +} +$services = @{ + 'HelloService1' = 8081 + 'HelloService2' = 8082 + 'HelloService3' = 8083 +} + +if ( $Register.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Register-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) -ParameterString "-Port $($_.Value)" -Password $Password } +} +if ( $Unregister.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { try { Unregister-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) -Force:$Force }catch { Write-Error -Exception $_.Exception } } + +} +if ($Start.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Start-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Stop.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Stop-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Query.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Get-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Resume.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Resume-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Query.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Get-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Restart.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Restart-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +# Start the Pode server +Start-PodeServer { + New-PodeLoggingMethod -File -Name "errors-$port" -MaxDays 4 -Path './logs' | Enable-PodeErrorLogging -Levels Informational + + # Add an HTTP endpoint listening on localhost at port 8080 + Add-PodeEndpoint -Address localhost -Port $Port -Protocol Http + + # Add a route for GET requests to the root path '/' + Add-PodeRoute -Method Get -Path '/' -ScriptBlock { + # Send a text response with 'Hello, world!' + Write-PodeTextResponse -Value 'Hello, Service!' + } +} diff --git a/examples/HelloWorld/servicesettings.json b/examples/HelloWorld/servicesettings.json new file mode 100644 index 000000000..881b69b12 --- /dev/null +++ b/examples/HelloWorld/servicesettings.json @@ -0,0 +1,11 @@ +{ + "PodeMonitorWorker ": { + "ScriptPath": "C:\\Users\\m_dan\\Documents\\GitHub\\Pode\\examples\\HelloWorld\\HelloWorld.ps1", + "PwshPath": "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + "ParameterString": "", + "LogFilePath": "C:\\Users\\m_dan\\Documents\\GitHub\\Pode\\examples\\HelloWorld\\logs\\PodeMonitorService.Prod.log", + "Quiet": true, + "DisableTermination": true, + "ShutdownWaitTimeMs": 30000 + } +} \ No newline at end of file diff --git a/pode.build.ps1 b/pode.build.ps1 index e6eb172af..ea16064cd 100644 --- a/pode.build.ps1 +++ b/pode.build.ps1 @@ -110,6 +110,9 @@ param( [string] $UICulture = 'en-US', + [switch] + $DisableLifecycleServiceOperations, + [string[]] [ValidateSet('netstandard2.0', 'net8.0', 'net9.0', 'net10.0')] $TargetFrameworks = @('netstandard2.0', 'net8.0', 'net9.0'), @@ -134,28 +137,51 @@ $Versions = @{ <# .SYNOPSIS - Checks if the current environment is running on Windows. + Installs a specified package using the appropriate package manager for the OS. .DESCRIPTION - This function determines if the current PowerShell session is running on Windows. - It inspects `$PSVersionTable.Platform` and `$PSVersionTable.PSEdition` to verify the OS, - returning `$true` for Windows and `$false` for other platforms. + This function installs a specified package at a given version using platform-specific + package managers. For Windows, it uses Chocolatey (`choco`). On Unix-based systems, + it checks for `brew`, `apt-get`, and `yum` to handle installations. The function sets + the security protocol to TLS 1.2 to ensure secure connections during the installation. + +.PARAMETER name + The name of the package to install (e.g., 'git'). + +.PARAMETER version + The version of the package to install, required only for Chocolatey on Windows. .OUTPUTS - [bool] - Returns `$true` if the current environment is Windows, otherwise `$false`. + None. .EXAMPLE - if (Test-PodeBuildIsWindows) { - Write-Host "This script is running on Windows." - } + Invoke-PodeBuildInstall -Name 'git' -Version '2.30.0' + # Installs version 2.30.0 of Git on Windows if Chocolatey is available. .NOTES - - Useful for cross-platform scripts to conditionally execute Windows-specific commands. - - The `$PSVersionTable.Platform` variable may be `$null` in certain cases, so `$PSEdition` is used as an additional check. + - Requires administrator or sudo privileges on Unix-based systems. + - This function supports package installation on both Windows and Unix-based systems. + - If `choco` is available, it will use `choco` for Windows, and `brew`, `apt-get`, or `yum` for Unix-based systems. #> -function Test-PodeBuildIsWindows { - $v = $PSVersionTable - return ($v.Platform -ilike '*win*' -or ($null -eq $v.Platform -and $v.PSEdition -ieq 'desktop')) +function Invoke-PodeBuildInstall($name, $version) { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + if (Test-PodeBuildIsWindows) { + if (Test-PodeBuildCommand 'choco') { + choco install $name --version $version -y --no-progress + } + } + else { + if (Test-PodeBuildCommand 'brew') { + brew install $name + } + elseif (Test-PodeBuildCommand 'apt-get') { + sudo apt-get install $name -y + } + elseif (Test-PodeBuildCommand 'yum') { + sudo yum install $name -y + } + } } <# @@ -272,76 +298,54 @@ function Test-PodeBuildCommand($cmd) { <# .SYNOPSIS - Retrieves the branch name from the GitHub Actions environment variable. + Checks if the current environment is running on Windows. .DESCRIPTION - This function extracts the branch name from the `GITHUB_REF` environment variable, - which is commonly set in GitHub Actions workflows. It removes the 'refs/heads/' prefix - from the branch reference, leaving only the branch name. + This function determines if the current PowerShell session is running on Windows. + It inspects `$PSVersionTable.Platform` and `$PSVersionTable.PSEdition` to verify the OS, + returning `$true` for Windows and `$false` for other platforms. .OUTPUTS - [string] - The name of the GitHub branch. + [bool] - Returns `$true` if the current environment is Windows, otherwise `$false`. .EXAMPLE - $branch = Get-PodeBuildBranch - Write-Host "Current branch: $branch" - # Output example: Current branch: main + if (Test-PodeBuildIsWindows) { + Write-Host "This script is running on Windows." + } .NOTES - - Only relevant in environments where `GITHUB_REF` is defined (e.g., GitHub Actions). - - Returns an empty string if `GITHUB_REF` is not set. + - Useful for cross-platform scripts to conditionally execute Windows-specific commands. + - The `$PSVersionTable.Platform` variable may be `$null` in certain cases, so `$PSEdition` is used as an additional check. #> -function Get-PodeBuildBranch { - return ($env:GITHUB_REF -ireplace 'refs\/heads\/', '') +function Test-PodeBuildIsWindows { + $v = $PSVersionTable + return ($v.Platform -ilike '*win*' -or ($null -eq $v.Platform -and $v.PSEdition -ieq 'desktop')) } + <# .SYNOPSIS - Installs a specified package using the appropriate package manager for the OS. + Retrieves the branch name from the GitHub Actions environment variable. .DESCRIPTION - This function installs a specified package at a given version using platform-specific - package managers. For Windows, it uses Chocolatey (`choco`). On Unix-based systems, - it checks for `brew`, `apt-get`, and `yum` to handle installations. The function sets - the security protocol to TLS 1.2 to ensure secure connections during the installation. - -.PARAMETER name - The name of the package to install (e.g., 'git'). - -.PARAMETER version - The version of the package to install, required only for Chocolatey on Windows. + This function extracts the branch name from the `GITHUB_REF` environment variable, + which is commonly set in GitHub Actions workflows. It removes the 'refs/heads/' prefix + from the branch reference, leaving only the branch name. .OUTPUTS - None. + [string] - The name of the GitHub branch. .EXAMPLE - Invoke-PodeBuildInstall -Name 'git' -Version '2.30.0' - # Installs version 2.30.0 of Git on Windows if Chocolatey is available. + $branch = Get-PodeBuildBranch + Write-Host "Current branch: $branch" + # Output example: Current branch: main .NOTES - - Requires administrator or sudo privileges on Unix-based systems. - - This function supports package installation on both Windows and Unix-based systems. - - If `choco` is available, it will use `choco` for Windows, and `brew`, `apt-get`, or `yum` for Unix-based systems. + - Only relevant in environments where `GITHUB_REF` is defined (e.g., GitHub Actions). + - Returns an empty string if `GITHUB_REF` is not set. #> -function Invoke-PodeBuildInstall($name, $version) { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - - if (Test-PodeBuildIsWindows) { - if (Test-PodeBuildCommand 'choco') { - choco install $name --version $version -y --no-progress - } - } - else { - if (Test-PodeBuildCommand 'brew') { - brew install $name - } - elseif (Test-PodeBuildCommand 'apt-get') { - sudo apt-get install $name -y - } - elseif (Test-PodeBuildCommand 'yum') { - sudo yum install $name -y - } - } +function Get-PodeBuildBranch { + return ($env:GITHUB_REF -ireplace 'refs\/heads\/', '') } <# @@ -514,11 +518,104 @@ function Invoke-PodeBuildDotnetBuild { # Use dotnet publish for .NET Core and .NET 5+ dotnet publish --configuration Release --self-contained --framework $target $AssemblyVersion $AssemblyPrerelease --output ../Libs/$target + # Throw an error if the build fails if (!$?) { throw "Build failed for target framework '$target'." } } +<# +.SYNOPSIS + Builds the Pode Monitor Service for multiple target platforms using .NET SDK. + +.DESCRIPTION + This function automates the build process for the Pode Monitor Service. It: + - Determines the highest installed .NET SDK version. + - Verifies compatibility with the required SDK version. + - Optionally sets an assembly version during the build. + - Builds the service for specified runtime targets across platforms (Windows, Linux, macOS). + - Allows defining custom constants for conditional compilation. + +.PARAMETER Version + Specifies the assembly version to use for the build. If not provided, no version is set. + +.PARAMETER DisableLifecycleServiceOperations + If specified, excludes lifecycle service operations during the build by omitting related compilation constants. + +.INPUTS + None. The function does not accept pipeline input. + +.OUTPUTS + None. The function produces build artifacts in the output directory. + +.NOTES + This function is designed to work with .NET SDK and assumes it is installed and configured properly. + It throws an error if the build process fails for any target. + +.EXAMPLE + Invoke-PodeBuildDotnetMonitorSrvBuild -Version "1.0.0" + + Builds the Pode Monitor Service with an assembly version of 1.0.0. + +.EXAMPLE + Invoke-PodeBuildDotnetMonitorSrvBuild -DisableLifecycleServiceOperations + + Builds the Pode Monitor Service without lifecycle service operations. + +.EXAMPLE + Invoke-PodeBuildDotnetMonitorSrvBuild + + Builds the Pode Monitor Service for all target runtimes without a specific assembly version. +#> +function Invoke-PodeBuildDotnetMonitorSrvBuild() { + # Retrieve the highest installed SDK version + $majorVersion = ([version](dotnet --version)).Major + + # Determine if the target framework is compatible + $isCompatible = $majorVersions -ge $requiredSdkVersion + + # Skip build if not compatible + if ($isCompatible) { + Write-Output "SDK for target framework '$target' is compatible with the '$AvailableSdkVersion' framework." + } + else { + Write-Warning "SDK for target framework '$target' is not compatible with the '$AvailableSdkVersion' framework. Skipping build." + return + } + + # Optionally set assembly version + if ($Version) { + Write-Host "Assembly Version $Version" + $AssemblyVersion = "-p:Version=$Version" + } + else { + $AssemblyVersion = '' + } + + foreach ($target in @('win-x64', 'win-arm64' , 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64','linux-arm','win-x86','linux-musl-x64')) { + $DefineConstants = @() + $ParamConstants = '' + + # Add compilation constants if lifecycle operations are enabled + if (!$DisableLifecycleServiceOperations) { + $DefineConstants += 'ENABLE_LIFECYCLE_OPERATIONS' + } + + # Prepare constants for the build parameters + if ($DefineConstants.Count -gt 0) { + $ParamConstants = "-p:DefineConstants=`"$( $DefineConstants -join ';')`"" + } + + # Perform the build for the target runtime + dotnet publish --runtime $target --output ../Bin/$target --configuration Release $AssemblyVersion $ParamConstants + + # Throw an error if the build fails + if (!$?) { + throw "dotnet publish failed for $($target)" + } + } +} + <# .SYNOPSIS Retrieves the end-of-life (EOL) and supported versions of PowerShell. @@ -567,7 +664,7 @@ function Get-PodeBuildPwshEOL { .DESCRIPTION This function detects whether the current operating system is Windows by checking - the `$IsWindows` automatic variable, the presence of the `$env:ProgramFiles` variable, + the `Test-PodeBuildIsWindows` automatic variable, the presence of the `$env:ProgramFiles` variable, and the PowerShell Edition in `$PSVersionTable`. This function returns `$true` if any of these indicate Windows. @@ -896,8 +993,6 @@ function Split-PodeBuildPwshPath { } } - - # Check if the script is running under Invoke-Build if (($null -eq $PSCmdlet.MyInvocation) -or ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('BuildRoot') -and ($null -eq $BuildRoot))) { Write-Host 'This script is intended to be run with Invoke-Build. Please use Invoke-Build to execute the tasks defined in this script.' -ForegroundColor Yellow @@ -1130,9 +1225,6 @@ Add-BuildTask Build BuildDeps, { Remove-Item -Path ./src/Libs -Recurse -Force | Out-Null } - - - # Retrieve the SDK version being used # $dotnetVersion = dotnet --version @@ -1153,6 +1245,20 @@ Add-BuildTask Build BuildDeps, { Pop-Location } + if (Test-Path ./src/Bin) { + Remove-Item -Path ./src/Bin -Recurse -Force | Out-Null + } + + try { + Push-Location ./src/PodeMonitor + Invoke-PodeBuildDotnetMonitorSrvBuild + } + finally { + Pop-Location + } + + + } @@ -1234,7 +1340,7 @@ Add-BuildTask PackageFolder Build, { New-Item -Path $path -ItemType Directory -Force | Out-Null # which source folders do we need? create them and copy their contents - $folders = @('Private', 'Public', 'Misc', 'Libs', 'Locales') + $folders = @('Private', 'Public', 'Misc', 'Libs', 'Locales', 'Bin') $folders | ForEach-Object { New-Item -ItemType Directory -Path (Join-Path $path $_) -Force | Out-Null Copy-Item -Path "./src/$($_)/*" -Destination (Join-Path $path $_) -Force -Recurse | Out-Null @@ -1266,11 +1372,62 @@ Add-BuildTask TestNoBuild TestDeps, { Remove-Module Pester -Force -ErrorAction Ignore Import-Module Pester -Force -RequiredVersion $Versions.Pester } - + Write-Output '' # for windows, output current netsh excluded ports if (Test-PodeBuildIsWindows) { netsh int ipv4 show excludedportrange protocol=tcp | Out-Default + + # Retrieve the current Windows identity and token + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + + # Gather user information + $user = $identity.Name + $isElevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $adminStatus = if ($isElevated) { 'Administrator' } else { 'Standard User' } + $groups = $identity.Groups | ForEach-Object { + try { + $_.Translate([Security.Principal.NTAccount]).Value + } + catch { + $_.Value # Fallback to SID if translation fails + } + } + + # Generate output + Write-Output 'Pester Execution Context (Windows):' + Write-Output " - User: $user" + Write-Output " - Role: $adminStatus" + Write-Output " - Elevated Privileges: $isElevated" + Write-Output " - Group Memberships: $( $groups -join ', ')" + } + + + if ($IsLinux) { + $user = whoami + $groupsRaw = (groups $user | Out-String).Trim() + $groups = $groupsRaw -split '\s+' | Where-Object { $_ -ne ':' } | Sort-Object -Unique + + # Check for sudo privileges based on group membership + $isSudoUser = $groups -match '\bwheel\b' -or $groups -match '\badmin\b' -or $groups -match '\bsudo\b' -or $groups -match '\badm\b' + + Write-Output 'Pester Execution Context (Linux):' + Write-Output " - User: $user" + Write-Output " - Groups: $( $groups -join ', ')" + Write-Output " - Sudo: $($isSudoUser -eq $true)" } + + if ($IsMacOS) { + $user = whoami + $groups = (id -Gn $user).Split(' ') # Use `id -Gn` for consistent group names on macOS + $formattedGroups = $groups -join ', ' + Write-Output 'Pester Execution Context (macOS):' + Write-Output " - User: $user" + Write-Output " - Groups: $formattedGroups" + } + + Write-Output '' + if ($UICulture -ne ([System.Threading.Thread]::CurrentThread.CurrentUICulture) ) { $originalUICulture = [System.Threading.Thread]::CurrentThread.CurrentUICulture Write-Output "Original UICulture is $originalUICulture" @@ -1450,6 +1607,12 @@ Add-BuildTask CleanLibs { Remove-Item -Path $path -Recurse -Force | Out-Null } + $path = './src/Bin' + if (Test-Path -Path $path -PathType Container) { + Write-Host "Removing $path contents" + Remove-Item -Path $path -Recurse -Force | Out-Null + } + Write-Host "Cleanup $path done" } @@ -1656,7 +1819,7 @@ Add-BuildTask SetupPowerShell { #> # Synopsis: Build the Release Notes -task ReleaseNotes { +Add-BuildTask ReleaseNotes { if ([string]::IsNullOrWhiteSpace($ReleaseNoteVersion)) { Write-Host 'Please provide a ReleaseNoteVersion' -ForegroundColor Red return diff --git a/src/Locales/ar/Pode.psd1 b/src/Locales/ar/Pode.psd1 index 5d5b3f9ba..08c1d306f 100644 --- a/src/Locales/ar/Pode.psd1 +++ b/src/Locales/ar/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "الدالة '{0}' لا تقبل مصفوفة كمدخل لأنبوب البيانات." unsupportedStreamCompressionEncodingExceptionMessage = 'تشفير الضغط غير مدعوم للتشفير {0}' localEndpointConflictExceptionMessage = "تم تعريف كل من '{0}' و '{1}' كنقاط نهاية محلية لـ OpenAPI، لكن يُسمح فقط بنقطة نهاية محلية واحدة لكل تعريف API." + serviceAlreadyRegisteredException = "الخدمة '{0}' مسجلة بالفعل." + serviceIsNotRegisteredException = "الخدمة '{0}' غير مسجلة." + serviceCommandFailedException = "فشل الأمر '{0}' في الخدمة '{1}'." + serviceRegistrationException = "فشل تسجيل الخدمة '{0}'." + serviceIsRunningException = "الخدمة '{0}' تعمل. استخدم المعامل -Force للإيقاف بالقوة." + serviceUnRegistrationException = "فشل إلغاء تسجيل الخدمة '{0}'." + passwordRequiredForServiceUserException = "مطلوب كلمة مرور عند تحديد مستخدم الخدمة في نظام Windows. يرجى تقديم كلمة مرور صالحة للمستخدم '{0}'." + featureNotSupportedException = '{0} مدعومة فقط على نظام التشغيل Windows.' suspendingMessage = 'تعليق' resumingMessage = 'استئناف' serverControlCommandsTitle = 'أوامر التحكم بالخادم:' diff --git a/src/Locales/de/Pode.psd1 b/src/Locales/de/Pode.psd1 index d99aa754b..464caa43d 100644 --- a/src/Locales/de/Pode.psd1 +++ b/src/Locales/de/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Die Funktion '{0}' akzeptiert kein Array als Pipeline-Eingabe." unsupportedStreamCompressionEncodingExceptionMessage = 'Die Stream-Komprimierungskodierung wird nicht unterstützt: {0}' localEndpointConflictExceptionMessage = "Sowohl '{0}' als auch '{1}' sind als lokale OpenAPI-Endpunkte definiert, aber es ist nur ein lokaler Endpunkt pro API-Definition erlaubt." + serviceAlreadyRegisteredException = "Der Dienst '{0}' ist bereits registriert." + serviceIsNotRegisteredException = "Der Dienst '{0}' ist nicht registriert." + serviceCommandFailedException = "Der Dienstbefehl '{0}' ist bei dem Dienst '{1}' fehlgeschlagen." + serviceRegistrationException = "Die Registrierung des Dienstes '{0}' ist fehlgeschlagen." + serviceIsRunningException = "Der Dienst '{0}' läuft. Verwenden Sie den Parameter -Force, um den Dienst zwangsweise zu stoppen." + serviceUnRegistrationException = "Die Abmeldung des Dienstes '{0}' ist fehlgeschlagen." + passwordRequiredForServiceUserException = "Ein Passwort ist erforderlich, wenn ein Dienstbenutzer unter Windows angegeben wird. Bitte geben Sie ein gültiges Passwort für den Benutzer '{0}' an." + featureNotSupportedException = '{0} wird nur unter Windows unterstützt.' suspendingMessage = 'Anhalten' resumingMessage = 'Fortsetzen' serverControlCommandsTitle = 'Serversteuerbefehle:' diff --git a/src/Locales/en-us/Pode.psd1 b/src/Locales/en-us/Pode.psd1 index ec9f3f137..3912880b7 100644 --- a/src/Locales/en-us/Pode.psd1 +++ b/src/Locales/en-us/Pode.psd1 @@ -290,7 +290,15 @@ getRequestBodyNotAllowedExceptionMessage = "'{0}' operations cannot have a Request Body. Use -AllowNonStandardBody to override this restriction." fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "The function '{0}' does not accept an array as pipeline input." unsupportedStreamCompressionEncodingExceptionMessage = 'Unsupported stream compression encoding: {0}' - localEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition." + LocalEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition." + serviceAlreadyRegisteredException = "Service '{0}' is already registered." + serviceIsNotRegisteredException = "Service '{0}' is not registered." + serviceCommandFailedException = "Service command '{0}' failed on service '{1}'." + serviceRegistrationException = "Service '{0}' registration failed." + serviceIsRunningException = "Service '{0}' is running. Use the -Force parameter to forcefully stop." + serviceUnRegistrationException = "Service '{0}' unregistration failed." + passwordRequiredForServiceUserException = "A password is required when specifying a service user on Windows. Please provide a valid password for the user '{0}'." + featureNotSupportedException = '{0} is supported only on Windows.' suspendingMessage = 'Suspending' resumingMessage = 'Resuming' serverControlCommandsTitle = 'Server Control Commands:' diff --git a/src/Locales/en/Pode.psd1 b/src/Locales/en/Pode.psd1 index ed54596fe..cc4f43f53 100644 --- a/src/Locales/en/Pode.psd1 +++ b/src/Locales/en/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "The function '{0}' does not accept an array as pipeline input." unsupportedStreamCompressionEncodingExceptionMessage = 'Unsupported stream compression encoding: {0}' localEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition." + serviceAlreadyRegisteredException = "Service '{0}' is already registered." + serviceIsNotRegisteredException = "Service '{0}' is not registered." + serviceCommandFailedException = "Service command '{0}' failed on service '{1}'." + serviceRegistrationException = "Service '{0}' registration failed." + serviceIsRunningException = "Service '{0}' is running. Use the -Force parameter to forcefully stop." + serviceUnRegistrationException = "Service '{0}' unregistration failed." + passwordRequiredForServiceUserException = "A password is required when specifying a service user on Windows. Please provide a valid password for the user '{0}'." + featureNotSupportedException = '{0} is supported only on Windows.' suspendingMessage = 'Suspending' resumingMessage = 'Resuming' serverControlCommandsTitle = 'Server Control Commands:' diff --git a/src/Locales/es/Pode.psd1 b/src/Locales/es/Pode.psd1 index 58fab3d68..23f450eed 100644 --- a/src/Locales/es/Pode.psd1 +++ b/src/Locales/es/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La función '{0}' no acepta una matriz como entrada de canalización." unsupportedStreamCompressionEncodingExceptionMessage = 'La codificación de compresión de transmisión no es compatible: {0}' localEndpointConflictExceptionMessage = "Tanto '{0}' como '{1}' están definidos como puntos finales locales de OpenAPI, pero solo se permite un punto final local por definición de API." + serviceAlreadyRegisteredException = "El servicio '{0}' ya está registrado." + serviceIsNotRegisteredException = "El servicio '{0}' no está registrado." + serviceCommandFailedException = "El comando del servicio '{0}' falló en el servicio '{1}'." + serviceRegistrationException = "Falló el registro del servicio '{0}'." + serviceIsRunningException = "El servicio '{0}' está en ejecución. Utilice el parámetro -Force para detenerlo a la fuerza." + serviceUnRegistrationException = "La anulación del registro del servicio '{0}' falló." + passwordRequiredForServiceUserException = "Se requiere una contraseña al especificar un usuario de servicio en Windows. Por favor, proporcione una contraseña válida para el usuario '{0}'." + featureNotSupportedException = '{0} solo es compatible con Windows.' suspendingMessage = 'Suspendiendo' resumingMessage = 'Reanudando' serverControlCommandsTitle = 'Comandos de control del servidor:' diff --git a/src/Locales/fr/Pode.psd1 b/src/Locales/fr/Pode.psd1 index acc75938c..3944c211c 100644 --- a/src/Locales/fr/Pode.psd1 +++ b/src/Locales/fr/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La fonction '{0}' n'accepte pas un tableau en tant qu'entrée de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = "La compression de flux {0} n'est pas prise en charge." localEndpointConflictExceptionMessage = "Les deux '{0}' et '{1}' sont définis comme des points de terminaison locaux pour OpenAPI, mais un seul point de terminaison local est autorisé par définition d'API." + serviceAlreadyRegisteredException = "Le service '{0}' est déjà enregistré." + serviceIsNotRegisteredException = "Le service '{0}' n'est pas enregistré." + serviceCommandFailedException = "La commande de service '{0}' a échoué sur le service '{1}'." + serviceRegistrationException = "Échec de l'enregistrement du service '{0}'." + serviceIsRunningException = "Le service '{0}' est en cours d'exécution. Utilisez le paramètre -Force pour forcer l'arrêt." + serviceUnRegistrationException = "La désinscription du service '{0}' a échoué." + passwordRequiredForServiceUserException = "Un mot de passe est requis lors de la spécification d'un utilisateur de service sous Windows. Veuillez fournir un mot de passe valide pour l'utilisateur '{0}'." + featureNotSupportedException = '{0} est pris en charge uniquement sous Windows.' suspendingMessage = 'Suspension' resumingMessage = 'Reprise' serverControlCommandsTitle = 'Commandes de contrôle du serveur :' diff --git a/src/Locales/it/Pode.psd1 b/src/Locales/it/Pode.psd1 index ce2dbf7cd..b229026c2 100644 --- a/src/Locales/it/Pode.psd1 +++ b/src/Locales/it/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La funzione '{0}' non accetta una matrice come input della pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'La compressione dello stream non è supportata per la codifica {0}' localEndpointConflictExceptionMessage = "Sia '{0}' che '{1}' sono definiti come endpoint locali OpenAPI, ma è consentito solo un endpoint locale per definizione API." + serviceAlreadyRegisteredException = "Il servizio '{0}' è già registrato." + serviceIsNotRegisteredException = "Il servizio '{0}' non è registrato." + serviceCommandFailedException = "Il comando '{0}' è fallito sul servizio '{1}'." + serviceRegistrationException = "Registrazione del servizio '{0}' non riuscita." + serviceIsRunningException = "Il servizio '{0}' è in esecuzione. Utilizzare il parametro -Force per interromperlo forzatamente." + serviceUnRegistrationException = "La cancellazione della registrazione del servizio '{0}' è fallita." + passwordRequiredForServiceUserException = "È richiesta una password quando si specifica un utente del servizio su Windows. Si prega di fornire una password valida per l'utente '{0}'." + featureNotSupportedException = '{0} è supportato solo su Windows.' suspendingMessage = 'Sospensione' resumingMessage = 'Ripresa' serverControlCommandsTitle = 'Comandi di controllo del server:' diff --git a/src/Locales/ja/Pode.psd1 b/src/Locales/ja/Pode.psd1 index fba3f10c1..5107cf479 100644 --- a/src/Locales/ja/Pode.psd1 +++ b/src/Locales/ja/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "関数 '{0}' は配列をパイプライン入力として受け付けません。" unsupportedStreamCompressionEncodingExceptionMessage = 'サポートされていないストリーム圧縮エンコーディングが提供されました: {0}' localEndpointConflictExceptionMessage = "'{0}' と '{1}' は OpenAPI のローカルエンドポイントとして定義されていますが、API 定義ごとに 1 つのローカルエンドポイントのみ許可されます。" + serviceAlreadyRegisteredException = "サービス '{0}' はすでに登録されています。" + serviceIsNotRegisteredException = "サービス '{0}' は登録されていません。" + serviceCommandFailedException = "サービスコマンド '{0}' はサービス '{1}' で失敗しました。" + serviceRegistrationException = "サービス '{0}' の登録に失敗しました。" + serviceIsRunningException = "サービス '{0}' が実行中です。強制的に停止するには、-Force パラメーターを使用してください。" + serviceUnRegistrationException = "サービス '{0}' の登録解除に失敗しました。" + passwordRequiredForServiceUserException = "Windowsでサービスユーザーを指定する際にはパスワードが必要です。ユーザー '{0}' に有効なパスワードを入力してください。" + featureNotSupportedException = '{0} は Windows のみでサポートされています。' suspendingMessage = '停止' resumingMessage = '再開' serverControlCommandsTitle = 'サーバーコントロールコマンド:' diff --git a/src/Locales/ko/Pode.psd1 b/src/Locales/ko/Pode.psd1 index d26a73d0b..6623c6351 100644 --- a/src/Locales/ko/Pode.psd1 +++ b/src/Locales/ko/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "함수 '{0}'은(는) 배열을 파이프라인 입력으로 받지 않습니다." unsupportedStreamCompressionEncodingExceptionMessage = '지원되지 않는 스트림 압축 인코딩: {0}' localEndpointConflictExceptionMessage = "'{0}' 와 '{1}' 는 OpenAPI 로컬 엔드포인트로 정의되었지만, API 정의당 하나의 로컬 엔드포인트만 허용됩니다." + serviceAlreadyRegisteredException = "서비스 '{0}'가 이미 등록되었습니다." + serviceIsNotRegisteredException = "서비스 '{0}'가 등록되지 않았습니다." + serviceCommandFailedException = "서비스 명령 '{0}' 이(가) 서비스 '{1}' 에서 실패했습니다." + serviceRegistrationException = "서비스 '{0}' 등록에 실패했습니다." + serviceIsRunningException = "서비스 '{0}'가 실행 중입니다. 강제로 중지하려면 -Force 매개변수를 사용하세요." + serviceUnRegistrationException = "서비스 '{0}' 등록 취소에 실패했습니다." + passwordRequiredForServiceUserException = "Windows에서 서비스 사용자를 지정할 때는 비밀번호가 필요합니다. 사용자 '{0}'에 대해 유효한 비밀번호를 입력하세요." + featureNotSupportedException = '{0}는 Windows에서만 지원됩니다.' suspendingMessage = '중단' resumingMessage = '재개' serverControlCommandsTitle = '서버 제어 명령:' diff --git a/src/Locales/nl/Pode.psd1 b/src/Locales/nl/Pode.psd1 index 147f5fc1e..84b40c199 100644 --- a/src/Locales/nl/Pode.psd1 +++ b/src/Locales/nl/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "De functie '{0}' accepteert geen array als pipeline-invoer." unsupportedStreamCompressionEncodingExceptionMessage = 'Niet-ondersteunde streamcompressie-encodering: {0}' localEndpointConflictExceptionMessage = "Zowel '{0}' als '{1}' zijn gedefinieerd als lokale OpenAPI-eindpunten, maar er is slechts één lokaal eindpunt per API-definitie toegestaan." + serviceAlreadyRegisteredException = "De service '{0}' is al geregistreerd." + serviceIsNotRegisteredException = "De service '{0}' is niet geregistreerd." + serviceCommandFailedException = "De serviceopdracht '{0}' is mislukt op de service '{1}'." + serviceRegistrationException = "Registratie van de service '{0}' is mislukt." + serviceIsRunningException = "De service '{0}' draait. Gebruik de parameter -Force om de service geforceerd te stoppen." + serviceUnRegistrationException = "Het afmelden van de service '{0}' is mislukt." + passwordRequiredForServiceUserException = "Een wachtwoord is vereist bij het specificeren van een servicegebruiker in Windows. Geef een geldig wachtwoord op voor de gebruiker '{0}'." + featureNotSupportedException = '{0} wordt alleen ondersteund op Windows.' suspendingMessage = 'Onderbreken' resumingMessage = 'Hervatten' serverControlCommandsTitle = "Serverbedieningscommando's:" diff --git a/src/Locales/pl/Pode.psd1 b/src/Locales/pl/Pode.psd1 index dab099cd6..6ce8a3898 100644 --- a/src/Locales/pl/Pode.psd1 +++ b/src/Locales/pl/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Funkcja '{0}' nie akceptuje tablicy jako wejścia potoku." unsupportedStreamCompressionEncodingExceptionMessage = 'Kodowanie kompresji strumienia nie jest obsługiwane: {0}' localEndpointConflictExceptionMessage = "Zarówno '{0}', jak i '{1}' są zdefiniowane jako lokalne punkty końcowe OpenAPI, ale na jedną definicję API dozwolony jest tylko jeden lokalny punkt końcowy." + serviceAlreadyRegisteredException = "Usługa '{0}' jest już zarejestrowana." + serviceIsNotRegisteredException = "Usługa '{0}' nie jest zarejestrowana." + serviceCommandFailedException = "Polecenie serwisu '{0}' nie powiodło się w serwisie '{1}'." + serviceRegistrationException = "Rejestracja usługi '{0}' nie powiodła się." + serviceIsRunningException = "Usługa '{0}' jest uruchomiona. Użyj parametru -Force, aby wymusić zatrzymanie." + serviceUnRegistrationException = "Nie udało się wyrejestrować usługi '{0}'." + passwordRequiredForServiceUserException = "Wymagane jest hasło podczas określania użytkownika usługi w systemie Windows. Podaj prawidłowe hasło dla użytkownika '{0}'." + featureNotSupportedException = '{0} jest obsługiwane tylko w systemie Windows.' suspendingMessage = 'Wstrzymywanie' resumingMessage = 'Wznawianie' serverControlCommandsTitle = 'Polecenia sterowania serwerem:' diff --git a/src/Locales/pt/Pode.psd1 b/src/Locales/pt/Pode.psd1 index e458fa5e7..a044485ee 100644 --- a/src/Locales/pt/Pode.psd1 +++ b/src/Locales/pt/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "A função '{0}' não aceita uma matriz como entrada de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'A codificação de compressão de fluxo não é suportada.' localEndpointConflictExceptionMessage = "Tanto '{0}' quanto '{1}' estão definidos como endpoints locais do OpenAPI, mas apenas um endpoint local é permitido por definição de API." + serviceAlreadyRegisteredException = "O serviço '{0}' já está registrado." + serviceIsNotRegisteredException = "O serviço '{0}' não está registrado." + serviceCommandFailedException = "O comando do serviço '{0}' falhou no serviço '{1}'." + serviceRegistrationException = "Falha no registro do serviço '{0}'." + serviceIsRunningException = "O serviço '{0}' está em execução. Use o parâmetro -Force para forçar a parada." + serviceUnRegistrationException = "A anulação do registro do serviço '{0}' falhou." + passwordRequiredForServiceUserException = "Uma senha é necessária ao especificar um usuário de serviço no Windows. Por favor, forneça uma senha válida para o usuário '{0}'." + featureNotSupportedException = '{0} é compatível apenas com o Windows.' suspendingMessage = 'Suspensão' resumingMessage = 'Retomada' serverControlCommandsTitle = 'Comandos de controle do servidor:' diff --git a/src/Locales/zh/Pode.psd1 b/src/Locales/zh/Pode.psd1 index f4dedcd1d..8d953bd1d 100644 --- a/src/Locales/zh/Pode.psd1 +++ b/src/Locales/zh/Pode.psd1 @@ -291,6 +291,14 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "函数 '{0}' 不接受数组作为管道输入。" unsupportedStreamCompressionEncodingExceptionMessage = '不支持的流压缩编码: {0}' localEndpointConflictExceptionMessage = "'{0}' 和 '{1}' 都被定义为 OpenAPI 的本地端点,但每个 API 定义仅允许一个本地端点。" + serviceAlreadyRegisteredException = "服务 '{0}' 已经注册。" + serviceIsNotRegisteredException = "服务 '{0}' 未注册。" + serviceCommandFailedException = "服务命令 '{0}' 在服务 '{1}' 上失败。" + serviceRegistrationException = "服务 '{0}' 注册失败。" + serviceIsRunningException = "服务 '{0}' 正在运行。使用 -Force 参数强制停止。" + serviceUnRegistrationException = "服务 '{0}' 的注销失败。" + passwordRequiredForServiceUserException = "在 Windows 中指定服务用户时需要密码。请为用户 '{0}' 提供有效的密码。" + featureNotSupportedException = '{0} 仅支持 Windows。' suspendingMessage = '暂停' resumingMessage = '恢复' serverControlCommandsTitle = '服务器控制命令:' diff --git a/src/Pode.psd1 b/src/Pode.psd1 index 00e0bd337..4af2f0c7b 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -546,6 +546,17 @@ 'New-PodeLimitEndpointComponent', 'New-PodeLimitMethodComponent', 'New-PodeLimitHeaderComponent' + 'Use-PodeScopedVariables', + + # service + 'Register-PodeService', + 'Unregister-PodeService', + 'Start-PodeService', + 'Stop-PodeService', + 'Get-PodeService', + 'Suspend-PodeService', + 'Resume-PodeService', + 'Restart-PodeService' ) # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. diff --git a/src/Pode.psm1 b/src/Pode.psm1 index 8e117f136..213c86c7d 100644 --- a/src/Pode.psm1 +++ b/src/Pode.psm1 @@ -146,6 +146,15 @@ try { Export-ModuleMember -Function ($funcs.Name) } } + + # Define Properties Display + if (!(Get-TypeData -TypeName 'PodeService')) { + $TypeData = @{ + TypeName = 'PodeService' + DefaultDisplayPropertySet = 'Name', 'Status', 'Pid' + } + Update-TypeData @TypeData + } } catch { throw ("Failed to load the Pode module. $_") diff --git a/src/PodeMonitor/IPausableHostedService.cs b/src/PodeMonitor/IPausableHostedService.cs new file mode 100644 index 000000000..ce0426fbe --- /dev/null +++ b/src/PodeMonitor/IPausableHostedService.cs @@ -0,0 +1,26 @@ +namespace PodeMonitor +{ + /// + /// Defines a contract for a hosted service that supports pausing and resuming. + /// + public interface IPausableHostedService + { + /// + /// Pauses the hosted service. + /// This method is called when the service receives a pause command. + /// + void OnPause(); + + /// + /// Resumes the hosted service. + /// This method is called when the service receives a continue command after being paused. + /// + void OnContinue(); + + + + void Restart(); + + public PodeMonitorServiceState State { get; } + } +} diff --git a/src/PodeMonitor/PipeNameGenerator.cs b/src/PodeMonitor/PipeNameGenerator.cs new file mode 100644 index 000000000..6a4299dba --- /dev/null +++ b/src/PodeMonitor/PipeNameGenerator.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +namespace PodeMonitor +{ + public static class PipeNameGenerator + { + private const int MaxUnixPathLength = 104; // Max length for Unix domain sockets on macOS + private const string UnixTempDir = "/tmp"; // Short temporary directory for Unix systems + + public static string GeneratePipeName() + { + // Generate a unique name based on a GUID + string uniqueId = Guid.NewGuid().ToString("N").Substring(0, 8); + + if (OperatingSystem.IsWindows()) + { + // Use Windows named pipe format + return $"PodePipe_{uniqueId}"; + } + else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + // Use Unix domain socket format with a shorter temp directory + //string pipePath = Path.Combine(UnixTempDir, $"PodePipe_{uniqueId}"); + string pipePath = $"PodePipe_{uniqueId}"; + + // Ensure the path is within the allowed length for Unix domain sockets + if (pipePath.Length > MaxUnixPathLength) + { + throw new InvalidOperationException($"Generated pipe path exceeds the maximum length of {MaxUnixPathLength} characters: {pipePath}"); + } + + return pipePath; + } + else + { + throw new PlatformNotSupportedException("Unsupported operating system for pipe name generation."); + } + } + } +} diff --git a/src/PodeMonitor/PodeLogLevel.cs b/src/PodeMonitor/PodeLogLevel.cs new file mode 100644 index 000000000..ed91a0884 --- /dev/null +++ b/src/PodeMonitor/PodeLogLevel.cs @@ -0,0 +1,15 @@ + +namespace PodeMonitor +{ + /// + /// Enum representing the various log levels for PodeMonitorLogger. + /// + public enum PodeLogLevel + { + DEBUG, // Detailed information for debugging purposes + INFO, // General operational information + WARN, // Warning messages for potential issues + ERROR, // Error messages for failures + CRITICAL // Critical errors indicating severe failures + } +} \ No newline at end of file diff --git a/src/PodeMonitor/PodeMonitor.cs b/src/PodeMonitor/PodeMonitor.cs new file mode 100644 index 000000000..446c050cb --- /dev/null +++ b/src/PodeMonitor/PodeMonitor.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Threading; + +namespace PodeMonitor +{ + + /// + /// Class responsible for managing and monitoring the Pode PowerShell process. + /// Provides functionality for starting, stopping, suspending, resuming, and restarting the process. + /// Communicates with the Pode process via named pipes. + /// + public class PodeMonitor + { + private readonly object _syncLock = new(); // Synchronization lock for thread safety + private Process _powerShellProcess; // PowerShell process instance + private NamedPipeClientStream _pipeClient; // Named pipe client for inter-process communication + + // Configuration properties + private readonly string _scriptPath; // Path to the Pode script + private readonly string _parameterString; // Parameters passed to the script + private readonly string _pwshPath; // Path to the PowerShell executable + private readonly int _shutdownWaitTimeMs; // Timeout for shutting down the process + private readonly string _stateFilePath; // Path to the service state file + + private DateTime _lastLogTime; // Tracks the last time the process logged activity + + public int StartMaxRetryCount { get; } // Maximum number of retries for starting the process + public int StartRetryDelayMs { get; } // Delay between retries in milliseconds + + private volatile PodeMonitorServiceState _state; + + + public PodeMonitorServiceState State { get => _state; set => _state = value; } + + public bool DisableTermination { get => _serviceJson.DisableTermination; } + + private class ServiceJson(PodeMonitorWorkerOptions options) + { + public readonly string PipeName = PipeNameGenerator.GeneratePipeName(); // Name of the named pipe for communication + public readonly bool Quiet = options.Quiet; // Indicates whether the process runs in quiet mode + public readonly bool DisableTermination = options.DisableTermination; // Indicates whether termination is disabled + public readonly bool DisableConsoleInput = options.DisableConsoleInput; // Disables all console interactions for the server + public readonly bool IgnoreServerConfig = options.IgnoreServerConfig; // Prevents the server from loading settings from the server.psd1 configuration file + public readonly string ConfigFile = options.ConfigFile.Replace("\\", "\\\\"); // Specifies a custom configuration file instead of using the default `server.psd1` + } + + private readonly ServiceJson _serviceJson; + + /// + /// Initializes a new instance of the class with the specified configuration options. + /// + /// Configuration options for the PodeMonitor. + public PodeMonitor(PodeMonitorWorkerOptions options) + { + // Initialize configuration properties + _scriptPath = options.ScriptPath; + _pwshPath = options.PwshPath; + _parameterString = options.ParameterString; + _shutdownWaitTimeMs = options.ShutdownWaitTimeMs; + StartMaxRetryCount = options.StartMaxRetryCount; + StartRetryDelayMs = options.StartRetryDelayMs; + + // Initialize the _serviceJson object with values from options + _serviceJson = new ServiceJson(options); + + // Define the state file path only for Linux/macOS + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + + string homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string stateDirectory = OperatingSystem.IsLinux() + ? "/run/podemonitor" + : OperatingSystem.IsMacOS() + ? Path.Combine(homeDirectory, "Library", "LaunchAgents", "PodeMonitor") + : throw new PlatformNotSupportedException("The current platform is not supported for setting the state directory."); + try + { + if (!Directory.Exists(stateDirectory)) + { + Directory.CreateDirectory(stateDirectory); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, + $"Failed to create state directory at {stateDirectory}: {ex.Message}"); + throw; + } + + // Define the state file path (default to /var/tmp for Linux/macOS) + _stateFilePath = Path.Combine(stateDirectory, $"{Environment.ProcessId}.state"); + + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Initialized PodeMonitor with pipe name: {0} and state file: {1}", _serviceJson.PipeName, _stateFilePath); + } + } + + /// + /// Starts the Pode PowerShell process. If the process is already running, logs its status. + /// + public void StartPowerShellProcess() + { + lock (_syncLock) + { + if (_powerShellProcess != null && !_powerShellProcess.HasExited) + { + if ((DateTime.Now - _lastLogTime).TotalMinutes >= 5) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process is Alive."); + _lastLogTime = DateTime.Now; + } + return; + } + + try + { + // Configure the PowerShell process + _powerShellProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _pwshPath, + Arguments = BuildCommand(), + RedirectStandardOutput = true, // Redirect standard output for logging + RedirectStandardError = true, // Redirect standard error for logging + UseShellExecute = false, // Run without using shell execution + CreateNoWindow = true // Prevent the creation of a window + } + }; + + // Subscribe to the output stream for logging and state parsing + _powerShellProcess.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "Pode", _powerShellProcess.Id, args.Data); + ParseServiceState(args.Data); + } + }; + + // Subscribe to the error stream for logging errors + _powerShellProcess.ErrorDataReceived += (sender, args) => + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "Pode", _powerShellProcess.Id, args.Data); + }; + + // Start the process and begin reading the output/error streams + _powerShellProcess.Start(); + _powerShellProcess.BeginOutputReadLine(); + _powerShellProcess.BeginErrorReadLine(); + + _lastLogTime = DateTime.Now; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process started successfully."); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Failed to start Pode process: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + } + } + + /// + /// Stops the Pode PowerShell process gracefully. If it does not terminate, it is forcefully killed. + /// + public void StopPowerShellProcess() + { + lock (_syncLock) + { + if (_powerShellProcess == null || (_powerShellProcess.HasExited && Process.GetProcessById(_powerShellProcess.Id) == null)) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process is not running."); + return; + } + + try + { + if (InitializePipeClientWithRetry()) + { + SendPipeMessage("stop"); + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Waiting for {_shutdownWaitTimeMs} milliseconds for Pode process to exit..."); + WaitForProcessExit(_shutdownWaitTimeMs); + + if (_powerShellProcess != null && !_powerShellProcess.HasExited) + { + PodeMonitorLogger.Log(PodeLogLevel.WARN, "PodeMonitor", Environment.ProcessId, $"Pode process has exited:{_powerShellProcess.HasExited} Id:{_powerShellProcess.Id}"); + + PodeMonitorLogger.Log(PodeLogLevel.WARN, "PodeMonitor", Environment.ProcessId, "Pode process did not terminate gracefully. Killing process."); + _powerShellProcess.Kill(); + } + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process stopped successfully."); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Error stopping Pode process: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + finally + { + CleanupResources(); + } + } + } + + /// + /// Sends a suspend command to the Pode process via named pipe. + /// + public void SuspendPowerShellProcess() => ExecutePipeCommand("suspend"); + + /// + /// Sends a resume command to the Pode process via named pipe. + /// + public void ResumePowerShellProcess() => ExecutePipeCommand("resume"); + + /// + /// Sends a restart command to the Pode process via named pipe. + /// + public void RestartPowerShellProcess() => ExecutePipeCommand("restart"); + + + + /// + /// Executes a command by sending it to the Pode process via named pipe. + /// + /// The command to execute (e.g., "suspend", "resume", "restart"). + private void ExecutePipeCommand(string command) + { + lock (_syncLock) + { + try + { + if (InitializePipeClientWithRetry()) + { + SendPipeMessage(command); + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"{command.ToUpper()} command sent to Pode process."); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Error executing {command} command: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + finally + { + CleanupPipeClient(); + } + } + } + + /// + /// Parses the service state from the provided output message and updates the state variables. + /// + /// The output message containing the service state. + private void ParseServiceState(string output) + { + if (string.IsNullOrWhiteSpace(output)) return; + + if (output.StartsWith("Service State: ", StringComparison.OrdinalIgnoreCase)) + { + string state = output["Service State: ".Length..].Trim(); + + // Convert the extracted string to a PodeMonitorServiceState enum + PodeMonitorServiceState parsedState = state.ToPodeMonitorServiceState(); + + // Update the service state + UpdateServiceState(parsedState); + } + } + + /// + /// Updates the internal state variables based on the provided service state. + /// + /// The new service state. + private void UpdateServiceState(PodeMonitorServiceState state) + { + _state = state; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Service state updated to: {state}"); + // Write the state to the state file only on Linux/macOS + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + // Write the state to the state file + WriteServiceStateToFile(state); + } + } + + /// + /// Builds the PowerShell command to execute the Pode process. + /// + /// The PowerShell command string. + private string BuildCommand() + { + string podeServiceJson = + $"{{\\\"DisableTermination\\\": {_serviceJson.DisableTermination.ToString().ToLower()}, " + + $"\\\"Quiet\\\": {_serviceJson.Quiet.ToString().ToLower()}, " + + $"\\\"DisableConsoleInput\\\": {_serviceJson.DisableConsoleInput.ToString().ToLower()}, " + + $"\\\"IgnoreServerConfig\\\": {_serviceJson.IgnoreServerConfig.ToString().ToLower()}, " + + $"\\\"ConfigFile\\\": \\\"{_serviceJson.ConfigFile}\\\", " + + $"\\\"PipeName\\\": \\\"{_serviceJson.PipeName}\\\"}}"; + + return $"-NoProfile -Command \"& {{ $global:PodeService = '{podeServiceJson}' | ConvertFrom-Json; . '{_scriptPath}' {_parameterString} }}\""; + } + + + /// + /// Initializes the named pipe client with a retry mechanism. + /// + /// The maximum number of retries for connection. + /// True if the pipe client is successfully connected; otherwise, false. + private bool InitializePipeClientWithRetry(int maxRetries = 3) + { + int attempts = 0; + + while (attempts < maxRetries) + { + try + { + if (_pipeClient == null) + { + _pipeClient = new NamedPipeClientStream(".", _serviceJson.PipeName, PipeDirection.InOut); + } + + if (!_pipeClient.IsConnected) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Connecting to pipe server (Attempt {attempts + 1})..."); + _pipeClient.Connect(10000); // Timeout of 10 seconds + } + + return _pipeClient.IsConnected; + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Pipe connection attempt {attempts + 1} failed: {ex.Message}"); + } + + attempts++; + Thread.Sleep(1000); + } + + return false; + } + + /// + /// Sends a message to the Pode process via named pipe. + /// + /// The message to send. + private void SendPipeMessage(string message) + { + try + { + using var writer = new StreamWriter(_pipeClient) { AutoFlush = true }; + writer.WriteLine(message); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Error sending message to pipe: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + } + + /// + /// Waits for the Pode process to exit within the specified timeout. + /// + /// The timeout period in milliseconds. + private void WaitForProcessExit(int timeout) + { + int waited = 0; + while (!_powerShellProcess.HasExited && waited < timeout) + { + Thread.Sleep(200); + waited += 200; + } + } + + + /// + /// Writes the current service state to the state file. + /// + /// The service state to write. + private void WriteServiceStateToFile(PodeMonitorServiceState state) + { + lock (_syncLock) // Ensure thread-safe access + { + try + { + File.WriteAllText(_stateFilePath, state.ToString().ToLowerInvariant()); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, $"Service state written to file: {_stateFilePath}"); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Failed to write service state to file: {ex.Message}"); + } + } + } + + /// + /// Deletes the service state file during cleanup. + /// + private void DeleteServiceStateFile() + { + lock (_syncLock) // Ensure thread-safe access + { + try + { + if (File.Exists(_stateFilePath)) + { + File.Delete(_stateFilePath); + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Service state file deleted: {_stateFilePath}"); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Failed to delete service state file: {ex.Message}"); + } + } + } + + /// + /// Cleans up resources associated with the Pode process and the pipe client. + /// + private void CleanupResources() + { + _powerShellProcess?.Dispose(); + _powerShellProcess = null; + + CleanupPipeClient(); + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + DeleteServiceStateFile(); + } + } + + /// + /// Cleans up the named pipe client. + /// + private void CleanupPipeClient() + { + _pipeClient?.Dispose(); + _pipeClient = null; + } + } +} \ No newline at end of file diff --git a/src/PodeMonitor/PodeMonitor.csproj b/src/PodeMonitor/PodeMonitor.csproj new file mode 100644 index 000000000..cba19cd46 --- /dev/null +++ b/src/PodeMonitor/PodeMonitor.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + true + true + true + true + false + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64;linux-arm;win-x86;linux-musl-x64 + + + + + + + + + + + + + + + diff --git a/src/PodeMonitor/PodeMonitorLogger.cs b/src/PodeMonitor/PodeMonitorLogger.cs new file mode 100644 index 000000000..35e841568 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorLogger.cs @@ -0,0 +1,219 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace PodeMonitor +{ + + /// + /// A thread-safe logger for PodeMonitor that supports log rotation, exception logging, and log level filtering. + /// + public static partial class PodeMonitorLogger + { + private static readonly object _logLock = new(); // Ensures thread-safe writes + private static string _logFilePath = "PodeService.log"; // Default log file path + private static PodeLogLevel _minLogLevel = PodeLogLevel.INFO; // Default minimum log level + private const long DefaultMaxFileSize = 10 * 1024 * 1024; // Default max file size: 10 MB + + [GeneratedRegex(@"\x1B\[[0-9;]*[a-zA-Z]")] + private static partial Regex AnsiRegex(); + + /// + /// Initializes the logger with a custom log file path and minimum log level. + /// Validates the path, ensures the log file exists, and sets up log rotation. + /// + /// Path to the log file. + /// Minimum log level to record. + /// Maximum log file size in bytes before rotation. + public static void Initialize(string filePath, PodeLogLevel level, long maxFileSizeInBytes = DefaultMaxFileSize) + { + try + { + // Set the log file path and validate it + if (!string.IsNullOrWhiteSpace(filePath)) + { + ValidateLogPath(filePath); + _logFilePath = filePath; + } + + _minLogLevel = level; + + // Ensure the log file exists + if (!File.Exists(_logFilePath)) + { + using (File.Create(_logFilePath)) { }; + } + + // Perform log rotation if necessary + RotateLogFile(maxFileSizeInBytes); + + // Log initialization success + Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, + "Logger initialized. LogFilePath: {0}, MinLogLevel: {1}, MaxFileSize: {2} bytes", _logFilePath, _minLogLevel, maxFileSizeInBytes); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to initialize logger: {ex.Message}"); + } + } + + /// + /// Logs a message to the log file with the specified log level, context, and optional arguments. + /// + /// Log level. + /// Context of the log (e.g., "PodeMonitor"). + /// Process ID to include in the log. + /// Message to log. + /// Optional arguments for formatting the message. + public static void Log(PodeLogLevel level, string context, int pid, string message = "", params object[] args) + { + if (level < _minLogLevel || string.IsNullOrEmpty(message)) + { + return; // Skip logging for levels below the minimum or empty messages + } + + try + { + // Sanitize the message to remove ANSI escape codes + string sanitizedMessage = AnsiRegex().Replace(message, string.Empty); + + // Format the sanitized message + string formattedMessage = string.Format(sanitizedMessage, args); + + // Get the current time in ISO 8601 format (UTC) + string timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + // Construct the log entry + string logEntry = $"{timestamp} [PID:{pid}] [{level}] [{context}] {formattedMessage}"; + + // Thread-safe log file write + lock (_logLock) + { + using StreamWriter writer = new(_logFilePath, true); + writer.WriteLine(logEntry); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to log to file:"); + Console.WriteLine($"{context} - {message}"); + Console.WriteLine($"Error: {ex.Message}"); + } + } + + /// + /// Logs an exception and an optional message to the log file. + /// Includes exception stack trace and inner exception details. + /// + /// Log level. + /// Exception to log. + /// Optional message to include. + /// Optional arguments for formatting the message. + public static void Log(PodeLogLevel level, Exception exception, string message = null, params object[] args) + { + if (level < _minLogLevel || (exception == null && string.IsNullOrEmpty(message))) + { + return; // Skip logging if the level is below the minimum or there's nothing to log + } + + try + { + // Get the current time in ISO 8601 format (UTC) + string timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + // Format the message if provided + string logMessage = string.Empty; + + if (!string.IsNullOrEmpty(message)) + { + // Sanitize the message to remove ANSI escape codes + string sanitizedMessage = AnsiRegex().Replace(message, string.Empty); + logMessage = string.Format(sanitizedMessage, args); + } + + // Add exception details + if (exception != null) + { + logMessage += $"{Environment.NewLine}Exception: {exception.GetType().Name}"; + logMessage += $"{Environment.NewLine}Message: {exception.Message}"; + logMessage += $"{Environment.NewLine}Stack Trace: {exception.StackTrace}"; + + // Include inner exception details if any + var innerException = exception.InnerException; + while (innerException != null) + { + logMessage += $"{Environment.NewLine}Inner Exception: {innerException.GetType().Name}"; + logMessage += $"{Environment.NewLine}Message: {innerException.Message}"; + logMessage += $"{Environment.NewLine}Stack Trace: {innerException.StackTrace}"; + innerException = innerException.InnerException; + } + } + + // Construct the log entry + string logEntry = $"{timestamp} [PID:{Environment.ProcessId}] [{level}] [PodeMonitor] {logMessage}"; + + // Thread-safe log file write + lock (_logLock) + { + using StreamWriter writer = new(_logFilePath, true); + writer.WriteLine(logEntry); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to log exception to file: {ex.Message}"); + } + } + + /// + /// Ensures log rotation by renaming old logs when the current log file exceeds the specified size. + /// + /// Maximum size of the log file in bytes before rotation. + private static void RotateLogFile(long maxFileSizeInBytes) + { + lock (_logLock) + { + FileInfo logFile = new(_logFilePath); + if (logFile.Exists && logFile.Length > maxFileSizeInBytes) + { + string rotatedFilePath = $"{_logFilePath}.{DateTime.UtcNow:yyyyMMddHHmmss}"; + File.Move(_logFilePath, rotatedFilePath); + } + } + } + + /// + /// Validates the log file path to ensure it is writable. + /// Creates the directory if it does not exist. + /// + /// Path to validate. + private static void ValidateLogPath(string filePath) + { + string directory = Path.GetDirectoryName(filePath); + + if (string.IsNullOrWhiteSpace(directory)) + { + throw new ArgumentException("Invalid log file path: Directory cannot be determined."); + } + + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + try + { + string testFilePath = Path.Combine(directory, "test_write.log"); + using (var stream = File.Create(testFilePath)) + { + stream.WriteByte(0); + } + File.Delete(testFilePath); + } + catch (Exception ex) + { + throw new IOException($"Log directory is not writable: {directory}", ex); + } + } + } +} diff --git a/src/PodeMonitor/PodeMonitorMain.cs b/src/PodeMonitor/PodeMonitorMain.cs new file mode 100644 index 000000000..e0945d3fe --- /dev/null +++ b/src/PodeMonitor/PodeMonitorMain.cs @@ -0,0 +1,232 @@ +using System; +using System.IO; +using System.ServiceProcess; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; +using System.Text.Json; +using Microsoft.Extensions.Options; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace PodeMonitor +{ + /// + /// Entry point for the Pode service. Handles platform-specific configurations and signal-based operations. + /// + public static partial class Program + { + // Platform-dependent signal registration (for linux/macOS) + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SignalHandler(int signum); + + [LibraryImport("libc", EntryPoint = "signal")] + private static partial int Signal(int signum, SignalHandler handler); + + private const int SIGTSTP = 20; // Signal for pause + private const int SIGCONT = 18; // Signal for continue + private const int SIGHUP = 1; // Signal for restart + private const int SIGTERM = 15; // Signal for gracefully terminate a process. + + private static PodeMonitorWorker _workerInstance; // Global instance for managing worker operations + + /// + /// Entry point for the Pode service. + /// + /// Command-line arguments. + public static void Main(string[] args) + { + string customConfigFile = args.Length > 0 ? args[0] : "srvsettings.json"; // Default config file + string serviceName = "PodeService"; + + // Check if the custom configuration file exists + if (!File.Exists(customConfigFile)) + { + Console.WriteLine($"Configuration file '{customConfigFile}' not found. Please provide a valid configuration file."); + Environment.Exit(1); // Exit with a non-zero code to indicate failure + } + + // Load configuration + IConfigurationRoot config = new ConfigurationBuilder() + .AddJsonFile(customConfigFile, optional: false, reloadOnChange: true) + .Build(); + + serviceName = config.GetSection("PodeMonitorWorker:Name").Value ?? serviceName; + + string logFilePath = config.GetSection("PodeMonitorWorker:logFilePath").Value ?? "PodeMonitorService.log"; + + // Parse log level + string logLevelString = config.GetSection("PodeMonitorWorker:LogLevel").Value; + + if (!Enum.TryParse(logLevelString, true, out PodeLogLevel logLevel)) + { + Console.WriteLine($"Invalid or missing log level '{logLevelString}'. Defaulting to INFO."); + logLevel = PodeLogLevel.INFO; // Default log level + } + else + { + Console.WriteLine($"Log level set to '{logLevelString}'."); + } + + // Parse log max file size + string logMaxFileSizeString = config.GetSection("PodeMonitorWorker:LogMaxFileSize").Value; + if (!long.TryParse(logMaxFileSizeString, out long logMaxFileSize)) + { + Console.WriteLine($"Invalid or missing log max file size '{logMaxFileSizeString}'. Defaulting to 10 MB."); + logMaxFileSize = 10 * 1024 * 1024; // Default to 10 MB + } + // Initialize logger + PodeMonitorLogger.Initialize(logFilePath, logLevel, logMaxFileSize); + + // Configure host builder + var builder = CreateHostBuilder(args, customConfigFile); + + // Platform-specific logic + if (OperatingSystem.IsLinux()) + { + ConfigureLinux(builder); + } + else if (OperatingSystem.IsWindows()) + { + ConfigureWindows(builder, serviceName); + } + else if (OperatingSystem.IsMacOS()) + { + ConfigureMacOS(builder); + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.WARN, "PodeMonitor", Environment.ProcessId, "Unsupported platform. Exiting."); + } + } + + /// + /// Creates and configures the host builder for the Pode service. + /// + /// Command-line arguments. + /// Path to the custom configuration file. + /// The configured host builder. + private static IHostBuilder CreateHostBuilder(string[] args, string customConfigFile) + { + return Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(config => + { + config.AddJsonFile(customConfigFile, optional: false, reloadOnChange: true); + }) + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection("PodeMonitorWorker")); + + // Register PodeMonitor + services.AddSingleton(serviceProvider => + { + var options = serviceProvider.GetRequiredService>().Value; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Initializing PodeMonitor with options: {0}", options.ToString()); + return new PodeMonitor(options); + }); + + // Register PodeMonitorWorker and track the instance + services.AddSingleton(provider => + { + var logger = provider.GetRequiredService>(); + var monitor = provider.GetRequiredService(); + var worker = new PodeMonitorWorker(logger, monitor); + _workerInstance = worker; // Track the instance globally + return worker; + }); + + // Add PodeMonitorWorker as a hosted service + services.AddHostedService(provider => provider.GetRequiredService()); + + // Register IPausableHostedService + services.AddSingleton(provider => provider.GetRequiredService()); + }); + } + +#if ENABLE_LIFECYCLE_OPERATIONS + /// + /// Configures the Pode service for linux, including signal handling. + /// + /// The host builder. + [SupportedOSPlatform("linux")] + private static void ConfigureLinux(IHostBuilder builder) + { + // Handle linux signals for pause, resume, and restart + Signal(SIGTSTP, HandleSignalStop); + Signal(SIGCONT, HandleSignalContinue); + Signal(SIGHUP, HandleSignalRestart); + builder.UseSystemd(); + builder.Build().Run(); + } + + /// + /// Configures the Pode service for macOS, including signal handling. + /// + /// The host builder. + [SupportedOSPlatform("macOS")] + private static void ConfigureMacOS(IHostBuilder builder) + { + // Use launchd for macOS + Signal(SIGTSTP, HandleSignalStop); + Signal(SIGCONT, HandleSignalContinue); + Signal(SIGHUP, HandleSignalRestart); + Signal(SIGTERM, HandleSignalTerminate); + builder.Build().Run(); + } + + /// + /// Configures the Pode service for Windows, enabling pause and continue support. + /// + /// The host builder. + /// The name of the service. + [SupportedOSPlatform("windows")] + private static void ConfigureWindows(IHostBuilder builder, string serviceName) + { + using var host = builder.Build(); + var service = new PodeMonitorWindowsService(host, serviceName); + ServiceBase.Run(service); + } + + private static void HandleSignalStop(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGTSTP received."); + HandlePause(); + } + + private static void HandleSignalTerminate(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGTERM received."); + HandleStop(); + } + + private static void HandleSignalContinue(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGCONT received."); + HandleContinue(); + } + + private static void HandleSignalRestart(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGHUP received."); + HandleRestart(); + } + + private static void HandlePause() => _workerInstance?.OnPause(); + private static void HandleContinue() => _workerInstance?.OnContinue(); + private static void HandleRestart() => _workerInstance?.Restart(); + private static void HandleStop() => _workerInstance?.Shutdown(); +#else + [SupportedOSPlatform("linux")] + private static void ConfigureLinux(IHostBuilder builder) => builder.UseSystemd().Build().Run(); + + [SupportedOSPlatform("macOS")] + private static void ConfigureMacOS(IHostBuilder builder) => builder.Build().Run(); + + [SupportedOSPlatform("windows")] + private static void ConfigureWindows(IHostBuilder builder, string serviceName) => + builder.UseWindowsService().Build().Run(); +#endif + } +} diff --git a/src/PodeMonitor/PodeMonitorServiceState.cs b/src/PodeMonitor/PodeMonitorServiceState.cs new file mode 100644 index 000000000..43e4e1ffd --- /dev/null +++ b/src/PodeMonitor/PodeMonitorServiceState.cs @@ -0,0 +1,51 @@ +namespace PodeMonitor +{ + /// + /// Enum representing possible states of the Pode service. + /// + public enum PodeMonitorServiceState + { + Unknown, // State is unknown + + /// + /// The server has been completely Stopped and is no longer running. + /// + Stopped, + + /// + /// The server is in the process of Stopping and shutting down its operations. + /// + Stopping, + + /// + /// The server is resuming from a suspended state and is starting to run again. + /// + Resuming, + + /// + /// The server is in the process of suspending its operations. + /// + Suspending, + + /// + /// The server is currently suspended and not processing any requests. + /// + Suspended, + + /// + /// The server is in the process of restarting its operations. + /// + Restarting, + + /// + /// The server is starting its operations. + /// + Starting, + + /// + /// The server is running and actively processing requests. + /// + Running + } + +} \ No newline at end of file diff --git a/src/PodeMonitor/PodeMonitorWindowsService.cs b/src/PodeMonitor/PodeMonitorWindowsService.cs new file mode 100644 index 000000000..b962e3c04 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorWindowsService.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.ServiceProcess; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Runtime.Versioning; +using System.Diagnostics; + +namespace PodeMonitor +{ + /// + /// Represents a Windows service that integrates with a Pode host and supports lifecycle operations such as start, stop, pause, continue, and restart. + /// + [SupportedOSPlatform("windows")] + public class PodeMonitorWindowsService : ServiceBase + { + private readonly IHost _host; // The Pode host instance + private const int CustomCommandRestart = 128; // Custom command for SIGHUP-like restart + + /// + /// Initializes a new instance of the PodeMonitorWindowsService class. + /// + /// The host instance managing the Pode application. + /// The name of the Windows service. + public PodeMonitorWindowsService(IHost host, string serviceName) + { + _host = host ?? throw new ArgumentNullException(nameof(host), "Host cannot be null."); + CanPauseAndContinue = true; // Enable support for pause and continue operations + ServiceName = serviceName ?? throw new ArgumentNullException(nameof(serviceName), "Service name cannot be null."); // Dynamically set the service name + } + + /// + /// Handles the service start operation. Initializes the Pode host and starts its execution. + /// + /// Command-line arguments passed to the service. + protected override void OnStart(string[] args) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service starting..."); + try + { + base.OnStart(args); // Call the base implementation + _host.StartAsync().Wait(); // Start the Pode host asynchronously and wait for it to complete + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service started successfully."); + } + catch (Exception ex) + { + // Log the exception to the custom log + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Service startup failed."); + + // Write critical errors to the Windows Event Log + EventLog.WriteEntry(ServiceName, $"Critical failure during service startup: {ex.Message}\n{ex.StackTrace}", + EventLogEntryType.Error); + + // Rethrow the exception to signal failure to the Windows Service Manager + throw; + } + } + + /// + /// Handles the service stop operation. Gracefully stops the Pode host. + /// + protected override void OnStop() + { + base.OnStop(); // Call the base implementation + _host.StopAsync().Wait(); // Stop the Pode host asynchronously and wait for it to complete + } + + /// + /// Handles the service pause operation. Pauses the Pode host by invoking IPausableHostedService. + /// + protected override void OnPause() + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service pausing..."); + base.OnPause(); // Call the base implementation + + // Retrieve the IPausableHostedService instance from the service container + var service = _host.Services.GetService(typeof(IPausableHostedService)); + if (service != null) + { + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, $"Resolved IPausableHostedService: {service.GetType().FullName}"); + ((IPausableHostedService)service).OnPause(); // Invoke the pause operation + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, "Error: Failed to resolve IPausableHostedService."); + } + } + + /// + /// Handles the service resume operation. Resumes the Pode host by invoking IPausableHostedService. + /// + protected override void OnContinue() + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service resuming..."); + base.OnContinue(); // Call the base implementation + + // Retrieve the IPausableHostedService instance from the service container + var service = _host.Services.GetService(typeof(IPausableHostedService)); + if (service != null) + { + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, $"Resolved IPausableHostedService: {service.GetType().FullName}"); + ((IPausableHostedService)service).OnContinue(); // Invoke the resume operation + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, "Error: Failed to resolve IPausableHostedService."); + } + } + + /// + /// Handles custom control commands sent to the service. Supports a SIGHUP-like restart operation. + /// + /// The custom command number. + protected override void OnCustomCommand(int command) + { + if (command == CustomCommandRestart) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Custom restart command received. Restarting service..."); + var service = _host.Services.GetService(typeof(IPausableHostedService)); + if (service != null) + { + ((IPausableHostedService)service).Restart(); // Trigger the restart operation + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, "Error: Failed to resolve IPausableHostedService for restart."); + } + } + else + { + base.OnCustomCommand(command); // Handle other custom commands + } + } + } +} diff --git a/src/PodeMonitor/PodeMonitorWorker.cs b/src/PodeMonitor/PodeMonitorWorker.cs new file mode 100644 index 000000000..89dca3ac9 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorWorker.cs @@ -0,0 +1,226 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PodeMonitor +{ + /// + /// Manages the lifecycle of the Pode PowerShell process, supporting start, stop, pause, and resume operations. + /// Implements IPausableHostedService for handling pause and resume operations. + /// + public sealed class PodeMonitorWorker : BackgroundService, IPausableHostedService + { + // Logger instance for logging informational and error messages + private readonly ILogger _logger; + + // Instance of PodeMonitor to manage the Pode PowerShell process + private readonly PodeMonitor _pwshMonitor; + + // Delay in milliseconds to prevent rapid consecutive operations + private readonly int _delayMs = 5000; + + private bool _terminating = false; + + public PodeMonitorServiceState State => _pwshMonitor.State; + + + /// + /// Initializes a new instance of the PodeMonitorWorker class. + /// + /// Logger instance for logging messages and errors. + /// Instance of PodeMonitor for managing the PowerShell process. + public PodeMonitorWorker(ILogger logger, PodeMonitor pwshMonitor) + { + _logger = logger; // Assign the logger + _pwshMonitor = pwshMonitor; // Assign the shared PodeMonitor instance + _logger.LogInformation("PodeMonitorWorker initialized with shared PodeMonitor."); + } + + /// + /// The main execution loop for the worker. + /// Monitors and restarts the Pode PowerShell process if needed. + /// + /// Cancellation token to signal when the worker should stop. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "PodeMonitorWorker running at: {0}", DateTimeOffset.Now); + int retryCount = 0; // Tracks the number of retries in case of failures + + while (!stoppingToken.IsCancellationRequested && !_terminating) + { + try + { + retryCount = 0; // Reset retry count on success + + // Start the Pode PowerShell process + _pwshMonitor.StartPowerShellProcess(); + } + catch (Exception ex) + { + retryCount++; + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error in ExecuteAsync: {0}. Retry {1}/{2}", ex.Message, retryCount, _pwshMonitor.StartMaxRetryCount); + + // If retries exceed the maximum, log and exit the loop + if (retryCount >= _pwshMonitor.StartMaxRetryCount) + { + PodeMonitorLogger.Log(PodeLogLevel.CRITICAL, "PodeMonitor", Environment.ProcessId, "Maximum retry count reached. Exiting monitoring loop."); + break; + } + + // Delay before retrying + await Task.Delay(_pwshMonitor.StartRetryDelayMs, stoppingToken); + } + + // Add a delay between iterations + await Task.Delay(10000, stoppingToken); + } + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Monitoring loop has stopped."); + } + + /// + /// Stops the Pode PowerShell process gracefully. + /// + /// Cancellation token to signal when the stop should occur. + public override async Task StopAsync(CancellationToken stoppingToken) + { + Shutdown(); + + await base.StopAsync(stoppingToken); // Wait for the base StopAsync to complete + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service stopped successfully at: {0}", DateTimeOffset.Now); + } + + + /// + /// Shutdown the Pode PowerShell process by sending a shutdown command. + /// + public void Shutdown() + { + if ((!_terminating) && (_pwshMonitor.State == PodeMonitorServiceState.Running || _pwshMonitor.State == PodeMonitorServiceState.Suspended)) + { + + _terminating = true; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service is stopping at: {0}", DateTimeOffset.Now); + + try + { + _pwshMonitor.StopPowerShellProcess(); // Stop the process + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Stop message sent via pipe at: {0}", DateTimeOffset.Now); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error stopping PowerShell process: {0}", ex.Message); + } + } + } + + /// + /// Restarts the Pode PowerShell process by sending a restart command. + /// + public void Restart() + { + if ((!_terminating) && _pwshMonitor.State == PodeMonitorServiceState.Running || _pwshMonitor.State == PodeMonitorServiceState.Suspended) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service restarting at: {0}", DateTimeOffset.Now); + try + { + _pwshMonitor.RestartPowerShellProcess(); // Restart the process + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Restart message sent via pipe at: {0}", DateTimeOffset.Now); + + //AddOperationDelay("Pause"); // Delay to ensure stability + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error during pause: {0}", ex.Message); + } + } + } + + /// + /// Pauses the Pode PowerShell process and adds a delay to ensure stable operation. + /// + public void OnPause() + { + if ((!_terminating) && _pwshMonitor.State == PodeMonitorServiceState.Running) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pause command received at: {0}", DateTimeOffset.Now); + + try + { + _pwshMonitor.SuspendPowerShellProcess(); // Send pause command to the process + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Suspend message sent via pipe at: {0}", DateTimeOffset.Now); + var retryCount = 0; // Reset retry count on success + while (_pwshMonitor.State != PodeMonitorServiceState.Suspended) + { + if (retryCount >= 100) + { + PodeMonitorLogger.Log(PodeLogLevel.CRITICAL, "PodeMonitor", Environment.ProcessId, "Maximum retry count reached. Exiting monitoring loop."); + break; + } + + // Delay before retrying + Thread.Sleep(200); + } + //AddOperationDelay("Pause"); // Delay to ensure stability + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error during pause: {0}", ex.Message); + } + } + } + + /// + /// Resumes the Pode PowerShell process and adds a delay to ensure stable operation. + /// + public void OnContinue() + { + if ((!_terminating) && _pwshMonitor.State == PodeMonitorServiceState.Suspended) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Continue command received at: {0}", DateTimeOffset.Now); + + try + { + _pwshMonitor.ResumePowerShellProcess(); // Send resume command to the process + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Resume message sent via pipe at: {0}", DateTimeOffset.Now); + var retryCount = 0; // Reset retry count on success + while (_pwshMonitor.State == PodeMonitorServiceState.Suspended) + { + if (retryCount >= 100) + { + PodeMonitorLogger.Log(PodeLogLevel.CRITICAL, "PodeMonitor", Environment.ProcessId, "Maximum retry count reached. Exiting monitoring loop."); + break; + } + + // Delay before retrying + Thread.Sleep(200); + } + + + // AddOperationDelay("Resume"); // Delay to ensure stability + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error during continue: {0}", ex.Message); + } + } + } + + /// + /// Adds a delay to ensure that rapid consecutive operations are prevented. + /// + /// The name of the operation (e.g., "Pause" or "Resume"). + private void AddOperationDelay(string operation) + { + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, "{0} operation completed. Adding delay of {1} ms.", operation, _delayMs); + Thread.Sleep(_delayMs); // Introduce a delay + } + } +} diff --git a/src/PodeMonitor/PodeMonitorWorkerOptions.cs b/src/PodeMonitor/PodeMonitorWorkerOptions.cs new file mode 100644 index 000000000..5849d85a8 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorWorkerOptions.cs @@ -0,0 +1,109 @@ +using System; + +namespace PodeMonitor +{ + /// + /// Configuration options for the PodeMonitorWorker service. + /// These options determine how the worker operates, including paths, parameters, and retry policies. + /// + public class PodeMonitorWorkerOptions + { + /// + /// The name of the service. + /// + public string Name { get; set; } + + /// + /// The path to the PowerShell script that the worker will execute. + /// + public string ScriptPath { get; set; } + + /// + /// The path to the PowerShell executable (pwsh). + /// + public string PwshPath { get; set; } + + /// + /// Additional parameters to pass to the PowerShell process. + /// Default is an empty string. + /// + public string ParameterString { get; set; } = ""; + + /// + /// The path to the log file where output from the PowerShell process will be written. + /// Default is an empty string (no logging). + /// + public string LogFilePath { get; set; } = ""; + + /// + /// The logging level for the service (e.g., DEBUG, INFO, WARN, ERROR, CRITICAL). + /// Default is INFO. + /// + public PodeLogLevel LogLevel { get; set; } = PodeLogLevel.INFO; + + /// + /// The maximum size (in bytes) of the log file before it is rotated. + /// Default is 10 MB (10 * 1024 * 1024 bytes). + /// + public long LogMaxFileSize { get; set; } = 10 * 1024 * 1024; + + /// + /// Indicates whether the PowerShell process should run in quiet mode, suppressing output. + /// Default is true. + /// + public bool Quiet { get; set; } = true; + + /// + /// Indicates whether termination of the PowerShell process is disabled. + /// Default is true. + /// + public bool DisableTermination { get; set; } = true; + + /// + /// The maximum time to wait (in milliseconds) for the PowerShell process to shut down. + /// Default is 30,000 milliseconds (30 seconds). + /// + public int ShutdownWaitTimeMs { get; set; } = 30000; + + /// + /// The maximum number of retries to start the PowerShell process before giving up. + /// Default is 3 retries. + /// + public int StartMaxRetryCount { get; set; } = 3; + + /// + /// The delay (in milliseconds) between retry attempts to start the PowerShell process. + /// Default is 5,000 milliseconds (5 seconds). + /// + public int StartRetryDelayMs { get; set; } = 5000; + + /// + /// Disables all console interactions for the server. + /// + public bool DisableConsoleInput { get; set; } = true; + + /// + /// Prevents the server from loading settings from the server.psd1 configuration file. + /// + public bool IgnoreServerConfig { get; set; } = false; + + /// + /// Specifies a custom configuration file instead of using the default `server.psd1`. + /// + public string ConfigFile { get; set; } = ""; + + /// + /// Provides a string representation of the configured options for debugging or logging purposes. + /// + /// A string containing all configured options and their values. + public override string ToString() + { + return $"Name: {Name}, ScriptPath: {ScriptPath}, PwshPath: {PwshPath}, ParameterString: {ParameterString}, " + + $"LogFilePath: {LogFilePath}, LogLevel: {LogLevel}, LogMaxFileSize: {LogMaxFileSize}, Quiet: {Quiet}, " + + $"DisableTermination: {DisableTermination}, ShutdownWaitTimeMs: {ShutdownWaitTimeMs}, " + + $"StartMaxRetryCount: {StartMaxRetryCount}, StartRetryDelayMs: {StartRetryDelayMs}" + + $"DisableConsoleInput: {IgnoreServerConfig}, IgnoreServerConfig: {IgnoreServerConfig}, ConfigFile: {ConfigFile}"; + } + } + +} diff --git a/src/PodeMonitor/PodeServiceStateExtensions.cs b/src/PodeMonitor/PodeServiceStateExtensions.cs new file mode 100644 index 000000000..21096e295 --- /dev/null +++ b/src/PodeMonitor/PodeServiceStateExtensions.cs @@ -0,0 +1,46 @@ +using System; + +namespace PodeMonitor +{ + public static class PodeServiceStateExtensions + { + /// + /// Converts a string to a PodeMonitorServiceState enum in a case-insensitive manner. + /// + /// The string representation of the state. + /// The corresponding PodeMonitorServiceState, or Unknown if parsing fails. + public static PodeMonitorServiceState ToPodeMonitorServiceState(this string stateString) + { + if (string.IsNullOrWhiteSpace(stateString)) + return PodeMonitorServiceState.Unknown; + + // Normalize known aliases + stateString = stateString.Trim().ToLowerInvariant(); + switch (stateString) + { + case "terminated": + return PodeMonitorServiceState.Stopped; + case "terminating": + return PodeMonitorServiceState.Stopping; + } + + // Try parsing the string to an enum + if (Enum.TryParse(stateString, true, out PodeMonitorServiceState result)) + { + return result; + } + + return PodeMonitorServiceState.Unknown; // Default if parsing fails + } + + /// + /// Converts a PodeMonitorServiceState enum to its string representation. + /// + /// The PodeMonitorServiceState enum value. + /// The string representation of the state. + public static string ToPodeMonitorServiceStateString(this PodeMonitorServiceState state) + { + return state.ToString(); + } + } +} diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index a6dfb64d5..f6140392b 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -55,6 +55,9 @@ function New-PodeContext { [string] $ConfigFile, + [hashtable] + $Service, + [string] $ApplicationName ) @@ -109,6 +112,11 @@ function New-PodeContext { $ctx.Server.ApplicationName = $ApplicationName + + if ($null -ne $Service) { + $ctx.Server.Service = $Service + } + # list of created listeners/receivers $ctx.Listeners = @() $ctx.Receivers = @() @@ -157,6 +165,7 @@ function New-PodeContext { Tasks = 2 WebSockets = 2 Timers = 1 + Service = 0 } # set socket details for pode server @@ -225,9 +234,11 @@ function New-PodeContext { # Load the server configuration based on the provided parameters. # If $IgnoreServerConfig is set, an empty configuration (@{}) is assigned; otherwise, the configuration is loaded using Open-PodeConfiguration. - $ctx.Server.Configuration = if ($IgnoreServerConfig) { @{} } + if ($IgnoreServerConfig) { + $ctx.Server.Configuration = @{} + } else { - Open-PodeConfiguration -ServerRoot $ServerRoot -Context $ctx -ConfigFile $ConfigFile + $ctx.Server.Configuration = Open-PodeConfiguration -ServerRoot $ServerRoot -Context $ctx -ConfigFile $ConfigFile } # Set the 'Enabled' property of the server configuration. @@ -511,6 +522,7 @@ function New-PodeContext { Tasks = $null Files = $null Timers = $null + Service = $null } # threading locks, etc. @@ -717,6 +729,15 @@ function New-PodeRunspacePool { $PodeContext.RunspacePools.Gui.Pool.ApartmentState = 'STA' } + + if (Test-PodeServiceEnabled ) { + $PodeContext.Threads['Service'] = 1 + $PodeContext.RunspacePools.Service = @{ + Pool = [runspacefactory]::CreateRunspacePool(1, 1, $PodeContext.RunspaceState, $Host) + State = 'Waiting' + LastId = 0 + } + } } <# diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index d39c69576..9ebca78c7 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -145,27 +145,6 @@ function Get-PodePSVersionTable { return $PSVersionTable } -function Test-PodeIsAdminUser { - # check the current platform, if it's unix then return true - if (Test-PodeIsUnix) { - return $true - } - - try { - $principal = [System.Security.Principal.WindowsPrincipal]::new([System.Security.Principal.WindowsIdentity]::GetCurrent()) - if ($null -eq $principal) { - return $false - } - - return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) - } - catch [exception] { - Write-PodeHost 'Error checking user administrator priviledges' -ForegroundColor Red - Write-PodeHost $_.Exception.Message -ForegroundColor Red - return $false - } -} - function Get-PodeHostIPRegex { param( [Parameter(Mandatory = $true)] @@ -3853,10 +3832,10 @@ function Resolve-PodeObjectArray { # Changes to $clonedObject will not affect $originalObject and vice versa. .NOTES - This function uses the System.Management.Automation.PSSerializer class, which is available in - PowerShell 5.1 and later versions. The default depth parameter is set to 10 to handle nested - objects appropriately, but it can be customized via the -Deep parameter. - This is an internal function and may change in future releases of Pode. + - This function uses the System.Management.Automation.PSSerializer class, which is available in + PowerShell 5.1 and later versions. The default depth parameter is set to 10 to handle nested + objects appropriately, but it can be customized via the -Deep parameter. + - This is an internal function and may change in future releases of Pode. #> function Copy-PodeObjectDeepClone { param ( @@ -4058,6 +4037,262 @@ function Test-PodeIsISEHost { } +<# +.SYNOPSIS + Tests if the current user has administrative privileges on Windows or root/sudo privileges on Linux/macOS. + +.DESCRIPTION + This function checks the current user's privileges. On Windows, it checks if the user is an Administrator. + If the session is not elevated, you can optionally check if the user has the potential to elevate using the -Elevate switch. + On Linux and macOS, it checks if the user is either root or has sudo (Linux) or admin (macOS) privileges. + You can also check if the user has the potential to elevate by belonging to the sudo or admin group using the -Elevate switch. + +.PARAMETER Elevate + The -Elevate switch allows you to check if the current user has the potential to elevate to administrator/root privileges, + even if the session is not currently elevated. + +.PARAMETER Console + The -Console switch will output errors to the console if an exception occurs. + Otherwise, the errors will be written to the Pode error log. + +.EXAMPLE + Test-PodeAdminPrivilege + + If the user has administrative privileges, it returns $true. If not, it returns $false. + +.EXAMPLE + Test-PodeAdminPrivilege -Elevate + + This will check if the user has administrative/root/sudo privileges or the potential to elevate, + even if the session is not currently elevated. + +.EXAMPLE + Test-PodeAdminPrivilege -Elevate -Console + + This will check for admin privileges or potential to elevate and will output errors to the console if any occur. + +.OUTPUTS + [bool] + Returns $true if the user has administrative/root/sudo/admin privileges or the potential to elevate, + otherwise returns $false. + +.NOTES + - This function works across multiple platforms: Windows, Linux, and macOS. + On Linux/macOS, it checks for root, sudo, or admin group memberships, and optionally checks for elevation potential + if the -Elevate switch is used. + - This is an internal function and may change in future releases of Pode. +#> + +function Test-PodeAdminPrivilege { + param( + [switch] + $Elevate, + [switch] + $Console + ) + try { + # Check if the operating system is Windows + if (Test-PodeIsWindows) { + + # Retrieve the current Windows identity and token + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + + if ($null -eq $principal) { + return $false + } + + $isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if ($isAdmin) { + return $true + } + + # Check if the token is elevated + if ($identity.IsSystem -or $identity.IsAuthenticated -and $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + return $true + } + + if ($Elevate.IsPresent) { + # Use 'whoami /groups' to check if the user has the potential to elevate + $groups = whoami /groups + if ($groups -match 'S-1-5-32-544') { + return $true + } + } + return $false + } + else { + # Check if the operating system is Linux or macOS (both are Unix-like) + + # Check if the user is root (UID 0) + $isRoot = [int](id -u) + if ($isRoot -eq 0) { + return $true + } + + if ($Elevate.IsPresent) { + # Check if the user has sudo privileges by checking sudo group membership + $user = whoami + $groups = (groups $user) + Write-Verbose "User:$user Groups: $( $groups -join ',')" + # macOS typically uses 'admin' group for sudo privileges + return ($groups -match '\bwheel\b' -or $groups -match '\badmin\b' -or $groups -match '\bsudo\b' -or $groups -match '\badm\b') + } + return $false + } + } + catch [exception] { + if ($Console.IsPresent) { + Write-PodeHost 'Error checking user privileges' -ForegroundColor Red + Write-PodeHost $_.Exception.Message -ForegroundColor Red + } + else { + $_ | Write-PodeErrorLog + } + return $false + } +} + +<# +.SYNOPSIS + Starts a command with elevated privileges if the current session is not already elevated. + +.DESCRIPTION + This function checks if the current PowerShell session is running with administrator privileges. + If not, it re-launches the command as an elevated process. If the session is already elevated, + it will execute the command directly and return the result of the command. + +.PARAMETER Command + The PowerShell command to be executed. This can be any valid PowerShell command, script, or executable. + +.PARAMETER Arguments + The arguments to be passed to the command. This can be any valid argument list for the command or script. + +.EXAMPLE + Invoke-PodeWinElevatedCommand -Command "Get-Service" -Arguments "-Name 'W32Time'" + + This will run the `Get-Service` command with elevated privileges, pass the `-Name 'W32Time'` argument, and return the result. + +.EXAMPLE + Invoke-PodeWinElevatedCommand -Command "C:\Scripts\MyScript.ps1" -Arguments "-Param1 'Value1' -Param2 'Value2'" + + This will run the script `MyScript.ps1` with elevated privileges, pass the parameters `-Param1` and `-Param2`, and return the result. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Invoke-PodeWinElevatedCommand { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')] + param ( + [string] + $Command, + [string] + $Arguments, + [PSCredential] $Credential + ) + + + # Check if the current session is elevated + $isElevated = ([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + + if (-not $isElevated) { + + # Escape the arguments by replacing " with `" (escaping quotes) + $escapedArguments = $Arguments -replace '"', '"""' + $psCredential = '' + + # Combine command and arguments into a string to pass for elevated execution + # $escapedCommand = "`"$Command`" $Arguments" + if ($Credential) { + $password = Convertfrom-SecureString $Credential.Password + $psCredential = "-Credential ([pscredential]::new('$($Credential.UserName)', `$('$password'|ConvertTo-SecureString)))" + } + + # Combine command and arguments into a string for elevated execution + $escapedCommand = "$Command $psCredential $escapedArguments" + # Start elevated process with properly escaped command and arguments + $result = Start-Process -FilePath ((Get-Process -Id $PID).Path) ` + -ArgumentList '-NoProfile', '-ExecutionPolicy Bypass', "-Command & {$escapedCommand}" ` + -Verb RunAs -Wait -PassThru + + return $result + } + + # Run the command directly with arguments if elevated and capture the output + return Invoke-Expression "$Command $Arguments" +} + +<# +.SYNOPSIS + Determines the OS architecture for the current system. + +.DESCRIPTION + This function detects the operating system's architecture and maps it to a format compatible with + PowerShell installation requirements. It works on both Windows and Unix-based systems, translating + various architecture identifiers (e.g., 'amd64', 'x86_64') into standardized PowerShell-supported names + like 'x64', 'x86', 'arm64', and 'arm32'. On Linux, the function also checks for musl libc to provide + an architecture-specific identifier. + +.OUTPUTS + [string] - The architecture string, such as 'x64', 'x86', 'arm64', 'arm32', or 'musl-x64'. + +.EXAMPLE + $arch = Get-PodeOSPwshArchitecture + Write-Host "Current architecture: $arch" + +.NOTES + - For Windows, architecture is derived from the `PROCESSOR_ARCHITECTURE` environment variable. + - For Unix-based systems, architecture is determined using the `uname -m` command. + - The function adds support for identifying musl libc on Linux, returning 'musl-x64' if detected. + - If the architecture is not supported, the function returns an empty string. +#> +function Get-PodeOSPwshArchitecture { + # Initialize an empty variable for storing the detected architecture + $arch = [string]::Empty + + # Detect architecture on Unix-based systems (Linux/macOS) + if ($IsLinux -or $IsMacOS) { + # Use the 'uname -m' command to determine the system architecture + $arch = uname -m + } + else { + # For Windows, use the environment variable 'PROCESSOR_ARCHITECTURE' + $arch = $env:PROCESSOR_ARCHITECTURE + } + + # Map the detected architecture to PowerShell-compatible formats + switch ($arch.ToLowerInvariant()) { + 'amd64' { $arch = 'x64' } # 64-bit AMD architecture + 'x86' { $arch = 'x86' } # 32-bit Intel architecture + 'x86_64' { $arch = 'x64' } # 64-bit Intel architecture + 'armv7*' { $arch = 'arm32' } # 32-bit ARM architecture (v7 series) + 'aarch64*' { $arch = 'arm64' } # 64-bit ARM architecture (aarch64 series) + 'arm64' { $arch = 'arm64' } # Explicit ARM64 + 'arm64*' { $arch = 'arm64' } # Pattern matching for ARM64 + 'armv8*' { $arch = 'arm64' } # ARM v8 series + default { return '' } # Unsupported architectures, return empty string + } + + # Additional check for musl libc on Linux systems + if ($IsLinux) { + if ($arch -eq 'x64') { + # Check if musl libc is present + if (Get-Command ldd -ErrorAction SilentlyContinue) { + $lddOutput = ldd --version 2>&1 + if ($lddOutput -match 'musl') { + # Append 'musl-' prefix to architecture + $arch = 'musl-x64' + } + } + } + } + + # Return the final architecture string + return $arch +} + + <# .SYNOPSIS Retrieves the name of the main Pode application script. diff --git a/src/Private/Logging.ps1 b/src/Private/Logging.ps1 index 3b35edb35..57e5cd128 100644 --- a/src/Private/Logging.ps1 +++ b/src/Private/Logging.ps1 @@ -135,7 +135,7 @@ function ConvertTo-PodeEventViewerLevel { function Get-PodeLoggingInbuiltType { param( [Parameter(Mandatory = $true)] - [ValidateSet('Errors', 'Requests')] + [ValidateSet('Errors', 'Requests','service')] [string] $Type ) @@ -191,6 +191,34 @@ function Get-PodeLoggingInbuiltType { "StackTrace: $($item.StackTrace)" ) + # join the details and return + return "$($row -join "`n")`n" + } + } + 'service' { + $script = { + param($item, $options) + + # do nothing if the error level isn't present + if (@($options.Levels) -inotcontains $item.Level) { + return + } + + # just return the item if Raw is set + if ($options.Raw) { + return $item + } + + # build the exception details + $row = @( + "Date: $($item.Date.ToString('yyyy-MM-dd HH:mm:ss'))", + "Level: $($item.Level)", + "ThreadId: $($item.ThreadId)", + "Server: $($item.Server)", + "Category: $($item.Category)", + "Message: $($item.Message)" + ) + # join the details and return return "$($row -join "`n")`n" } diff --git a/src/Private/Runspaces.ps1 b/src/Private/Runspaces.ps1 index da4bd0b46..b7845273a 100644 --- a/src/Private/Runspaces.ps1 +++ b/src/Private/Runspaces.ps1 @@ -42,7 +42,6 @@ function Add-PodeRunspace { param( [Parameter(Mandatory = $true)] - [ValidateSet('Main', 'Signals', 'Schedules', 'Gui', 'Web', 'Smtp', 'Tcp', 'Tasks', 'WebSockets', 'Files', 'Timers')] [string] $Type, diff --git a/src/Private/Server.ps1 b/src/Private/Server.ps1 index c2051a90c..9573ba06e 100644 --- a/src/Private/Server.ps1 +++ b/src/Private/Server.ps1 @@ -196,6 +196,8 @@ function Start-PodeInternalServer { Invoke-PodeEvent -Type Running + # Start Service Monitor + Start-PodeServiceHeartbeat } catch { throw diff --git a/src/Private/Service.ps1 b/src/Private/Service.ps1 new file mode 100644 index 000000000..3cff5daca --- /dev/null +++ b/src/Private/Service.ps1 @@ -0,0 +1,1644 @@ +<# +.SYNOPSIS + Tests if the Pode service is enabled. + +.DESCRIPTION + This function checks if the Pode service is enabled by verifying if the `Service` key exists in the `$PodeContext.Server` hashtable. + +.OUTPUTS + [Bool] - `$true` if the 'Service' key exists, `$false` if it does not. + +.EXAMPLE + Test-PodeServiceEnabled + + Returns `$true` if the Pode service is enabled, otherwise returns `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeServiceEnabled { + + # Check if the 'Service' key exists in the $PodeContext.Server hashtable + return $PodeContext.Server.ContainsKey('Service') +} + +<# +.SYNOPSIS + Starts the Pode Service Heartbeat using a named pipe for communication with a C# service. + +.DESCRIPTION + This function starts a named pipe server in PowerShell that listens for commands from a C# application. It supports two commands: + - 'shutdown': to gracefully stop the Pode server. + - 'restart': to restart the Pode server. + +.PARAMETER None + The function takes no parameters. It retrieves the pipe name from the Pode service context. + +.EXAMPLE + Start-PodeServiceHeartbeat + + This command starts the Pode service monitoring and waits for 'shutdown' or 'restart' commands from the named pipe. + +.NOTES + This is an internal function and may change in future releases of Pode. + + The function uses Pode's context for the service to manage the pipe server. The pipe listens for messages sent from a C# client + and performs actions based on the received message. + + If the pipe receives a 'stop' message, the Pode server is stopped. + If the pipe receives a 'restart' message, the Pode server is restarted. + + Global variable example: $global:PodeService=@{DisableTermination=$true;Quiet=$false;Pipename='ssss'} +#> +function Start-PodeServiceHeartbeat { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] + [CmdletBinding()] + param() + # Check if the Pode service is enabled + if (Test-PodeServiceEnabled) { + + # Define the script block for the client receiver, listens for commands via the named pipe + $scriptBlock = { + while (!(Test-PodeCancellationTokenRequest -Type Terminate)) { + + do { + Start-Sleep -Seconds 1 + $serviceState = Get-PodeServerState + }until(( [Pode.PodeServerState]::Running, [Pode.PodeServerState]::Suspended, [Pode.PodeServerState]::Terminating) -contains ( $serviceState) ) + + [System.Console]::WriteLine("Initialize Listener Pipe $($PodeContext.Server.Service.PipeName)") + [System.Console]::WriteLine("Service State: $serviceState") + [System.Console]::WriteLine("Total Uptime: $(Get-PodeServerUptime -Total -Format verbose -ExcludeMilliseconds)") + if ((Get-PodeServerUptime) -gt 1000) { + [System.Console]::WriteLine("Uptime Since Last Restart: $(Get-PodeServerUptime -Readable -OutputType Verbose -ExcludeMilliseconds)") + } + [System.Console]::WriteLine("Total Number of Restart: $(Get-PodeServerRestartCount)") + try { + Start-Sleep -Milliseconds 100 + # Create a named pipe server stream + $pipeStream = [System.IO.Pipes.NamedPipeServerStream]::new( + $PodeContext.Server.Service.PipeName, + [System.IO.Pipes.PipeDirection]::InOut, + 1, # Max number of allowed concurrent connections + [System.IO.Pipes.PipeTransmissionMode]::Byte, + [System.IO.Pipes.PipeOptions]::None + ) + + [System.Console]::WriteLine("Waiting for connection to the $($PodeContext.Server.Service.PipeName) pipe.") + $pipeStream.WaitForConnection() # Wait until a client connects + [System.Console]::WriteLine("Connected to the $($PodeContext.Server.Service.PipeName) pipe.") + + # Create a StreamReader to read incoming messages from the pipe + $reader = [System.IO.StreamReader]::new($pipeStream) + + # Process incoming messages in a loop as long as the pipe is connected + if ($pipeStream.IsConnected) { + $message = $reader.ReadLine() # Read message from the pipe + if ( Test-PodeCancellationTokenRequest -Type Terminate) { + return + } + + if ($message) { + [System.Console]::WriteLine("Received message: $message") + + switch ($message) { + 'stop' { + # Process 'shutdown' message + [System.Console]::WriteLine("Server request: 'Stop'. Closing Pode ...") + Close-PodeServer # Gracefully stop Pode server + Start-Sleep 1 + [System.Console]::WriteLine("Service State: $(Get-PodeServerState)") + + [System.Console]::WriteLine('Closing Service Monitoring Heartbeat') + return # Exit the loop + } + + 'restart' { + # Process 'restart' message + [System.Console]::WriteLine("Server request: 'Restart'. Restarting Pode ...") + Restart-PodeServer # Restart Pode server + Start-Sleep 1 + [System.Console]::WriteLine("Service State: $(Get-PodeServerState)") + + [System.Console]::WriteLine('Closing Service Monitoring Heartbeat') + return # Exit the loop + } + + 'suspend' { + # Process 'suspend' message + [System.Console]::WriteLine("Server request: 'Suspend'. Suspending Pode ...") + Suspend-PodeServer + Start-Sleep 1 + [System.Console]::WriteLine("Service State: $(Get-PodeServerState)") + break + } + + 'resume' { + # Process 'resume' message + [System.Console]::WriteLine("Server request: 'Resume'. Resuming Pode ...") + Resume-PodeServer + Start-Sleep 1 + [System.Console]::WriteLine("Service State: $(Get-PodeServerState)") + break + } + } + + } + } + } + catch { + $_ | Write-PodeErrorLog # Log any errors that occur during pipe operation + } + finally { + if ($reader) { + $reader.Dispose() + } + if ($pipeStream) { + $pipeStream.Flush() + $pipeStream.Close() + $pipeStream.Dispose() # Always dispose of the pipe stream when done + [System.Console]::WriteLine("Disposing Listener Pipe $($PodeContext.Server.Service.PipeName)") + } + } + + } + [System.Console]::WriteLine('Closing Service Monitoring Heartbeat') + } + + # Assign a name to the Pode service + $PodeContext.Server.Service['Name'] = 'Service' + Write-Verbose -Message 'Starting service monitoring' + + # Start the runspace that runs the client receiver script block + $PodeContext.Server.Service['Runspace'] = Add-PodeRunspace -Type 'Service' -ScriptBlock ($scriptBlock) -PassThru + } +} + +<# +.SYNOPSIS + Registers a Pode service as a macOS LaunchAgent/Daemon. + +.DESCRIPTION + The `Register-PodeMacService` function creates a macOS plist file for the Pode service. It sets up the service + to run using `launchctl`, specifying options such as autostart, logging, and the executable path. + +.PARAMETER Name + The name of the Pode service. This is used to identify the service in macOS. + +.PARAMETER Description + A brief description of the service. This is not included in the plist file but can be useful for logging. + +.PARAMETER BinPath + The path to the directory where the PodeMonitor executable is located. + +.PARAMETER SettingsFile + The path to the configuration file (e.g., `srvsettings.json`) that the Pode service will use. + +.PARAMETER User + The user under which the Pode service will run. + +.PARAMETER Start + If specified, the service will be started after registration. + +.PARAMETER Autostart + If specified, the service will automatically start when the system boots. + +.PARAMETER OsArchitecture + Specifies the architecture of the operating system (e.g., `osx-x64` or `osx-arm64`). + +.PARAMETER Agent + A switch to create an Agent instead of a Daemon in MacOS. + +.OUTPUTS + Returns $true if successful. + +.EXAMPLE + Register-PodeMacService -Name 'MyPodeService' -Description 'My Pode service' -BinPath '/path/to/bin' ` + -SettingsFile '/path/to/srvsettings.json' -User 'podeuser' -Start -Autostart -OsArchitecture 'osx-arm64' + + Registers a Pode service on macOS and starts it immediately with autostart enabled. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Register-PodeMacService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [string] + $Description, + + [string] + $BinPath, + + [string] + $SettingsFile, + + [string] + $User, + + [string] + $OsArchitecture, + + [string] + $LogPath, + + [switch] + $Agent + ) + + $nameService = Get-PodeRealServiceName -Name $Name + + # Check if the service is already registered + if ((Test-PodeMacOsServiceIsRegistered $nameService -Agent:$Agent)) { + # Service is already registered. + throw ($PodeLocale.serviceAlreadyRegisteredException -f $nameService) + } + + # Determine whether the service should run at load + $runAtLoad = if ($Autostart.IsPresent) { '' } else { '' } + + + # Create a temporary file + $tempFile = [System.IO.Path]::GetTempFileName() + + # Create the plist content + @" + + + + + Label + $nameService + + ProgramArguments + + $BinPath/$OsArchitecture/PodeMonitor + $SettingsFile + + + WorkingDirectory + $BinPath + + RunAtLoad + $runAtLoad + + StandardOutPath + $LogPath/$nameService.stdout.log + + StandardErrorPath + $LogPath/$nameService.stderr.log + + KeepAlive + + SuccessfulExit + + + + + + +"@ | Set-Content -Path $tempFile -Encoding UTF8 + + Write-Verbose -Message "Service '$nameService' WorkingDirectory : $($BinPath)." + try { + if ($Agent) { + $plistPath = "$($HOME)/Library/LaunchAgents/$($nameService).plist" + Copy-Item -Path $tempFile -Destination $plistPath + #set rw r r permissions + chmod 644 $plistPath + # Load the plist with launchctl + launchctl load $plistPath + } + else { + $plistPath = "/Library/LaunchDaemons/$($nameService).plist" + & sudo cp $tempFile $plistPath + #set rw r r permissions + & sudo chmod 644 $plistPath + + & sudo chown root:wheel $plistPath + + # Load the plist with launchctl + & sudo launchctl load $plistPath + + } + + # Verify the service is now registered + if (! (Test-PodeMacOsServiceIsRegistered $nameService -Agent:$Agent)) { + # Service registration failed. + throw ($PodeLocale.serviceRegistrationException -f $nameService) + } + } + catch { + $_ | Write-PodeErrorLog + throw $_ # Rethrow the error after logging + } + + return $true +} + + +<# +.SYNOPSIS + Registers a new systemd service on a Linux system to run a Pode-based PowerShell worker. + +.DESCRIPTION + The `Register-PodeLinuxService` function configures and registers a new systemd service on a Linux system. + It sets up the service with the specified parameters, generates the service definition file, enables the service, + and optionally starts it. It can also create the necessary user if it does not exist. + +.PARAMETER Name + The name of the systemd service to be registered. + +.PARAMETER Description + A brief description of the service. Defaults to an empty string. + +.PARAMETER BinPath + The path to the directory containing the `PodeMonitor` executable. + +.PARAMETER SettingsFile + The path to the settings file for the Pode worker. + +.PARAMETER User + The name of the user under which the service will run. If the user does not exist, it will be created unless the `SkipUserCreation` switch is used. + +.PARAMETER Group + The group under which the service will run. Defaults to the same as the `User` parameter. + +.PARAMETER OsArchitecture + The architecture of the operating system (e.g., `x64`, `arm64`). Used to locate the appropriate binary. + +.OUTPUTS + Returns $true if successful. + +.EXAMPLE + Register-PodeLinuxService -Name "PodeExampleService" -Description "An example Pode service" ` + -BinPath "/usr/local/bin" -SettingsFile "/etc/pode/example-settings.json" ` + -User "podeuser" -Group "podegroup" -Start -OsArchitecture "x64" + + Registers a new systemd service named "PodeExampleService", creates the necessary user and group, + generates the service file, enables the service, and starts it. + +.EXAMPLE + Register-PodeLinuxService -Name "PodeExampleService" -BinPath "/usr/local/bin" ` + -SettingsFile "/etc/pode/example-settings.json" -User "podeuser" -SkipUserCreation ` + -OsArchitecture "arm64" + + Registers a new systemd service without creating the user, and does not start the service immediately. + +.NOTES + - This function assumes systemd is the init system on the Linux machine. + - The function will check if the service is already registered and will throw an error if it is. + - If the user specified by the `User` parameter does not exist, the function will create it unless the `SkipUserCreation` switch is used. + - This is an internal function and may change in future releases of Pode. +#> +function Register-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [string] + $Description, + + [string] + $BinPath, + + [string] + $SettingsFile, + + [string] + $User, + + [string] + $Group, + + [switch] + $Start, + + [string] + $OsArchitecture + ) + $nameService = Get-PodeRealServiceName -Name $Name + $null = systemctl status $nameService 2>&1 + + # Check if the service is already registered + if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 3) { + # Service is already registered. + throw ($PodeLocale.serviceAlreadyRegisteredException -f $nameService ) + } + # Create a temporary file + $tempFile = [System.IO.Path]::GetTempFileName() + + $execStart = "$BinPath/$OsArchitecture/PodeMonitor `"$SettingsFile`"" + # Create the service file + @" +[Unit] +Description=$Description +After=network.target + +[Service] +ExecStart=$execStart +WorkingDirectory=$BinPath +Restart=always +User=$User +KillMode=process +Environment=NOTIFY_SOCKET=/run/systemd/notify +Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 +# Uncomment and adjust if needed +# Group=$Group +# Environment=ASPNETCORE_ENVIRONMENT=Production + +[Install] +WantedBy=multi-user.target +"@ | Set-Content -Path $tempFile -Encoding UTF8 + + Write-Verbose -Message "Service '$nameService' ExecStart : $execStart)." + + & sudo cp $tempFile "/etc/systemd/system/$nameService" + + Remove-Item -path $tempFile -ErrorAction SilentlyContinue + + # Enable the service and check if it fails + try { + if (!(Enable-PodeLinuxService -Name $nameService)) { + # Service registration failed. + throw ($PodeLocale.serviceRegistrationException -f $nameService) + } + } + catch { + $_ | Write-PodeErrorLog + throw $_ # Rethrow the error after logging + return $false + } + + return $true +} + +<# +.SYNOPSIS + Registers a new Windows service to run a Pode-based PowerShell worker. + +.DESCRIPTION + The `Register-PodeMonitorWindowsService` function configures and registers a new Windows service to run a Pode-based PowerShell worker. + It sets up the service with the specified parameters, including paths to the Pode monitor executable, configuration file, + credentials, and security descriptor. The service can be optionally started immediately after registration. + +.PARAMETER Name + The name of the Windows service to be registered. + +.PARAMETER Description + A brief description of the service. Defaults to an empty string. + +.PARAMETER DisplayName + The display name of the service, as it will appear in the Windows Services Manager. + +.PARAMETER StartupType + Specifies how the service is started. Options are: 'Automatic', 'Manual', or 'Disabled'. Defaults to 'Automatic'. + +.PARAMETER BinPath + The path to the directory containing the `PodeMonitor` executable. + +.PARAMETER SettingsFile + The path to the configuration file for the Pode worker. + +.PARAMETER Credential + A `PSCredential` object specifying the credentials for the account under which the service will run. + +.PARAMETER SecurityDescriptorSddl + An SDDL string (Security Descriptor Definition Language) used to define the security of the service. + +.PARAMETER OsArchitecture + The architecture of the operating system (e.g., `x64`, `arm64`). Used to locate the appropriate binary. + +.OUTPUTS + Returns $true if successful. + +.EXAMPLE + Register-PodeMonitorWindowsService -Name "PodeExampleService" -DisplayName "Pode Example Service" ` + -BinPath "C:\Pode" -SettingsFile "C:\Pode\settings.json" ` + -StartupType "Automatic" -Credential (Get-Credential) -Start -OsArchitecture "x64" + + Registers a new Windows service named "PodeExampleService", creates the service with credentials, + generates the service, and starts it. + +.EXAMPLE + Register-PodeMonitorWindowsService -Name "PodeExampleService" -BinPath "C:\Pode" ` + -SettingsFile "C:\Pode\settings.json" -OsArchitecture "x64" + + Registers a new Windows service without credentials or immediate startup. + +.NOTES + - This function assumes the service binary exists at the specified `BinPath`. + - It checks if the service already exists and throws an error if it does. + - This is an internal function and may change in future releases of Pode. +#> + +function Register-PodeMonitorWindowsService { + param( + [string] + $Name, + + [string] + $Description, + + [string] + $DisplayName, + + [string] + $StartupType, + + [string] + $BinPath, + + [string] + $SettingsFile, + + [pscredential] + $Credential, + + [string] + $SecurityDescriptorSddl, + + [string] + $OsArchitecture + ) + + + # Check if service already exists + if (Get-Service -Name $Name -ErrorAction SilentlyContinue) { + # Service is already registered. + throw ($PodeLocale.serviceAlreadyRegisteredException -f "$Name") + + } + + # Parameters for New-Service + $params = @{ + Name = $Name + BinaryPathName = "`"$BinPath\$OsArchitecture\PodeMonitor.exe`" `"$SettingsFile`"" + DisplayName = $DisplayName + StartupType = $StartupType + Description = $Description + #DependsOn = 'NetLogon' + } + if ($SecurityDescriptorSddl) { + $params['SecurityDescriptorSddl'] = $SecurityDescriptorSddl + } + Write-Verbose -Message "Service '$Name' BinaryPathName : $($params['BinaryPathName'])." + + try { + $paramsString = $params.GetEnumerator() | ForEach-Object { "-$($_.Key) '$($_.Value)'" } + + $sv = Invoke-PodeWinElevatedCommand -Command 'New-Service' -Arguments ($paramsString -join ' ') -Credential $Credential + + if (!$sv) { + # Service registration failed. + throw ($PodeLocale.serviceRegistrationException -f "$Name") + } + } + catch { + $_ | Write-PodeErrorLog + throw $_ # Rethrow the error after logging + } + + return $true +} + + + + + +function Test-PodeUserServiceCreationPrivilege { + # Get the list of user privileges + $privileges = whoami /priv | Where-Object { $_ -match 'SeCreateServicePrivilege' } + + if ($privileges) { + return $true + } + else { + return $false + } +} + +<# +.SYNOPSIS + Confirms if the current user has the necessary privileges to run the script. + +.DESCRIPTION + This function checks if the user has administrative privileges on Windows or root/sudo privileges on Linux/macOS. + If the user does not have the required privileges, the script will output an appropriate message and exit. + +.PARAMETER None + This function does not accept any parameters. + +.EXAMPLE + Confirm-PodeAdminPrivilege + + This will check if the user has the necessary privileges to run the script. If not, it will output an error message and exit. + +.OUTPUTS + Exits the script if the necessary privileges are not available. + +.NOTES + This function works across Windows, Linux, and macOS, and checks for either administrative/root/sudo privileges or specific service-related permissions. +#> + +function Confirm-PodeAdminPrivilege { + # Check for administrative privileges + if (! (Test-PodeAdminPrivilege -Elevate)) { + if (Test-PodeIsWindows -and (Test-PodeUserServiceCreationPrivilege)) { + Write-PodeHost "Insufficient privileges. This script requires Administrator access or the 'SERVICE_CHANGE_CONFIG' (SeCreateServicePrivilege) permission to continue." -ForegroundColor Red + exit + } + + # Message for non-Windows (Linux/macOS) + Write-PodeHost "Insufficient privileges. This script must be run as root or with 'sudo' permissions to continue." -ForegroundColor Red + exit + } +} + +<# +.SYNOPSIS + Tests if a Linux service is registered. + +.DESCRIPTION + Checks if a specified Linux service is registered by using the `systemctl status` command. + It returns `$true` if the service is found or its status code matches either `0` or `3`. + +.PARAMETER Name + The name of the Linux service to test. + +.OUTPUTS + [bool] + Returns `$true` if the service is registered; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeLinuxServiceIsRegistered { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + $nameService = Get-PodeRealServiceName -Name $Name + $systemctlStatus = systemctl status $nameService 2>&1 + $isRegistered = ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 3) + Write-Verbose -Message ($systemctlStatus -join '`n') + return $isRegistered +} + +<# +.SYNOPSIS + Tests if a Linux service is active. + +.DESCRIPTION + Checks if a specified Linux service is currently active by using the `systemctl is-active` command. + It returns `$true` if the service is active. + +.PARAMETER Name + The name of the Linux service to check. + +.OUTPUTS + [bool] + Returns `$true` if the service is active; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeLinuxServiceIsActive { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $systemctlIsActive = systemctl is-active $nameService 2>&1 + $isActive = $systemctlIsActive -eq 'active' + Write-Verbose -Message ($systemctlIsActive -join '`n') + return $isActive +} + +<# +.SYNOPSIS + Disables a Linux service. + +.DESCRIPTION + Disables a specified Linux service by using the `sudo systemctl disable` command. + It returns `$true` if the service is successfully disabled. + +.PARAMETER Name + The name of the Linux service to disable. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully disabled; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Disable-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $systemctlDisable = & sudo systemctl disable $nameService 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($systemctlDisable -join '`n') + return $success +} + +<# +.SYNOPSIS + Enables a Linux service. + +.DESCRIPTION + Enables a specified Linux service by using the `sudo systemctl enable` command. + It returns `$true` if the service is successfully enabled. + +.PARAMETER Name + The name of the Linux service to enable. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully enabled; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Enable-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $systemctlEnable = & sudo systemctl enable $Name 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($systemctlEnable -join '`n') + return $success +} + +<# +.SYNOPSIS + Stops a Linux service. + +.DESCRIPTION + Stops a specified Linux service by using the `systemctl stop` command. + It returns `$true` if the service is successfully stopped. + +.PARAMETER Name + The name of the Linux service to stop. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully stopped; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Stop-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + #return (Send-PodeServiceSignal -Name $Name -Signal SIGTERM) + $serviceStopInfo = & sudo systemctl stop $nameService 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($serviceStopInfo -join "`n") + return $success +} + +<# +.SYNOPSIS + Starts a Linux service. + +.DESCRIPTION + Starts a specified Linux service by using the `systemctl start` command. + It returns `$true` if the service is successfully started. + +.PARAMETER Name + The name of the Linux service to start. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully started; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Start-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $serviceStartInfo = & sudo systemctl start $nameService 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($serviceStartInfo -join "`n") + return $success +} + +<# +.SYNOPSIS + Tests if a macOS service is registered. + +.DESCRIPTION + Checks if a specified macOS service is registered by using the `launchctl list` command. + It returns `$true` if the service is registered. + +.PARAMETER Name + The name of the macOS service to test. + +.PARAMETER Agent + Return only Agent type services. + +.OUTPUTS + [bool] + Returns `$true` if the service is registered; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeMacOsServiceIsRegistered { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + $nameService = Get-PodeRealServiceName -Name $Name + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $systemctlStatus = & sudo launchctl list $nameService 2>&1 + } + else { + $systemctlStatus = & launchctl list $nameService 2>&1 + } + $isRegistered = ($LASTEXITCODE -eq 0) + Write-Verbose -Message ($systemctlStatus -join '`n') + return $isRegistered +} + +<# +.SYNOPSIS + Checks if a Pode service is registered on the current operating system. + +.DESCRIPTION + This function determines if a Pode service with the specified name is registered, + based on the operating system. It delegates the check to the appropriate + platform-specific function or logic. + +.PARAMETER Name + The name of the Pode service to check. + +.EXAMPLE + Test-PodeServiceIsRegistered -Name 'MyService' + + Checks if the Pode service named 'MyService' is registered. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeServiceIsRegistered { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + if (Test-PodeIsWindows) { + $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$Name'" + return $null -eq $service + } + if ($IsLinux) { + return Test-PodeLinuxServiceIsRegistered -Name $Name + } + if ($IsMacOS) { + return Test-PodeMacOsServiceIsRegistered -Name $Name + } +} + +<# +.SYNOPSIS + Checks if a Pode service is active and running on the current operating system. + +.DESCRIPTION + This function determines if a Pode service with the specified name is active (running), + based on the operating system. It delegates the check to the appropriate platform-specific + function or logic. + +.PARAMETER Name + The name of the Pode service to check. + +.EXAMPLE + Test-PodeServiceIsActive -Name 'MyService' + + Checks if the Pode service named 'MyService' is active and running. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeServiceIsActive { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + if (Test-PodeIsWindows) { + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($service) { + # Check if the service is already running + return ($service.Status -ne 'Running') + } + return $false + } + if ($IsLinux) { + return Test-PodeLinuxServiceIsActive -Name $Name + } + if ($IsMacOS) { + return Test-PodeMacOsServiceIsActive -Name $Name + } + +} + + +<# +.SYNOPSIS + Tests if a macOS service is active. + +.DESCRIPTION + Checks if a specified macOS service is currently active by looking for the "PID" value in the output of `launchctl list`. + It returns `$true` if the service is active (i.e., if a PID is found). + +.PARAMETER Name + The name of the macOS service to check. + +.OUTPUTS + [bool] + Returns `$true` if the service is active; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeMacOsServiceIsActive { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + if ($sudo) { + $serviceInfo = & sudo launchctl list $nameService + } + else { + $serviceInfo = & launchctl list $nameService + } + $isActive = $serviceInfo -match '"PID" = (\d+);' + Write-Verbose -Message ($serviceInfo -join "`n") + return $isActive.Count -eq 1 +} + +<# +.SYNOPSIS + Retrieves the PID of a macOS service. + +.DESCRIPTION + Retrieves the process ID (PID) of a specified macOS service by using `launchctl list`. + If the service is not active or a PID cannot be found, the function returns `0`. + +PARAMETER Name + The name of the macOS service whose PID you want to retrieve. + +.OUTPUTS + [int] + Returns the PID of the service if it is active; otherwise, returns `0`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Get-PodeMacOsServicePid { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + if ($sudo) { + $serviceInfo = & sudo launchctl list $nameService + } + else { + $serviceInfo = & launchctl list $nameService + } + $pidString = $serviceInfo -match '"PID" = (\d+);' + Write-Verbose -Message ($serviceInfo -join "`n") + return $(if ($pidString.Count -eq 1) { ($pidString[0].split('= '))[1].trim(';') } else { 0 }) +} + +<# +.SYNOPSIS + Disables a macOS service. + +.DESCRIPTION + Disables a specified macOS service by using `launchctl unload` to unload the service's plist file. + It returns `$true` if the service is successfully disabled. + +.PARAMETER Name + The name of the macOS service to disable. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully disabled; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Disable-PodeMacOsService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + # Standardize service naming for Linux/macOS + $nameService = Get-PodeRealServiceName -Name $Name + + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $systemctlDisable = & sudo launchctl unload "/Library/LaunchDaemons/$nameService.plist" 2>&1 + } + else { + $systemctlDisable = & launchctl unload "$HOME/Library/LaunchAgents/$nameService.plist" 2>&1 + } + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($systemctlDisable -join '`n') + return $success +} + +<# +.SYNOPSIS + Stops a macOS service. + +.DESCRIPTION + Stops a specified macOS service by using the `launchctl stop` command. + It returns `$true` if the service is successfully stopped. + +.PARAMETER Name + The name of the macOS service to stop. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully stopped; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Stop-PodeMacOsService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + + return (Send-PodeServiceSignal -Name $Name -Signal SIGTERM -Agent:$Agent) +} + +<# +.SYNOPSIS + Starts a macOS service. + +.DESCRIPTION + Starts a specified macOS service by using the `launchctl start` command. + It returns `$true` if the service is successfully started. + +.PARAMETER Name + The name of the macOS service to start. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully started; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Start-PodeMacOsService { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + $nameService = Get-PodeRealServiceName -Name $Name + + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $serviceStartInfo = & sudo launchctl start $nameService 2>&1 + } + else { + $serviceStartInfo = & launchctl start $nameService 2>&1 + } + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($serviceStartInfo -join "`n") + return $success +} + +<# +.SYNOPSIS + Sends a specified signal to a Pode service on Linux or macOS. + +.DESCRIPTION + The `Send-PodeServiceSignal` function sends a Unix signal (`SIGTSTP`, `SIGCONT`, `SIGHUP`, or `SIGTERM`) to a specified Pode service. It checks if the service is registered and active before sending the signal. The function supports both standard and elevated privilege operations based on the service's configuration. + +.PARAMETER Name + The name of the Pode service to signal. + +.PARAMETER Signal + The Unix signal to send to the service. Supported signals are: + - `SIGTSTP`: Stop the service temporarily (20). + - `SIGCONT`: Continue the service (18). + - `SIGHUP`: Restart the service (1). + - `SIGTERM`: Terminate the service gracefully (15). + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + [bool] Returns `$true` if the signal was successfully sent, otherwise `$false`. + +.EXAMPLE + Send-PodeServiceSignal -Name "MyPodeService" -Signal "SIGHUP" + + Sends the `SIGHUP` signal to the Pode service named "MyPodeService", instructing it to restart. + +.EXAMPLE + Send-PodeServiceSignal -Name "AnotherService" -Signal "SIGTERM" + + Sends the `SIGTERM` signal to gracefully stop the Pode service named "AnotherService". + +.NOTES + - This function is intended for use on Linux and macOS only. + - Requires administrative/root privileges to send signals to services running with elevated privileges. + - Logs verbose output for troubleshooting. + - This is an internal function and may change in future releases of Pode. +#> +function Send-PodeServiceSignal { + [CmdletBinding()] + [OutputType([bool])] + param( + # The name of the Pode service to signal + [Parameter(Mandatory = $true)] + [string] + $Name, + + # The Unix signal to send to the service + [Parameter(Mandatory = $true)] + [ValidateSet('SIGTSTP', 'SIGCONT', 'SIGHUP', 'SIGTERM')] + [string] + $Signal, + + [switch] + $Agent + ) + + # Standardize service naming for Linux/macOS + $nameService = Get-PodeRealServiceName -Name $Name + + # Map signal names to their corresponding Unix signal numbers + $signalMap = @{ + 'SIGTSTP' = 20 # Stop the process + 'SIGCONT' = 18 # Resume the process + 'SIGHUP' = 1 # Restart the process + 'SIGTERM' = 15 # Gracefully terminate the process + } + + # Retrieve the signal number from the map + $level = $signalMap[$Signal] + + # Check if the service is registered + if ((Test-PodeServiceIsRegistered -Name $nameService)) { + # Check if the service is currently active + if ((Test-PodeServiceIsActive -Name $nameService)) { + Write-Verbose -Message "Service '$Name' is active. Sending $Signal signal." + + # Retrieve service details, including the PID and privilege requirement + $svc = Get-PodeService -Name $Name -Agent:$Agent + + # Send the signal based on the privilege level + if ($svc.Sudo) { + & sudo /bin/kill -$($level) $svc.Pid + } + else { + & /bin/kill -$($level) $svc.Pid + } + + # Check the exit code to determine if the signal was sent successfully + $success = $LASTEXITCODE -eq 0 + if ($success) { + Write-Verbose -Message "$Signal signal sent to service '$Name'." + } + return $success + } + else { + Write-Verbose -Message "Service '$Name' is not running." + } + } + else { + # Throw an exception if the service is not registered + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + # Return false if the signal could not be sent + return $false +} + +<# +.SYNOPSIS + Waits for a Pode service to reach a specified status within a defined timeout period. + +.DESCRIPTION + The `Wait-PodeServiceStatus` function continuously checks the status of a specified Pode service and waits for it to reach the desired status (`Running`, `Stopped`, or `Suspended`). If the service does not reach the desired status within the timeout period, the function returns `$false`. + +.PARAMETER Name + The name of the Pode service to monitor. + +.PARAMETER Status + The desired status to wait for. Valid values are: + - `Running` + - `Stopped` + - `Suspended` + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the desired status. Defaults to 10 seconds. + +.EXAMPLE + Wait-PodeServiceStatus -Name "MyPodeService" -Status "Running" -Timeout 15 + + Waits up to 15 seconds for the Pode service named "MyPodeService" to reach the `Running` status. + +.EXAMPLE + Wait-PodeServiceStatus -Name "AnotherService" -Status "Stopped" + + Waits up to 10 seconds (default timeout) for the Pode service named "AnotherService" to reach the `Stopped` status. + +.OUTPUTS + [bool] Returns `$true` if the service reaches the desired status within the timeout period, otherwise `$false`. + +.NOTES + - The function checks the service status every second until the desired status is reached or the timeout period expires. + - If the service does not reach the desired status within the timeout period, the function returns `$false`. + - This is an internal function and may change in future releases of Pode. +#> +function Wait-PodeServiceStatus { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true)] + [ValidateSet('Running', 'Stopped', 'Suspended')] + [string] + $Status, + + [Parameter(Mandatory = $false)] + [int] + $Timeout = 10 + ) + + # Record the start time for timeout tracking + $startTime = Get-Date + Write-Verbose "Waiting for service '$Name' to reach status '$Status' with a timeout of $Timeout seconds." + + # Begin an infinite loop to monitor the service status + while ($true) { + # Retrieve the current status of the specified Pode service + $currentStatus = Get-PodeServiceStatus -Name $Name + + # Check if the service has reached the desired status + if ($currentStatus.Status -eq $Status) { + Write-Verbose "Service '$Name' has reached the desired status '$Status'." + return $true + } + + # Check if the timeout period has been exceeded + if ((Get-Date) -gt $startTime.AddSeconds($Timeout)) { + Write-Verbose "Timeout reached. Service '$Name' did not reach the desired status '$Status'." + return $false + } + + # Pause execution for 1 second before checking again + Start-Sleep -Seconds 1 + } +} + +<# +.SYNOPSIS + Retrieves the status of a Pode service on Windows, Linux, and macOS. + +.DESCRIPTION + The `Get-PodeServiceStatus` function provides detailed information about the status of a Pode service. + It queries the service's current state, process ID (PID), and whether elevated privileges (Sudo) are required, + adapting its behavior to the platform it runs on: + + - **Windows**: Retrieves service information using the `Win32_Service` class and maps common states to Pode-specific ones. + - **Linux**: Uses `systemctl` to determine the service status and reads additional state information from custom Pode state files if available. + - **macOS**: Checks service status via `launchctl` and processes custom Pode state files when applicable. + +.PARAMETER Name + Specifies the name of the Pode service to query. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Get-PodeServiceStatus -Name "MyPodeService" + Retrieves the status of the Pode service named "MyPodeService". + +.EXAMPLE + Get-PodeServiceStatus -Name "MyPodeService" -Agent + Retrieves the status of the agent-type Pode service named "MyPodeService" (macOS only). + +.OUTPUTS + [PSCustomObject] The function returns a custom object with the following properties: + - **Name**: The name of the service. + - **Status**: The current status of the service (e.g., Running, Stopped, Suspended). + - **Pid**: The process ID of the service. + - **Sudo**: A boolean indicating whether elevated privileges are required. + - **PathName**: The path to the service's configuration or executable. + - **Type**: The type of the service (e.g., Service, Daemon, Agent). + +.NOTES + - **Supported Status States**: Running, Stopped, Suspended, Starting, Stopping, Pausing, Resuming, Unknown. + - Requires administrative/root privileges for accessing service information on Linux and macOS. + - **Platform-specific Behaviors**: + - **Windows**: Leverages CIM to query service information and map states. + - **Linux**: Relies on `systemctl` and custom Pode state files for service details. + - **macOS**: Uses `launchctl` and Pode state files to assess service status. + - If the specified service is not found, the function returns `$null`. + - Logs errors and warnings to assist in troubleshooting. + - This function is internal to Pode and subject to changes in future releases. +#> +function Get-PodeServiceStatus { + [CmdletBinding()] + [OutputType([hashtable])] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + + + if (Test-PodeIsWindows) { + # Check if the service exists on Windows + $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$Name'" + + if ($service) { + switch ($service.State) { + 'Running' { $status = 'Running' } + 'Stopped' { $status = 'Stopped' } + 'Paused' { $status = 'Suspended' } + 'StartPending' { $status = 'Starting' } + 'StopPending' { $status = 'Stopping' } + 'PausePending' { $status = 'Pausing' } + 'ContinuePending' { $status = 'Resuming' } + default { $status = 'Unknown' } + } + return [PSCustomObject]@{ + PsTypeName = 'PodeService' + Name = $Name + Status = $status + Pid = $service.ProcessId + Sudo = $true + PathName = $service.PathName + Type = 'Service' + } + + } + else { + Write-Verbose -Message "Service '$Name' not found." + return $null + } + } + + elseif ($IsLinux) { + try { + $nameService = Get-PodeRealServiceName -Name $Name + # Check if the service exists on Linux (systemd) + if ((Test-PodeLinuxServiceIsRegistered -Name $nameService)) { + $servicePid = 0 + $status = $(systemctl show -p ActiveState $nameService | awk -F'=' '{print $2}') + + switch ($status) { + 'active' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $stateFilePath = "/var/run/podemonitor/$servicePid.state" + if (Test-Path -Path $stateFilePath) { + $status = Get-Content -Path $stateFilePath -Raw + $status = $status.Substring(0, 1).ToUpper() + $status.Substring(1) + } + } + 'reloading' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $status = 'Running' + } + 'maintenance' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $status = 'Suspended' + } + 'inactive' { + $status = 'Stopped' + } + 'failed' { + $status = 'Stopped' + } + 'activating' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $status = 'Starting' + } + 'deactivating' { + $status = 'Stopping' + } + default { + $status = 'Stopped' + } + } + return [PSCustomObject]@{ + PsTypeName = 'PodeService' + Name = $Name + Status = $status + Pid = $servicePid + Sudo = $true + PathName = "/etc/systemd/system/$nameService" + Type = 'Service' + } + } + else { + Write-Verbose -Message "Service '$nameService' not found." + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $null + } + } + + elseif ($IsMacOS) { + try { + $nameService = Get-PodeRealServiceName -Name $Name + # Check if the service exists on macOS (launchctl) + if ((Test-PodeMacOsServiceIsRegistered $nameService -Agent:$Agent)) { + $servicePid = Get-PodeMacOsServicePid -Name $nameService # Extract the PID from the match + + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $stateFilePath = "/Library/LaunchDaemons/PodeMonitor/$servicePid.state" + $plistPath = "/Library/LaunchDaemons/$($nameService).plist" + $serviceType = 'Daemon' + } + else { + $stateFilePath = "$($HOME)/Library/LaunchAgents/PodeMonitor/$servicePid.state" + $plistPath = "$($HOME)/Library/LaunchAgents/$($nameService).plist" + $serviceType = 'Agent' + } + + if (Test-Path -Path $stateFilePath) { + $status = Get-Content -Path $stateFilePath -Raw + $status = $status.Substring(0, 1).ToUpper() + $status.Substring(1) + } + else { + $status = 'Stopped' + } + + return [PSCustomObject]@{ + PsTypeName = 'PodeService' + Name = $Name + Status = $status + Pid = $servicePid + Sudo = $sudo + PathName = $plistPath + Type = $serviceType + } + } + else { + Write-Verbose -Message "Service '$Name' not found." + return $null + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $null + } + } + +} + +<# +.SYNOPSIS + Returns the standardized service name for a Pode service based on the current platform. + +.DESCRIPTION + The `Get-PodeRealServiceName` function formats a Pode service name to match platform-specific conventions: + - On macOS, the service name is prefixed with `pode.` and suffixed with `.service`, with spaces replaced by underscores. + - On Linux, the service name is suffixed with `.service`, with spaces replaced by underscores. + - On Windows, the service name is returned as provided. + +.PARAMETER Name + The name of the Pode service to standardize. + +.EXAMPLE + Get-PodeRealServiceName -Name "My Pode Service" + + For macOS, returns: `pode.My_Pode_Service.service`. + For Linux, returns: `My_Pode_Service.service`. + For Windows, returns: `My Pode Service`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Get-PodeRealServiceName { + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + # If the name already ends with '.service', return it directly + if ($Name -like '*.service') { + return $Name + } + + # Standardize service naming based on platform + if ($IsMacOS) { + return "pode.$Name.service".Replace(' ', '_') + } + elseif ($IsLinux) { + return "$Name.service".Replace(' ', '_') + } + else { + # Assume Windows or unknown platform + return $Name + } +} diff --git a/src/Public/Core.ps1 b/src/Public/Core.ps1 index c8bd2b49a..a0157c17a 100644 --- a/src/Public/Core.ps1 +++ b/src/Public/Core.ps1 @@ -228,7 +228,9 @@ function Start-PodeServer { end { if ($pipelineItemCount -gt 1) { throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) - } # Store the name of the current runspace + } + + # Store the name of the current runspace $previousRunspaceName = Get-PodeCurrentRunspaceName if ([string]::IsNullOrEmpty($ApplicationName)) { @@ -242,6 +244,33 @@ function Start-PodeServer { $Script:PodeContext = $null $ShowDoneMessage = $true + # check if podeWatchdog is configured + if ($PodeService) { + if ($null -ne $PodeService.DisableTermination -or + $null -ne $PodeService.Quiet -or + $null -ne $PodeService.PipeName -or + $null -ne $PodeService.DisableConsoleInput + ) { + $DisableTermination = [switch]$PodeService.DisableTermination + $Quiet = [switch]$PodeService.Quiet + $DisableConsoleInput = [switch]$PodeService.DisableConsoleInput + $IgnoreServerConfig = [switch]$PodeService.IgnoreServerConfig + + if (!([string]::IsNullOrEmpty($PodeService.ConfigFile)) -and !$PodeService.IgnoreServerConfig) { + $ConfigFile = $PodeService.ConfigFile + } + + $monitorService = @{ + DisableTermination = $PodeService.DisableTermination + Quiet = $PodeService.Quiet + PipeName = $PodeService.PipeName + DisableConsoleInput = $PodeService.DisableConsoleInput + ConfigFile = $PodeService.ConfigFile + IgnoreServerConfig = $PodeService.IgnoreServerConfig + } + Write-PodeHost $PodeService -Explode -Force } + } + try { # if we have a filepath, resolve it - and extract a root path from it if ($PSCmdlet.ParameterSetName -ieq 'file') { @@ -279,13 +308,14 @@ function Start-PodeServer { EnableBreakpoints = $EnableBreakpoints IgnoreServerConfig = $IgnoreServerConfig ConfigFile = $ConfigFile + Service = $monitorService ApplicationName = $ApplicationName } # Create main context object $PodeContext = New-PodeContext @ContextParams - + # Define parameter values with comments explaining each one $ConfigParameters = @{ DisableTermination = $DisableTermination # Disable termination of the Pode server from the console diff --git a/src/Public/Endpoint.ps1 b/src/Public/Endpoint.ps1 index e0a1eeaee..e0aa95daa 100644 --- a/src/Public/Endpoint.ps1 +++ b/src/Public/Endpoint.ps1 @@ -393,7 +393,7 @@ function Add-PodeEndpoint { $obj.Url = "$($obj.Protocol)://$($obj.FriendlyName):$($obj.Port)" } # if the address is non-local, then check admin privileges - if (!$Force -and !(Test-PodeIPAddressLocal -IP $obj.Address) -and !(Test-PodeIsAdminUser)) { + if (!$Force -and !(Test-PodeIPAddressLocal -IP $obj.Address) -and !(Test-PodeAdminPrivilege -Console)) { # Must be running with administrator privileges to listen on non-localhost addresses throw ($PodeLocale.mustBeRunningWithAdminPrivilegesExceptionMessage) } diff --git a/src/Public/Metrics.ps1 b/src/Public/Metrics.ps1 index 2334f27c5..62819f161 100644 --- a/src/Public/Metrics.ps1 +++ b/src/Public/Metrics.ps1 @@ -25,6 +25,18 @@ .PARAMETER ExcludeMilliseconds Omits milliseconds from the human-readable output when `-Format` is not `Milliseconds`. +.PARAMETER Readable + If supplied, the uptime will be returned in a human-readable format instead of milliseconds. + +.PARAMETER OutputType + Specifies the format for the human-readable output. Valid options are: + - 'Verbose' for detailed descriptions (e.g., "1 day, 2 hours, 3 minutes"). + - 'Compact' for a compact format (e.g., "dd:hh:mm:ss"). + - Default is concise format (e.g., "1d 2h 3m"). + +.PARAMETER ExcludeMilliseconds + If supplied, milliseconds will be excluded from the human-readable output. + .EXAMPLE $currentUptime = Get-PodeServerUptime # Output: 123456789 (milliseconds) @@ -56,6 +68,7 @@ function Get-PodeServerUptime { [CmdletBinding()] [OutputType([long], [string])] param( + # Common to all parameter sets [switch] $Total, @@ -63,7 +76,7 @@ function Get-PodeServerUptime { [ValidateSet('Milliseconds', 'Concise', 'Compact', 'Verbose')] [string] $Format = 'Milliseconds', - + [switch] $ExcludeMilliseconds ) diff --git a/src/Public/Service.ps1 b/src/Public/Service.ps1 new file mode 100644 index 000000000..a96831b6b --- /dev/null +++ b/src/Public/Service.ps1 @@ -0,0 +1,1184 @@ +<# +.SYNOPSIS + Registers a new Pode-based PowerShell worker as a service on Windows, Linux, or macOS. + +.DESCRIPTION + The `Register-PodeService` function configures and registers a Pode-based service that runs a PowerShell worker across multiple platforms + (Windows, Linux, macOS). It creates the service with parameters such as paths to the worker script, log files, and service-specific settings. + A `srvsettings.json` configuration file is generated, and the service can be optionally started after registration. + +.PARAMETER Name + Specifies the name of the service to be registered. This is a required parameter. + +.PARAMETER Description + A brief description of the service. Defaults to "This is a Pode service." + +.PARAMETER DisplayName + Specifies the display name for the service (Windows only). Defaults to "Pode Service($Name)". + +.PARAMETER StartupType + Specifies the startup type of the service ('Automatic' or 'Manual'). Defaults to 'Automatic'. + +.PARAMETER ParameterString + Any additional parameters to pass to the worker script when the service is run. Defaults to an empty string. + +.PARAMETER LogServicePodeHost + Enables logging for the Pode service host. If not provided, the service runs in quiet mode. + +.PARAMETER ShutdownWaitTimeMs + Maximum time in milliseconds to wait for the service to shut down gracefully before forcing termination. Defaults to 30,000 milliseconds (30 seconds). + +.PARAMETER StartMaxRetryCount + The maximum number of retries to start the PowerShell process before giving up. Default is 3 retries. + +.PARAMETER StartRetryDelayMs + The delay (in milliseconds) between retry attempts to start the PowerShell process. Default is 5,000 milliseconds (5 seconds). + +.PARAMETER WindowsUser + Specifies the username under which the service will run on Windows. Defaults to the current user if not provided. + +.PARAMETER LinuxUser + Specifies the username under which the service will run on Linux. Defaults to the current user if not provided. + +.PARAMETER Agent + Create an Agent instead of a Daemon on macOS (macOS only). + +.PARAMETER Start + A switch to start the service immediately after registration. + +.PARAMETER Password + A secure password for the service account (Windows only). If omitted, the service account defaults to 'NT AUTHORITY\SYSTEM'. + +.PARAMETER SecurityDescriptorSddl + A security descriptor in SDDL format, specifying the permissions for the service (Windows only). + +.PARAMETER SettingsPath + Specifies the directory to store the service configuration file (`_svcsettings.json`). If not provided, a default directory is used. + +.PARAMETER LogPath + Specifies the path for the service log files. If not provided, a default log directory is used. + +.PARAMETER LogLevel + Specifies the log verbosity level. Valid values are 'Debug', 'Info', 'Warn', 'Error', or 'Critical'. Defaults to 'Info'. + +.PARAMETER LogMaxFileSize + Specifies the maximum size of the log file in bytes. Defaults to 10 MB (10,485,760 bytes). + +.PARAMETER IgnoreServerConfig + Prevents the server from loading settings from the server.psd1 configuration file. + +.PARAMETER ConfigFile + Specifies a custom configuration file instead of using the default `server.psd1`. + +.EXAMPLE + Register-PodeService -Name "PodeExampleService" -Description "Example Pode Service" -ParameterString "-Verbose" + + This example registers a Pode service named "PodeExampleService" with verbose logging enabled. + +.NOTES + - Supports cross-platform service registration on Windows, Linux, and macOS. + - Generates a `srvsettings.json` file with service-specific configurations. + - Automatically starts the service using the `-Start` switch after registration. + - Dynamically obtains the PowerShell executable path for compatibility across platforms. +#> +function Register-PodeService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [string] + $Description = 'This is a Pode service.', + + [Parameter()] + [string] + $DisplayName = "Pode Service($Name)", + + [Parameter()] + [string] + [validateset('Manual', 'Automatic')] + $StartupType = 'Automatic', + + [Parameter()] + [string] + $SecurityDescriptorSddl, + + [Parameter()] + [string] + $ParameterString = '', + + [Parameter()] + [switch] + $LogServicePodeHost, + + [Parameter()] + [int] + $ShutdownWaitTimeMs = 30000, + + [Parameter()] + [int] + $StartMaxRetryCount = 3, + + [Parameter()] + [int] + $StartRetryDelayMs = 5000, + + [Parameter()] + [string] + $WindowsUser, + + [Parameter()] + [string] + $LinuxUser, + + [Parameter()] + [switch] + $Start, + + [Parameter()] + [switch] + $Agent, + + [Parameter()] + [securestring] + $Password, + + [Parameter()] + [string] + $SettingsPath, + + [Parameter()] + [string] + $LogPath, + + [Parameter()] + [string] + [validateset('Debug', 'Info', 'Warn', 'Error', 'Critical')] + $LogLevel = 'Info', + + [Parameter()] + [Int64] + $LogMaxFileSize = 10 * 1024 * 1024, + + [Parameter()] + [string] + $ConfigFile, + + [switch] + $IgnoreServerConfig + ) + + # Ensure the script is running with the necessary administrative/root privileges. + # Exits the script if the current user lacks the required privileges. + Confirm-PodeAdminPrivilege + + try { + # Obtain the script path and directory + if ($MyInvocation.ScriptName) { + $ScriptPath = $MyInvocation.ScriptName + $MainScriptPath = Split-Path -Path $ScriptPath -Parent + } + else { + return $null + } + # Define log paths and ensure the log directory exists + if (! $LogPath) { + $LogPath = Join-Path -Path $MainScriptPath -ChildPath 'logs' + } + + if (! (Test-Path -Path $LogPath -PathType Container)) { + $null = New-Item -Path $LogPath -ItemType Directory -Force + } + + $LogFilePath = Join-Path -Path $LogPath -ChildPath "$($Name)_svc.log" + + # Dynamically get the PowerShell executable path + $PwshPath = (Get-Process -Id $PID).Path + + # Define configuration directory and settings file path + if (!$SettingsPath) { + $SettingsPath = Join-Path -Path $MainScriptPath -ChildPath 'svc_settings' + } + + if (! (Test-Path -Path $SettingsPath -PathType Container)) { + $null = New-Item -Path $settingsPath -ItemType Directory + } + + if (Test-PodeIsWindows) { + if ([string]::IsNullOrEmpty($WindowsUser)) { + if ( ($null -ne $Password)) { + $UserName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + } + } + else { + $UserName = $WindowsUser + if ( ($null -eq $Password)) { + throw ($Podelocale.passwordRequiredForServiceUserException -f $UserName) + } + } + } + elseif ($IsLinux) { + if ([string]::IsNullOrEmpty($LinuxUser)) { + $UserName = [System.Environment]::UserName + } + else { + $UserName = $LinuxUser + } + } + + $settingsFile = Join-Path -Path $settingsPath -ChildPath "$($Name)_svcsettings.json" + Write-Verbose -Message "Service '$Name' setting : $settingsFile." + + # Generate the service settings JSON file + $jsonContent = @{ + PodeMonitorWorker = @{ + ScriptPath = $ScriptPath + PwshPath = $PwshPath + ParameterString = $ParameterString + LogFilePath = $LogFilePath + Quiet = !$LogServicePodeHost.IsPresent + DisableTermination = $true + ShutdownWaitTimeMs = $ShutdownWaitTimeMs + Name = $Name + StartMaxRetryCount = $StartMaxRetryCount + StartRetryDelayMs = $StartRetryDelayMs + LogLevel = $LogLevel.ToUpper() + LogMaxFileSize = $LogMaxFileSize + ConfigFile = $ConfigFile + IgnoreServerConfig = $IgnoreServerConfig.IsPresent + } + } + + # Save JSON to the settings file + $jsonContent | ConvertTo-Json | Set-Content -Path $settingsFile -Encoding UTF8 + + # Determine OS architecture and call platform-specific registration functions + $osArchitecture = Get-PodeOSPwshArchitecture + + if ([string]::IsNullOrEmpty($osArchitecture)) { + Write-Verbose 'Unsupported Architecture' + return $false + } + + # Get the directory path where the Pode module is installed and store it in $binPath + $binPath = Join-Path -Path ((Get-Module -Name Pode).ModuleBase) -ChildPath 'Bin' + + if (Test-PodeIsWindows) { + $param = @{ + Name = $Name + Description = $Description + DisplayName = $DisplayName + StartupType = $StartupType + BinPath = $binPath + SettingsFile = $settingsFile + Credential = if ($Password) { [pscredential]::new($UserName, $Password) }else { $null } + SecurityDescriptorSddl = $SecurityDescriptorSddl + OsArchitecture = "win-$osArchitecture" + } + $operation = Register-PodeMonitorWindowsService @param + } + elseif ($IsLinux) { + $param = @{ + Name = $Name + Description = $Description + BinPath = $binPath + SettingsFile = $settingsFile + User = $User + Group = $Group + Start = $Start + OsArchitecture = "linux-$osArchitecture" + } + $operation = Register-PodeLinuxService @param + } + elseif ($IsMacOS) { + $param = @{ + Name = $Name + Description = $Description + BinPath = $binPath + SettingsFile = $settingsFile + User = $User + OsArchitecture = "osx-$osArchitecture" + LogPath = $LogPath + Agent = $Agent + } + + $operation = Register-PodeMacService @param + } + + # Optionally start the service if requested + if ( $operation -and $Start.IsPresent) { + $operation = Start-PodeService -Name $Name + } + + return $operation + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + +<# +.SYNOPSIS + Start a Pode-based service on Windows, Linux, or macOS. + +.DESCRIPTION + The `Start-PodeService` function ensures that a specified Pode-based service is running. If the service is not registered or fails to start, the function throws an error. It supports platform-specific service management commands: + - Windows: Uses `sc.exe`. + - Linux: Uses `systemctl`. + - macOS: Uses `launchctl`. + +.PARAMETER Name + The name of the service to start. + +.PARAMETER Async + Indicates whether to return immediately after issuing the start command. If not specified, the function waits until the service reaches the 'Running' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Running' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Start-PodeService -Name 'MyService' + + Starts the service named 'MyService' if it is not already running. + +.EXAMPLE + Start-PodeService -Name 'MyService' -Async + + Starts the service named 'MyService' and returns immediately. + +.NOTES + - This function checks for necessary administrative/root privileges before execution. + - Service state management behavior: + - If the service is already running, no action is taken. + - If the service is not registered, an error is thrown. + - Service name is retrieved from the `srvsettings.json` file if available. + - Platform-specific commands are invoked to manage service states: + - Windows: `sc.exe start`. + - Linux: `sudo systemctl start`. + - macOS: `sudo launchctl start`. + - Errors and logs are captured for debugging purposes. +#> +function Start-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle the current service state + switch ($service.Status) { + 'Running' { + Write-Verbose -Message "Service '$Name' is already running." + return $true + } + 'Suspended' { + Write-Verbose -Message "Service '$Name' is suspended. Cannot start a suspended service." + return $false + } + 'Stopped' { + Write-Verbose -Message "Service '$Name' is currently stopped. Attempting to start..." + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot start at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Start the service based on the OS + $serviceStarted = $false + if (Test-PodeIsWindows) { + $serviceStarted = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "start '$Name'" + } + elseif ($IsLinux) { + $serviceStarted = Start-PodeLinuxService -Name $Name + } + elseif ($IsMacOS) { + $serviceStarted = Start-PodeMacOsService -Name $Name -Agent:$Agent + } + + # Check if the service start command failed + if (!$serviceStarted) { + throw ($PodeLocale.serviceCommandFailedException -f 'Start', $Name) + } + + # Handle async or wait for start + if ($Async) { + Write-Verbose -Message "Async mode: Service start command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to start (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + +<# +.SYNOPSIS + Stop a Pode-based service on Windows, Linux, or macOS. + +.DESCRIPTION + The `Stop-PodeService` function ensures that a specified Pode-based service is stopped. If the service is not registered or fails to stop, the function throws an error. It supports platform-specific service management commands: + - Windows: Uses `sc.exe`. + - Linux: Uses `systemctl`. + - macOS: Uses `launchctl`. + +.PARAMETER Name + The name of the service to stop. + +.PARAMETER Async + Indicates whether to return immediately after issuing the stop command. If not specified, the function waits until the service reaches the 'Stopped' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Stopped' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Stop-PodeService -Name 'MyService' + + Stops the service named 'MyService' if it is currently running. + +.EXAMPLE + Stop-PodeService -Name 'MyService' -Async + + Stops the service named 'MyService' and returns immediately. + +.NOTES + - This function checks for necessary administrative/root privileges before execution. + - Service state management behavior: + - If the service is not running, no action is taken. + - If the service is not registered, an error is thrown. + - Service name is retrieved from the `srvsettings.json` file if available. + - Platform-specific commands are invoked to manage service states: + - Windows: `sc.exe`. + - Linux: `sudo systemctl stop`. + - macOS: `sudo launchctl stop`. + - Errors and logs are captured for debugging purposes. + +#> +function Stop-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] + $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] + $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle service states + switch ($service.Status) { + 'Stopped' { + Write-Verbose -Message "Service '$Name' is already stopped." + return $true + } + { $_ -eq 'Running' -or $_ -eq 'Suspended' } { + Write-Verbose -Message "Service '$Name' is currently $($service.Status). Attempting to stop..." + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot stop at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Stop the service + $serviceStopped = $false + if (Test-PodeIsWindows) { + $serviceStopped = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "stop '$Name'" + } + elseif ($IsLinux) { + $serviceStopped = Stop-PodeLinuxService -Name $Name + } + elseif ($IsMacOS) { + $serviceStopped = Stop-PodeMacOsService -Name $Name -Agent:$Agent + } + + if (!$serviceStopped) { + throw ($PodeLocale.serviceCommandFailedException -f 'Stop', $Name) + } + + # Handle async or wait for stop + if ($Async) { + Write-Verbose -Message "Async mode: Service stop command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to stop (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Stopped -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + + +<# +.SYNOPSIS + Suspend a specified service on Windows systems. + +.DESCRIPTION + The `Suspend-PodeService` function attempts to suspend a specified service by name. This functionality is supported only on Windows systems using `sc.exe`. On Linux and macOS, the suspend functionality for services is not natively available, and an appropriate error message is returned. + +.PARAMETER Name + The name of the service to suspend. + +.PARAMETER Async + Indicates whether to return immediately after issuing the suspend command. If not specified, the function waits until the service reaches the 'Suspended' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Suspended' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Suspend-PodeService -Name 'MyService' + + Suspends the service named 'MyService' if it is currently running. + +.NOTES + - This function requires administrative/root privileges to execute. + - Platform-specific behavior: + - Windows: Uses `sc.exe` with the `pause` argument. + - Linux: Sends the `SIGTSTP` signal to the service process. + - macOS: Sends the `SIGTSTP` signal to the service process. + - On Linux and macOS, an error is logged if the signal command fails or the functionality is unavailable. + - If the service is already suspended, no action is taken. + - If the service is not registered, an error is thrown. + +#> +function Suspend-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle the current service state + switch ($service.Status) { + 'Suspended' { + Write-Verbose -Message "Service '$Name' is already suspended." + return $true + } + 'Running' { + Write-Verbose -Message "Service '$Name' is currently running. Attempting to suspend..." + } + 'Stopped' { + Write-Verbose -Message "Service '$Name' is stopped. Cannot suspend a stopped service." + return $false + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot suspend at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Suspend the service based on the OS + $serviceSuspended = $false + if (Test-PodeIsWindows) { + $serviceSuspended = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "pause '$Name'" + } + elseif ($IsLinux ) { + $serviceSuspended = ( Send-PodeServiceSignal -Name $Name -Signal 'SIGTSTP') + } + elseif ( $IsMacOS) { + $serviceSuspended = ( Send-PodeServiceSignal -Name $Name -Signal 'SIGTSTP' -Agent:$Agent) + } + + # Check if the service suspend command failed + if (!$serviceSuspended) { + throw ($PodeLocale.serviceCommandFailedException -f 'Suspend', $Name) + } + + # Handle async or wait for suspend + if ($Async) { + Write-Verbose -Message "Async mode: Service suspend command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to suspend (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Suspended -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + + +<# +.SYNOPSIS + Resume a specified service on Windows systems. + +.DESCRIPTION + The `Resume-PodeService` function attempts to resume a specified service by name. This functionality is supported only on Windows systems using `sc.exe`. On Linux and macOS, the resume functionality for services is not natively available, and an appropriate error message is returned. + +.PARAMETER Name + The name of the service to resume. + +.PARAMETER Async + Indicates whether to return immediately after issuing the resume command. If not specified, the function waits until the service reaches the 'Running' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Running' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Resume-PodeService -Name 'MyService' + + Resumes the service named 'MyService' if it is currently paused. + +.NOTES + - This function requires administrative/root privileges to execute. + - Platform-specific behavior: + - Windows: Uses `sc.exe` with the `continue` argument. + - Linux: Sends the `SIGCONT` signal to the service process. + - macOS: Sends the `SIGCONT` signal to the service process. + - On Linux and macOS, an error is logged if the signal command fails or the functionality is unavailable. + - If the service is not paused, no action is taken. + - If the service is not registered, an error is thrown. + +#> +function Resume-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle the current service state + switch ($service.Status) { + 'Running' { + Write-Verbose -Message "Service '$Name' is already running. No need to resume." + return $true + } + 'Suspended' { + Write-Verbose -Message "Service '$Name' is currently suspended. Attempting to resume..." + } + 'Stopped' { + Write-Verbose -Message "Service '$Name' is stopped. Cannot resume a stopped service." + return $false + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot resume at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Resume the service based on the OS + $serviceResumed = $false + if (Test-PodeIsWindows) { + $serviceResumed = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "continue '$Name'" + } + elseif ($IsLinux) { + $serviceResumed = Send-PodeServiceSignal -Name $Name -Signal 'SIGCONT' + } + elseif ($IsMacOS) { + $serviceResumed = Send-PodeServiceSignal -Name $Name -Signal 'SIGCONT' -Agent:$Agent + } + + # Check if the service resume command failed + if (!$serviceResumed) { + throw ($PodeLocale.serviceCommandFailedException -f 'Resume', $Name) + } + + # Handle async or wait for resume + if ($Async) { + Write-Verbose -Message "Async mode: Service resume command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to resume (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + +<# +.SYNOPSIS + Unregisters a Pode-based service across different platforms (Windows, Linux, and macOS). + +.DESCRIPTION + The `Unregister-PodeService` function removes a Pode-based service by checking its status and unregistering it from the system. + The function can stop the service forcefully if it is running, and then remove the service from the service manager. + It works on Windows, Linux (systemd), and macOS (launchctl). + +.PARAMETER Force + A switch parameter that forces the service to stop before unregistering. If the service is running and this parameter is not specified, + the function will throw an error. + +.PARAMETER Name + The name of the service. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Unregister-PodeService -Force + + Unregisters the Pode-based service, forcefully stopping it if it is currently running. + +.EXAMPLE + Unregister-PodeService + + Unregisters the Pode-based service if it is not running. If the service is running, the function throws an error unless the `-Force` parameter is used. + +.NOTES + - The function retrieves the service name from the `srvsettings.json` file located in the script directory. + - On Windows, it uses `Get-Service`, `Stop-Service`, and `Remove-Service`. + - On Linux, it uses `systemctl` to stop, disable, and remove the service. + - On macOS, it uses `launchctl` to stop and unload the service. +#> +function Unregister-PodeService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [switch] + $Force, + + [switch] + $Agent + ) + + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle service state + if ($service.Status -ne 'Stopped') { + if ($Force) { + Write-Verbose -Message "Service '$Name' is not stopped. Stopping the service due to -Force parameter." + if (!(Stop-PodeService -Name $Name)) { + Write-Verbose -Message "Service '$Name' is not stopped." + return $false + } + Write-Verbose -Message "Service '$Name' has been stopped." + } + else { + Write-Verbose -Message "Service '$Name' is not stopped. Use -Force to stop and unregister it." + return $false + } + } + + if (Test-PodeIsWindows) { + + # Remove the service + $null = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "delete '$Name'" + if (Get-PodeService -Name $Name -ErrorAction SilentlyContinue) { + Write-Verbose -Message "Service '$Name' unregistered failed." + throw ($PodeLocale.serviceUnRegistrationException -f $Name) + } + + Write-Verbose -Message "Service '$Name' unregistered successfully." + + # Remove the service configuration + if ($service.PathName -match '"([^"]+)" "([^"]+)"') { + $argument = $Matches[2] + if ( (Test-Path -Path ($argument) -PathType Leaf)) { + Remove-Item -Path ($argument) -ErrorAction SilentlyContinue + } + } + return $true + + } + + elseif ($IsLinux) { + if (! (Disable-PodeLinuxService -Name $Name)) { + Write-Verbose -Message "Service '$Name' unregistered failed." + throw ($PodeLocale.serviceUnRegistrationException -f $Name) + } + + Write-Verbose -Message "Service '$Name' unregistered successfully." + + # Read the content of the service file + if ((Test-path -path $service.PathName -PathType Leaf)) { + $serviceFileContent = & sudo cat $service.PathName + # Extract the SettingsFile from the ExecStart line using regex + $execStart = ($serviceFileContent | Select-String -Pattern 'ExecStart=.*\s+(.*)').ToString() + # Find the index of '/PodeMonitor ' in the string + $index = $execStart.IndexOf('/PodeMonitor ') + ('/PodeMonitor '.Length) + # Extract everything after '/PodeMonitor ' + $settingsFile = $execStart.Substring($index).trim('"') + + & sudo rm $settingsFile + Write-Verbose -Message "Settings file '$settingsFile' removed." + + & sudo rm $service.PathName + Write-Verbose -Message "Service file '$($service.PathName)' removed." + } + + # Reload systemd to apply changes + & sudo systemctl daemon-reload + Write-Verbose -Message 'Systemd daemon reloaded.' + return $true + } + + elseif ($IsMacOS) { + # Disable and unregister the service + if (!(Disable-PodeMacOsService -Name $Name -Agent:$Agent)) { + Write-Verbose -Message "Service '$Name' unregistered failed." + throw ($PodeLocale.serviceUnRegistrationException -f $Name) + } + + Write-Verbose -Message "Service '$Name' unregistered successfully." + + # Check if the plist file exists + if (Test-Path -Path $service.PathName) { + # Read the content of the plist file + $plistXml = [xml](Get-Content -Path $service.PathName -Raw) + if ($plistXml.plist.dict.array.string.Count -ge 2) { + # Extract the second string in the ProgramArguments array (the settings file path) + $settingsFile = $plistXml.plist.dict.array.string[1] + if ($service.Sudo) { + & sudo rm $settingsFile + Write-Verbose -Message "Settings file '$settingsFile' removed." + + & sudo rm $service.PathName + Write-Verbose -Message "Service file '$($service.PathName)' removed." + } + else { + Remove-Item -Path $settingsFile -ErrorAction SilentlyContinue + Write-Verbose -Message "Settings file '$settingsFile' removed." + + Remove-Item -Path $service.PathName -ErrorAction SilentlyContinue + Write-Verbose -Message "Service file '$($service.PathName)' removed." + } + } + } + return $true + } +} + + +<# +.SYNOPSIS + Retrieves the status of a Pode service across different platforms (Windows, Linux, and macOS). + +.DESCRIPTION + The `Get-PodeService` function checks if a Pode-based service is running or stopped on the host system. + It supports Windows (using `Get-Service`), Linux (using `systemctl`), and macOS (using `launchctl`). + The function returns a consistent result across all platforms by providing the service name and status in + a hashtable format. The status is mapped to common states like "Running," "Stopped," "Starting," and "Stopping." + +.PARAMETER Name + The name of the service. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + Hashtable + The function returns a hashtable containing the service name and its status. + For example: @{ Name = "MyService"; Status = "Running" } + +.EXAMPLE + Get-PodeService + + Retrieves the current status of the Pode service defined in the `srvsettings.json` configuration file. + +.EXAMPLE + Get-PodeService + + On Windows: + @{ Name = "MyService"; Status = "Running" } + + On Linux: + @{ Name = "MyService"; Status = "Stopped" } + + On macOS: + @{ Name = "MyService"; Status = "Unknown" } + +.NOTES + - The function reads the service name from the `srvsettings.json` file in the script's directory. + - For Windows, it uses the `Get-Service` cmdlet. + - For Linux, it uses `systemctl` to retrieve the service status. + - For macOS, it uses `launchctl` to check if the service is running. +#> +function Get-PodeService { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + # Ensure the script is running with the necessary administrative/root privileges. + # Exits the script if the current user lacks the required privileges. + Confirm-PodeAdminPrivilege + return Get-PodeServiceStatus -Name $Name -Agent:$Agent +} + +<# +.SYNOPSIS + Restart a Pode service on Windows, Linux, or macOS by sending the appropriate restart signal. + +.DESCRIPTION + The `Restart-PodeService` function handles the restart operation for a Pode service across multiple platforms: + - Windows: Sends a restart control signal (128) using `sc.exe control`. + - Linux/macOS: Sends the `SIGHUP` signal to the service's process ID. + +.PARAMETER Name + The name of the Pode service to restart. + +.PARAMETER Async + Indicates whether to return immediately after issuing the restart command. If not specified, the function waits until the service reaches the 'Running' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Running' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Restart-PodeService -Name "MyPodeService" + + Attempts to restart the Pode service named "MyPodeService" on the current platform. + +.EXAMPLE + Restart-PodeService -Name "AnotherService" -Verbose + + Restarts the Pode service named "AnotherService" with detailed verbose output. + +.NOTES + - This function requires administrative/root privileges to execute. + - Platform-specific behavior: + - Windows: Uses `sc.exe control` with the signal `128` to restart the service. + - Linux/macOS: Sends the `SIGHUP` signal to the service process. + - If the service is not running or suspended, no restart signal is sent. + - If the service is not registered, an error is thrown. + - Errors and logs are captured for debugging purposes. + +#> +function Restart-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] + $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [int] + $Timeout = 10, + + [switch] + $Agent + ) + Write-Verbose -Message "Attempting to restart service '$Name' on platform $([System.Environment]::OSVersion.Platform)..." + + # Ensure the script is running with the necessary administrative/root privileges. + # Exits the script if the current user lacks the required privileges. + Confirm-PodeAdminPrivilege + + try { + + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + # Service is not registered + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + if ('Running' -ne $service.Status ) { + Write-Verbose -Message "Service '$Name' is not Running." + return $false + } + if (Test-PodeIsWindows) { + + Write-Verbose -Message "Sending restart (128) signal to service '$Name'." + if ( Invoke-PodeWinElevatedCommand -Command 'sc control' -Arguments "'$Name' 128") { + if ($Async) { + return $true + } + else { + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + throw ($PodeLocale.serviceCommandFailedException -f 'sc.exe control {0} 128', $Name) + + } + elseif ($IsLinux) { + # Start the service + if (((Send-PodeServiceSignal -Name $Name -Signal 'SIGHUP'))) { + if ($Async) { + return $true + } + else { + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + # Service command '{0}' failed on service '{1}'. + throw ($PodeLocale.serviceCommandFailedException -f 'sudo systemctl start', $Name) + + } + elseif ($IsMacOS) { + # Start the service + if (((Send-PodeServiceSignal -Name $Name -Signal 'SIGHUP' -Agent:$Agent))) { + if ($Async) { + return $true + } + else { + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + # Service command '{0}' failed on service '{1}'. + throw ($PodeLocale.serviceCommandFailedException -f 'sudo systemctl start', $Name) + } + } + catch { + # Log and display the error + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } + + Write-Verbose -Message "Service '$Name' restart operation completed successfully." + return $true +} + + diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index fdc942f3f..a655a69b3 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -873,32 +873,32 @@ function Out-PodeHost { <# .SYNOPSIS -Writes an object to the Host. + Writes an object to the Host. .DESCRIPTION -Writes an object to the Host. -It's advised to use this function, so that any output respects the -Quiet flag of the server. + Writes an object to the Host. + It's advised to use this function, so that any output respects the -Quiet flag of the server. .PARAMETER Object -The object to write. + The object to write. .PARAMETER ForegroundColor -An optional foreground colour. + An optional foreground colour. .PARAMETER NoNewLine -Whether or not to write a new line. + Whether or not to write a new line. .PARAMETER Explode -Show the object content + Show the object content .PARAMETER ShowType -Show the Object Type + Show the Object Type .PARAMETER Label -Show a label for the object + Show a label for the object .PARAMETER Force -Overrides the -Quiet flag of the server. + Overrides the -Quiet flag of the server. .EXAMPLE 'Some output' | Write-PodeHost -ForegroundColor Cyan @@ -908,6 +908,7 @@ function Write-PodeHost { [CmdletBinding(DefaultParameterSetName = 'inbuilt')] param( [Parameter(Position = 0, ValueFromPipeline = $true)] + [Alias('Message')] [object] $Object, diff --git a/tests/integration/Service.Tests.ps1 b/tests/integration/Service.Tests.ps1 new file mode 100644 index 000000000..573ace650 --- /dev/null +++ b/tests/integration/Service.Tests.ps1 @@ -0,0 +1,107 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] +param() + + + +Describe 'Service Lifecycle' { + + BeforeAll { + $isAgent = $false + if ($IsMacOS) { + $isAgent = $true + } + } + it 'register' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Register -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 10 + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + if ($IsMacOS) { + $status.Status | Should -Be 'Running' + $status.Pid | Should -BeGreaterThan 0 + } + else { + $status.Status | Should -Be 'Stopped' + $status.Pid | Should -Be 0 + } + + $status.Name | Should -Be 'Hello Service' + + } + + + it 'start' -Skip:( $IsMacOS) { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Start -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $webRequest = Invoke-WebRequest -uri http://localhost:8080 -ErrorAction SilentlyContinue + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + $webRequest.Content | Should -Be 'Hello, Service!' + } + + it 'pause' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Suspend -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Suspended' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + { Invoke-WebRequest -uri http://localhost:8080 } | Should -Throw + } + + it 'resume' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -resume -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $webRequest = Invoke-WebRequest -uri http://localhost:8080 -ErrorAction SilentlyContinue + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + $webRequest.Content | Should -Be 'Hello, Service!' + } + it 'stop' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Stop -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Stopped' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -Be 0 + + { Invoke-WebRequest -uri http://localhost:8080 } | Should -Throw + } + + it 're-start' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Start -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $webRequest = Invoke-WebRequest -uri http://localhost:8080 -ErrorAction SilentlyContinue + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + $webRequest.Content | Should -Be 'Hello, Service!' + } + + + + it 'unregister' { + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $isAgent = $status.Type -eq 'Agent' + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Unregister -Force -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status | Should -BeNullOrEmpty + { Invoke-WebRequest -uri http://localhost:8080 } | Should -Throw + } + +} \ No newline at end of file diff --git a/tests/unit/Context.Tests.ps1 b/tests/unit/Context.Tests.ps1 index 63949efdc..51330e98f 100644 --- a/tests/unit/Context.Tests.ps1 +++ b/tests/unit/Context.Tests.ps1 @@ -29,7 +29,7 @@ Describe 'Add-PodeEndpoint' { Context 'Valid parameters supplied' { BeforeAll { Mock Test-PodeIPAddress { return $true } - Mock Test-PodeIsAdminUser { return $true } + Mock Test-PodeAdminPrivilege { return $true } } It 'Set just a Hostname address - old' { $PodeContext.Server = @{ Endpoints = @{}; EndpointsMap = @{}; 'Type' = $null } @@ -371,7 +371,7 @@ Describe 'Add-PodeEndpoint' { } It 'Throws an error for not running as admin' { - Mock Test-PodeIsAdminUser { return $false } + Mock Test-PodeAdminPrivilege { return $false } $PodeContext.Server = @{ Endpoints = @{}; EndpointsMap = @{}; 'Type' = $null } { Add-PodeEndpoint -Address '127.0.0.2' -Protocol 'HTTP' } | Should -Throw -ExpectedMessage $PodeLocale.mustBeRunningWithAdminPrivilegesExceptionMessage #'*Must be running with admin*' } @@ -381,7 +381,7 @@ Describe 'Add-PodeEndpoint' { Describe 'Get-PodeEndpoint' { BeforeAll { Mock Test-PodeIPAddress { return $true } - Mock Test-PodeIsAdminUser { return $true } } + Mock Test-PodeAdminPrivilege { return $true } } It 'Returns no Endpoints' { $PodeContext.Server = @{ Endpoints = @{}; EndpointsMap = @{}; Type = $null } diff --git a/tests/unit/Routes.Tests.ps1 b/tests/unit/Routes.Tests.ps1 index b5f746448..118ef18ff 100644 --- a/tests/unit/Routes.Tests.ps1 +++ b/tests/unit/Routes.Tests.ps1 @@ -874,7 +874,7 @@ Describe 'Get-PodeRouteByUrl' { Describe 'Get-PodeRoute' { BeforeAll { Mock Test-PodeIPAddress { return $true } - Mock Test-PodeIsAdminUser { return $true } } + Mock Test-PodeAdminPrivilege { return $true } } BeforeEach { $PodeContext.Server = @{ 'Routes' = @{ 'GET' = @{}; 'POST' = @{}; }; 'FindEndpoints' = @{}; 'Endpoints' = @{}; 'EndpointsMap' = @{}; 'Type' = $null 'OpenAPI' = @{ diff --git a/tests/unit/Service.Tests.ps1 b/tests/unit/Service.Tests.ps1 new file mode 100644 index 000000000..a198585e4 --- /dev/null +++ b/tests/unit/Service.Tests.ps1 @@ -0,0 +1,237 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() + +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } +} + + +Describe 'Register-PodeService' { + BeforeAll { + Mock -CommandName Confirm-PodeAdminPrivilege + Mock -CommandName Register-PodeMonitorWindowsService { return $true } + Mock -CommandName Register-PodeLinuxService { return $true } + Mock -CommandName Register-PodeMacService { return $true } + Mock -CommandName Start-PodeService { return $true } + Mock -CommandName New-Item + Mock -CommandName ConvertTo-Json + Mock -CommandName Set-Content + Mock -CommandName Get-Process + Mock -CommandName Get-Module { return @{ModuleBase = $pwd } } + } + + + Context 'With valid parameters' { + + + It 'Registers a Windows service successfully' -Skip:(!$IsWindows) { + + # Arrange + $params = @{ + Name = 'TestService' + Description = 'Test Description' + DisplayName = 'Test Service Display Name' + StartupType = 'Automatic' + ParameterString = '-Verbose' + LogServicePodeHost = $true + Start = $true + } + # Mock -CommandName (Get-Process -Id $PID).Path -MockWith { 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' } + + # Act + Register-PodeService @params + + # Assert + Assert-MockCalled -CommandName Confirm-PodeAdminPrivilege -Exactly 1 + Assert-MockCalled -CommandName Register-PodeMonitorWindowsService -Exactly 1 + Assert-MockCalled -CommandName Start-PodeService -Exactly 1 + } + + It 'Registers a Linux service successfully' -Skip:(!$IsLinux) { + + $params = @{ + Name = 'LinuxTestService' + Description = 'Linux Test Service' + Start = $true + } + + # Act + Register-PodeService @params + + # Assert + Assert-MockCalled -CommandName Register-PodeLinuxService -Exactly 1 + Assert-MockCalled -CommandName Start-PodeService -Exactly 1 + } + + It 'Registers a macOS service successfully' -Skip:(!$IsMacOS) { + # Arrange + $params = @{ + Name = 'MacTestService' + Description = 'macOS Test Service' + Start = $true + } + + # Act + Register-PodeService @params + + # Assert + Assert-MockCalled -CommandName Register-PodeMacService -Exactly 1 + Assert-MockCalled -CommandName Start-PodeService -Exactly 1 + } + } + + Context 'With invalid parameters' { + It 'Throws an error if Name is missing' { + # Act & Assert + { Register-PodeService -Name $null -Description 'Missing Name' } | Should -Throw + } + + It 'Throws an error if Password is missing for a specified WindowsUser' -Skip:(!$IsWindows) { + # Arrange + $params = @{ + Name = 'TestService' + WindowsUser = 'TestUser' + } + + # Act & Assert + Register-PodeService @params -ErrorAction SilentlyContinue | Should -BeFalse + } + } + +} +Describe 'Start-PodeService' { + BeforeAll { + # Mock the required commands + Mock -CommandName Confirm-PodeAdminPrivilege + Mock -CommandName Invoke-PodeWinElevatedCommand + Mock -CommandName Test-PodeLinuxServiceIsRegistered + Mock -CommandName Test-PodeLinuxServiceIsActive + Mock -CommandName Start-PodeLinuxService + Mock -CommandName Test-PodeMacOsServiceIsRegistered + Mock -CommandName Test-PodeMacOsServiceIsActive + Mock -CommandName Start-PodeMacOsService + Mock -CommandName Write-PodeErrorLog + Mock -CommandName Write-Error + Mock -CommandName Get-PodeServiceStatus { return @{Status = '' } } + } + + Context 'On Windows platform' { + It 'Starts a stopped service successfully' -Skip:(!$IsWindows) { + # Mock a stopped service and simulate it starting + $script:status = 'none' + Mock -CommandName Get-PodeServiceStatus -MockWith { + if ($script:status -eq 'none') { + $script:status = 'Stopped' + } + else { + $script:status = 'Running' + } + [pscustomobject]@{ Name = 'TestService'; Status = $status } + } + Mock -CommandName Wait-PodeServiceStatus { $true } + Mock -CommandName Invoke-PodeWinElevatedCommand -MockWith { $true } + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Invoke-PodeWinElevatedCommand -Exactly 1 + } + + It 'Starts a started service ' -Skip:(!$IsWindows) { + Mock -CommandName Invoke-PodeWinElevatedCommand -MockWith { $null } + Mock -CommandName Get-PodeServiceStatus -MockWith { + [pscustomobject]@{ Name = 'TestService'; Status = 'Running' } + } + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Invoke-PodeWinElevatedCommand -Exactly 0 + } + + + It 'Throws an error if the service is not registered' -Skip:(!$IsWindows) { + + Start-PodeService -Name 'NonExistentService' -ErrorAction SilentlyContinue | Should -BeFalse + } + } + + Context 'On Linux platform' { + It 'Starts a stopped service successfully' -Skip:(!$IsLinux) { + $script:status = 'none' + Mock -CommandName Get-PodeServiceStatus -MockWith { + if ($script:status -eq 'none') { + $script:status = 'Stopped' + } + else { + $script:status = 'Running' + } + [pscustomobject]@{ Name = 'TestService'; Status = $status } + } + Mock -CommandName Wait-PodeServiceStatus { $true } + Mock -CommandName Test-PodeLinuxServiceIsRegistered -MockWith { $true } + Mock -CommandName Start-PodeLinuxService -MockWith { $true } + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Start-PodeLinuxService -Exactly 1 + } + + It 'Starts a started service ' -Skip:(!$IsLinux) { + + Mock -CommandName Start-PodeLinuxService -MockWith { $true } + Mock -CommandName Get-PodeServiceStatus -MockWith { + [pscustomobject]@{ Name = 'TestService'; Status = 'Running' } + } + + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Start-PodeLinuxService -Exactly 0 + } + + It 'Return false if the service is not registered' -Skip:(!$IsLinux) { + Mock -CommandName Test-PodeLinuxServiceIsRegistered -MockWith { $false } + Start-PodeService -Name 'NonExistentService' | Should -BeFalse + } + } + + Context 'On macOS platform' { + It 'Starts a stopped service successfully' -Skip:(!$IsMacOS) { + $script:status = 'none' + Mock -CommandName Get-PodeServiceStatus -MockWith { + if ($script:status -eq 'none') { + $script:status = 'Stopped' + } + else { + $script:status = 'Running' + } + [pscustomobject]@{ Name = 'TestService'; Status = $status } + } + Mock -CommandName Wait-PodeServiceStatus { $true } + Mock -CommandName Test-PodeMacOsServiceIsRegistered -MockWith { $true } + Mock -CommandName Start-PodeMacOsService -MockWith { $true } + + # Act + Start-PodeService -Name 'MacService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Start-PodeMacOsService -Exactly 1 + } + + It 'Return false if the service is not registered' -Skip:(!$IsMacOS) { + Mock -CommandName Test-PodeMacOsServiceIsRegistered -MockWith { $false } + + Start-PodeService -Name 'NonExistentService' | Should -BeFalse + } + } +} + From 0958e39e363418dd08ab8746acee5f699594c98e Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 08:48:59 -0800 Subject: [PATCH 10/12] Squashed commit of the following: commit d845c13221bcdc01562bcc991c5ef054fbf52051 Author: mdaneri Date: Sun Mar 2 08:10:13 2025 -0800 fix sample commit a2e6474e660e8bbb0174da330ee615ca0beb2c4c Author: mdaneri Date: Sun Mar 2 08:00:28 2025 -0800 Ensure ErrorLoggingLevels is only set when ErrorLoggingEnabled is true in Pode listener configuration. Include #1507 fixes commit 79461e8f525cd724fbab08782559a236c81a3f95 Author: mdaneri Date: Sun Mar 2 07:22:24 2025 -0800 remove a $_ | Write-PodeErrorLog when log doesn't exist commit c4b8ec25ff81f82cbf15e55c90e2de79624b7be4 Author: mdaneri Date: Sat Mar 1 19:06:45 2025 -0800 update test commit 87e42b93223b54a49cc5e61855f2e6e47020b0f4 Merge: a19e5d34 67505f56 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Feb 23 07:31:25 2025 -0800 Merge branch 'develop' into Log-rest-syslog commit a19e5d34402b1f230ea4ba53e250d8d9134c9cb9 Author: mdaneri Date: Sat Feb 22 09:25:49 2025 -0800 merged commit 228e84bd7e02d2c829318d7f739f7bc4425a23c8 Merge: 282875e3 cbdc62fe Author: mdaneri Date: Sat Feb 22 09:25:41 2025 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 282875e3271bf0ffc50f3cd6feb425da96f51d19 Author: mdaneri Date: Sat Feb 22 07:56:15 2025 -0800 Update OpenApi.Tests.ps1 commit e60b03956d10cdff229c445ca87f0810dae85ddc Merge: 534fe566 fbf6ecfb Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sat Feb 22 06:33:21 2025 -0800 Merge branch 'develop' into Log-rest-syslog commit 534fe566a124610a91964ae4a89a4732295a0eaa Merge: 3a83ea84 a76741bc Author: mdaneri Date: Sun Feb 16 07:09:03 2025 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 3a83ea84f3ac7682640477acb8b8135734b8fd40 Merge: ad896c61 a236a1a0 Author: mdaneri Date: Tue Feb 11 18:51:53 2025 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit ad896c616f2ba5394f6a4209334c0409871e4c68 Merge: 115515b4 75e29626 Author: mdaneri Date: Sun Feb 9 07:25:15 2025 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 115515b4a8d2b2c746fcd867768dc73adc4b4174 Author: mdaneri Date: Fri Feb 7 08:05:03 2025 -0800 . commit 01e5f1671ab32701aaf920b281a66956a368cc1f Author: mdaneri Date: Fri Feb 7 04:07:35 2025 -0800 Update Server.Tests.ps1 commit 99e7b43101a5756c4a07cb9371fec86d2066a289 Author: mdaneri Date: Thu Feb 6 06:31:33 2025 -0800 minor changes commit 64ab1c9fb0fd46813cdec5e08c5d8410cf485744 Author: mdaneri Date: Thu Feb 6 06:13:17 2025 -0800 Update Pode.psd1 commit 545b5f1bbb53962d995bae68ad913b443812ed3b Merge: bf258991 6b23fc33 Author: mdaneri Date: Wed Feb 5 14:56:38 2025 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit bf2589911dc7d205160a49c7d9a5d4238cb5a0e3 Author: mdaneri Date: Tue Feb 4 09:10:47 2025 -0800 fixes post merge commit f047cb641bf8f66054200c3b99a49adf5f925241 Author: mdaneri Date: Tue Feb 4 08:12:42 2025 -0800 Update Logging.ps1 commit 1dde713e2b46e4438978ff85ad97d4c65adea4de Author: mdaneri Date: Sun Jan 26 11:57:24 2025 -0800 Update Pode.psd1 commit 70683a0ae24c8c6f8c759128f5530072af7ceb51 Merge: 0de73eb3 f4db4b62 Author: mdaneri Date: Sun Jan 26 11:57:21 2025 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 0de73eb3587cdf3b6dba1fd263bd59e14724abf6 Merge: a9926dde 391bdfff Author: mdaneri Date: Sun Nov 24 17:06:09 2024 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit a9926dde9c988bb948b6c48c0042edfc0b373fa3 Merge: 6d9e798b 7a2cf535 Author: mdaneri Date: Sat Nov 23 08:02:23 2024 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 6d9e798bd8e2369688a6be81492f84b239faa027 Author: mdaneri Date: Fri Nov 22 06:19:56 2024 -0800 Update pode.build.ps1 commit 1ea0a7f7d44ba204ac8b46b4b04d8ce76aafaa8d Merge: 09fd277d 5a0bee12 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Wed Nov 6 05:49:47 2024 -0800 Merge branch 'develop' into Log-rest-syslog commit 09fd277dbbb234f0c2e1d2a4d3a27f534114855b Merge: a9f6855d 62bc7052 Author: mdaneri Date: Sun Nov 3 15:50:49 2024 -0800 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit a9f6855d48f47711b8e69059b6b38890ce0a5671 Merge: 6c9aa801 312654bf Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Nov 3 12:41:33 2024 -0800 Merge branch 'develop' into Log-rest-syslog commit 6c9aa8016b36b64f83af1f2b1699431bc2e2e4ef Author: mdaneri Date: Sat Nov 2 14:08:59 2024 -0700 fix merge commit 1d10e205d7fb39bd103ad10f066cba013b0fc7a3 Merge: a530fc51 a37f33b3 Author: mdaneri Date: Sat Nov 2 14:08:50 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit a530fc51b250bae3abc8f067c380d3c85f831970 Author: mdaneri Date: Sat Nov 2 10:19:05 2024 -0700 remove get-PodeloggingName commit 6119380acde1d82aee01080595a54f675635b765 Author: mdaneri Date: Sat Nov 2 09:44:23 2024 -0700 Syslog format in C# commit b529180e3342f19e27c1bba0e732f00041dc6542 Author: mdaneri Date: Sat Nov 2 08:28:47 2024 -0700 FIx extended format commit 4877db0a6ef55ce7aede6e20227ae460544e222a Author: mdaneri Date: Sat Nov 2 07:57:17 2024 -0700 moving some log formatting to C# commit 7ffef304496d22381eda6189df1e7517f70cea81 Author: mdaneri Date: Sat Nov 2 07:25:47 2024 -0700 rename WriteError to LogMessagen and WriteException to LogException commit 4de99c1f1b0821232b40b76da1d38785f70cc33e Author: mdaneri Date: Sat Nov 2 07:19:41 2024 -0700 Add support multiple restful syslog commit d4894600a239c684e418edb083f73e8664234a4f Author: mdaneri Date: Fri Nov 1 10:06:42 2024 -0700 add try catch commit 83b6f5aaa756b83a6ebae7ef8224344c8afaf4fe Author: mdaneri Date: Fri Nov 1 10:00:20 2024 -0700 improvements commit af602322710bdf3ac43531ef3ce140bc580dcdac Author: mdaneri Date: Fri Nov 1 09:04:03 2024 -0700 merging tag with source commit 6c26f67ef5d709e8ef68141cf60146dc62883284 Merge: da1c605f 308035d8 Author: mdaneri Date: Wed Oct 30 07:24:49 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit da1c605f63abdaf3d0a8e1bc1f8c714b2a865387 Author: mdaneri Date: Mon Oct 28 09:31:18 2024 -0700 fix servless test commit f9ab92009b0cbb362211d60fb2eacc6798b71474 Author: mdaneri Date: Mon Oct 28 09:18:10 2024 -0700 fix test and file logging commit 1efbc898163327c0eeb032c61cfa3c4109f822ff Author: mdaneri Date: Mon Oct 28 08:59:41 2024 -0700 File service fixes commit fe20cfb92c888bd72ce0cd2775094a394725d49a Author: mdaneri Date: Mon Oct 28 07:35:57 2024 -0700 Fix tests + bugs post merge commit 0504b1b5baeb5c58fc2683d6e7fb5b49bb611e4a Merge: 1be91635 c47ad6f7 Author: mdaneri Date: Mon Oct 28 07:15:31 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 1be91635b7fcbaac4386024708e7aa57044a7edb Author: mdaneri Date: Sun Oct 27 20:04:52 2024 -0700 functions change commit e4ebc2a21691b5cf388b90a16c125c8e7a705797 Merge: 48595850 09d9ad02 Author: mdaneri Date: Sun Oct 27 16:56:39 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 4859585052e15039e2659be89aed5787be37cc69 Author: mdaneri Date: Fri Oct 25 17:59:07 2024 -0700 Fix Levels and cleanup commit 5a77d59f8e37e7152db3e7d93baa1acc756ced2c Author: mdaneri Date: Fri Oct 25 17:18:13 2024 -0700 revert PodetraceLog commit 34eecb689cfcdad825df98764fcb846025a2f45b Merge: 9e482419 c8e23fba Author: mdaneri Date: Wed Oct 23 12:59:20 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 9e482419c340e5b0ccc6e10147252022225bad81 Merge: 078737ab 0b08d675 Author: mdaneri Date: Wed Oct 23 07:42:13 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 078737ab798ca8d7086f3d652f7eeb82c72b9aae Author: mdaneri Date: Mon Oct 21 18:53:36 2024 -0700 Update Context.ps1 commit 58e1d582616f8b73636b38c1e22e6e0154ee0102 Author: mdaneri Date: Mon Oct 21 08:28:37 2024 -0700 changed New-PodeCustomLoggingMethod to support only runspace commit 7e0514a07d147531b837a60193acedd42ee267c4 Author: mdaneri Date: Mon Oct 21 08:07:53 2024 -0700 Update Pode.psd1 commit b021e7e68ebf40f649c8163f1c39a5c092b52c13 Merge: 94da1986 79ec4681 Author: mdaneri Date: Mon Oct 21 08:07:49 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 94da198609c131e1a1bfe804769a07294f410b83 Author: mdaneri Date: Sun Oct 20 21:32:22 2024 -0700 function headers commit a0b4673203190d3147e54a3a19242a54d9711944 Author: mdaneri Date: Sun Oct 20 20:46:05 2024 -0700 New-PodeLoggingMethod deprecation commit bd92d534b01c79cf2f5952cdfc279b4c1bffe96e Author: mdaneri Date: Sun Oct 20 18:08:07 2024 -0700 fix test commit 7858e47a904939b383f3234897446dd5bf450eb4 Author: mdaneri Date: Sun Oct 20 09:34:08 2024 -0700 removed redundant sg function commit 155b7d9a5f3e49296f8514303efc0a49b17ee152 Merge: ce3ebcf1 04115807 Author: mdaneri Date: Sun Oct 20 09:24:56 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit ce3ebcf1140b5f7741a8885323b1eb73539f6189 Author: mdaneri Date: Sun Oct 20 09:24:39 2024 -0700 fix an PodeContext.cs created by a merge Performance improvement commit d16456f865bce5ce0910047d5d99cac85fa8f662 Author: mdaneri Date: Sat Oct 19 13:24:32 2024 -0700 minor changes commit 4d22cc39329af4fa1e60086bb052dad7a6b47c6b Merge: e8381732 a1811722 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Fri Oct 18 20:30:09 2024 -0400 Merge branch 'develop' into Log-rest-syslog commit e8381732b58863c3f314d0abb33e6825a726ab75 Merge: 51e1ed91 696cc43a Author: mdaneri Date: Wed Oct 16 07:55:33 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 51e1ed91108008e5749b53ecaf9ab6c296633def Merge: b0720ccc 21c76a16 Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Sep 29 08:06:56 2024 -0700 Merge branch 'develop' into Log-rest-syslog commit b0720ccc1305a413e9ecf93c52145f4efb18b6bc Author: mdaneri Date: Sat Sep 28 11:00:14 2024 -0700 Update Logging.ps1 commit 7de7e6979b91b7bbccefc6e2f7feb2616135b873 Merge: 3df186a9 2e9d252a Author: mdaneri Date: Sat Sep 28 10:59:51 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 3df186a92ac4f0c276189710315b31a805012b60 Merge: 984822ea 3ba72285 Author: mdaneri Date: Sat Sep 28 10:14:16 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 984822eabbf475cd2fc9b699722a50152886b573 Author: mdaneri Date: Sat Sep 28 09:03:17 2024 -0700 fix Locales duplicated key commit a7892acb6dc4e8379f3fffe8fea7bb4f51ed3412 Merge: 8005ef84 af574341 Author: mdaneri Date: Sat Sep 28 08:32:31 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 8005ef849bb365a58a8806787cc69513d50b40f0 Merge: c284d8c1 38f2f072 Author: mdaneri Date: Fri Sep 27 07:50:27 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit c284d8c1a88e071f24cb4c3f2e47141898e3ad62 Author: mdaneri Date: Mon Sep 23 08:23:59 2024 -0700 Fix runspace naming commit 60f8601307ff9a81e4a27d250b597299ca9b23ca Author: mdaneri Date: Sun Sep 22 08:44:12 2024 -0700 fix en_us language commit 811cbd628dd96aabedb3272d3669b27d65532594 Author: mdaneri Date: Sun Sep 22 08:24:09 2024 -0700 modified: src/Private/Context.ps1 commit 992c87f61bd2414d1fc25157275bf14ac483f843 Merge: 50a222e9 473c9be7 Author: mdaneri Date: Sun Sep 22 08:24:06 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 50a222e943b8261309c5140ca074ef460a051915 Merge: d7fa9f7a d51eda6b Author: mdaneri <17148649+mdaneri@users.noreply.github.com> Date: Sun Sep 22 06:24:08 2024 -0700 Merge branch 'develop' into Log-rest-syslog commit d7fa9f7a7c3a821aebca9ec0814131b6316f6df2 Author: mdaneri Date: Sat Sep 21 14:29:53 2024 -0700 fix tests commit 1deeffce531cfc0ac5d6b78daaebefd43fe07578 Merge: d775128b 93df8761 Author: mdaneri Date: Sat Sep 21 10:16:02 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit d775128ba13d84f47e42f0a43a543eee4b29f5f6 Merge: 25120b07 9a99c962 Author: mdaneri Date: Sun Sep 15 15:18:21 2024 -0700 Merge remote-tracking branch 'upstream/develop' into Log-rest-syslog commit 25120b07ef03fcdcc51924d71163f09b22ef820e Author: mdaneri Date: Sat Sep 7 20:56:17 2024 -0700 recovered from Add New Logging Features: Syslog and Restful Support #1335 --- docs/Getting-Started/Migrating/0X-to-1X.md | 2 +- docs/Tutorials/Configuration.md | 2 + docs/Tutorials/Logging/Methods/Custom.md | 63 +- docs/Tutorials/Logging/Methods/File.md | 39 + docs/Tutorials/Logging/Methods/Syslog.md | 56 + docs/Tutorials/Logging/Overview.md | 61 +- docs/Tutorials/Logging/Types/General.md | 70 + docs/Tutorials/Logging/Types/Requests.md | 24 +- examples/Logging.ps1 | 89 +- examples/Session-Data.ps1 | 84 + examples/server.psd1 | 1 + src/Listener/PodeContext.cs | 39 +- src/Listener/PodeFileWatcher.cs | 2 +- src/Listener/PodeFormat.cs | 257 +++ src/Listener/PodeHelpers.cs | 52 +- src/Listener/PodeListener.cs | 16 +- src/Listener/PodeLogger.cs | 271 +++ src/Listener/PodeReceiver.cs | 8 +- src/Listener/PodeRequest.cs | 21 +- src/Listener/PodeResponse.cs | 16 +- src/Listener/PodeResponseHeaders.cs | 48 +- src/Listener/PodeSignalRequest.cs | 4 +- src/Listener/PodeSmtpRequest.cs | 2 +- src/Listener/PodeSocket.cs | 28 +- src/Listener/PodeTcpRequest.cs | 2 +- src/Listener/PodeWatcher.cs | 8 +- src/Listener/PodeWebSocket.cs | 10 +- src/Locales/ar/Pode.psd1 | 7 +- src/Locales/de/Pode.psd1 | 7 +- src/Locales/en-us/Pode.psd1 | 7 +- src/Locales/en/Pode.psd1 | 7 +- src/Locales/es/Pode.psd1 | 7 +- src/Locales/fr/Pode.psd1 | 7 +- src/Locales/it/Pode.psd1 | 7 +- src/Locales/ja/Pode.psd1 | 7 +- src/Locales/ko/Pode.psd1 | 7 +- src/Locales/nl/Pode.psd1 | 7 +- src/Locales/pl/Pode.psd1 | 7 +- src/Locales/pt/Pode.psd1 | 7 +- src/Locales/zh/Pode.psd1 | 7 +- src/Pode.psd1 | 27 +- src/Private/Console.ps1 | 1 + src/Private/Context.ps1 | 97 +- src/Private/FileWatchers.ps1 | 4 +- src/Private/Helpers.ps1 | 3 + src/Private/Logging.ps1 | 1044 ++++++++-- src/Private/PodeServer.ps1 | 2 +- src/Private/Server.ps1 | 4 +- src/Private/SmtpServer.ps1 | 2 +- src/Private/TcpServer.ps1 | 2 +- src/Private/WebSockets.ps1 | 2 +- src/Public/Core.ps1 | 5 + src/Public/EndWare.ps1 | 65 + src/Public/Logging.ps1 | 1088 +++++++--- src/Public/LoggingMethod.ps1 | 2188 ++++++++++++++++++++ src/Public/Utilities.ps1 | 78 - tests/integration/OpenApi.Tests.ps1 | 137 +- tests/integration/Sessions.Tests.ps1 | 7 +- tests/shared/TestHelper.ps1 | 310 +++ tests/unit/Authentication.Tests.ps1 | 2 +- tests/unit/Helpers.Tests.ps1 | 3 +- tests/unit/Logging.Tests.ps1 | 110 +- tests/unit/Schedules.Tests.ps1 | 2 +- tests/unit/Server.Tests.ps1 | 18 +- tests/unit/Serverless.Tests.ps1 | 9 +- tests/unit/Timers.Tests.ps1 | 3 +- 66 files changed, 5640 insertions(+), 939 deletions(-) create mode 100644 docs/Tutorials/Logging/Methods/Syslog.md create mode 100644 docs/Tutorials/Logging/Types/General.md create mode 100644 examples/Session-Data.ps1 create mode 100644 src/Listener/PodeFormat.cs create mode 100644 src/Listener/PodeLogger.cs create mode 100644 src/Public/EndWare.ps1 create mode 100644 src/Public/LoggingMethod.ps1 diff --git a/docs/Getting-Started/Migrating/0X-to-1X.md b/docs/Getting-Started/Migrating/0X-to-1X.md index 8760d6d78..be6423609 100644 --- a/docs/Getting-Started/Migrating/0X-to-1X.md +++ b/docs/Getting-Started/Migrating/0X-to-1X.md @@ -154,7 +154,7 @@ Request and Error logging are inbuilt logging types that can be enabled using [` | [`Disable-PodeRequestLogging`](../../../Functions/Logging/Disable-PodeRequestLogging) | | [`Disable-PodeErrorLogging`](../../../Functions/Logging/Disable-PodeErrorLogging) | | [`Remove-PodeLogger`](../../../Functions/Logging/Remove-PodeLogger) | -| [`Clear-PodeLoggers`](../../../Functions/Logging/Clear-PodeLoggers) | +| [`Clear-PodeLogger`](../../../Functions/Logging/Clear-PodeLogger) | ### Writing Logs diff --git a/docs/Tutorials/Configuration.md b/docs/Tutorials/Configuration.md index f6a8720fa..b50149717 100644 --- a/docs/Tutorials/Configuration.md +++ b/docs/Tutorials/Configuration.md @@ -92,6 +92,8 @@ A "path" like `Server.Ssl.Protocols` looks like the below in the file: | Server.FileMonitor | Defines configuration for restarting the server based on file updates | [link](../Restarting/Types/FileMonitoring) | | Server.ReceiveTimeout | Define the amount of time a Receive method call will block waiting for data | [link](../Endpoints/Basic/StaticContent/#server-timeout) | | Server.DefaultFolders | Set the Default Folders paths | [link](../Routes/Utilities/StaticContent/#changing-the-default-folders) | +| Server.Logging.QueueLimit | Set the maximum number of logs allowed in the queue | [link](../Logging/Overview) | +| Server.Logging.Masking.Patterns | Regular expressions congiguration to mask sensitive logs information | [link](../Logging/Overview) | | Server.Console | Set the Console settings | [link](../Getting-Started/Console) | | Web.OpenApi.DefaultDefinitionTag | Define the primary tag name for OpenAPI ( `default` is the default) | [link](../OpenAPI/Overview) | | Web.OpenApi.UsePodeYamlInternal | Force the use of the internal YAML converter (`False` is the default) | | diff --git a/docs/Tutorials/Logging/Methods/Custom.md b/docs/Tutorials/Logging/Methods/Custom.md index 8fe03a7b1..a6cde3cc4 100644 --- a/docs/Tutorials/Logging/Methods/Custom.md +++ b/docs/Tutorials/Logging/Methods/Custom.md @@ -1,35 +1,76 @@ # Custom -Sometimes you don't want to log to a file, or the terminal; instead you want to log to something better, like LogStash, Splunk, Athena, or any other central logging platform. Although Pode doesn't have these inbuilt (yet!) it is possible to create a custom logging method, where you define a ScriptBlock with logic to send logs to these platforms. +Sometimes you may want to log to platforms other than a file or the terminal, such as LogStash, Splunk, Athena, or other central logging platforms. Although Pode doesn't have these integrations built-in (yet!), it is possible to create a custom logging method by defining a ScriptBlock with the logic to send logs to these platforms. -These custom method can be used for any log type - Requests, Error, or Custom. +Custom methods can be used for any log type: Requests, Error, or Custom. -The ScriptBlock you create will be supplied two arguments: +The ScriptBlock you create will receive two arguments: -1. The item to be logged. This could be a string (from Requests/Errors), or any custom type. -2. The options you supplied on [`New-PodeLoggingMethod`](../../../../Functions/Logging/New-PodeLoggingMethod). +1. The item to be logged. This could be a string (from Requests/Errors) or any custom type. + +2. The options you supplied to [`New-PodeLoggingMethod`](../../../../Functions/Logging/New-PodeLoggingMethod). + +Additionally, custom logging methods can be run in their own runspace by using the `-UseRunspace` parameter, ensuring isolation and efficiency. ## Examples ### Send to S3 Bucket -This example will take whatever item is supplied to it, convert it to a string, and then send it off to some S3 bucket in AWS. In this case, it will be logging Requests: +This example takes the supplied item, converts it to a string, and sends it to an S3 bucket in AWS. In this case, it will log Requests: +#### Legacy (No Runspace) ```powershell $s3_options = @{ AccessKey = $AccessKey SecretKey = $SecretKey } -$s3_logging = New-PodeLoggingType -Custom -ArgumentList $s3_options -ScriptBlock { +$s3_logging = New-PodeLoggingMethod -Custom -ArgumentList $s3_options -ScriptBlock { param($item, $s3_opts) - Write-S3Object ` - -BucketName '' ` - -Content $item.ToString() ` - -AccessKey $s3_opts.AccessKey ` + Write-S3Object \` + -BucketName '' \` + -Content $item.ToString() \` + -AccessKey $s3_opts.AccessKey \` -SecretKey $s3_opts.SecretKey } +$s3_logging | Enable-PodeRequestLogging +``` + +#### With Runspace + +```powershell +$s3_options = @{ + AccessKey = $AccessKey + SecretKey = $SecretKey +} + +$s3_logging = New-PodeLoggingMethod -Custom -UseRunspace -CustomOptions $s3_options -ScriptBlock { + # No param() allowed here + Write-S3Object \` + -BucketName '' \` + -Content $Item.ToString() \` + -AccessKey $Options.AccessKey \` + -SecretKey $Options.SecretKey +} $s3_logging | Enable-PodeRequestLogging ``` + + +In this example, the `-UseRunspace` parameter ensures that the custom logging method runs in its own runspace, providing better isolation and performance. + +##### Variable available inside the ScriptBlock + +| Variable | Type | Description | +| ----------------------- | ----------------------------- | ------------------------------------------------ | +| Item | string | Log message content | +| Options | hashtable | The options supplied to the logging method | +| Options.FailureAction | string (Ignore, Report, Halt) | Defines the behavior in case of failure. | +| Options.DataFormat | string | The date format to use for the log entries. | +| Options.AsUTC | boolean | the time is logged in UTC instead of local time. | +| Options. | PSObject | Any key passed using `-CustomOptions` parameter | +| RawItem | hashtable | Log message in raw format | + + +By leveraging custom logging methods, you can extend Pode's logging capabilities to integrate with a wide range of external platforms, providing flexibility and control over your logging strategy. \ No newline at end of file diff --git a/docs/Tutorials/Logging/Methods/File.md b/docs/Tutorials/Logging/Methods/File.md index 84b28df21..946923a79 100644 --- a/docs/Tutorials/Logging/Methods/File.md +++ b/docs/Tutorials/Logging/Methods/File.md @@ -39,3 +39,42 @@ By default Pode puts all logs in the `./logs` directory. You can use a custom pa ```powershell New-PodeLoggingMethod -File -Name 'requests' -Path 'E:/logs' | Enable-PodeRequestLogging ``` + +### Format + +The Format parameter allows you to specify the format of the log entries. Available options are: + +- RFC3164 +- RFC5424 +- Simple +- Default (default option) + +The Simple format uses the following structure: timestamp level source message. The Default format uses the legacy Pode format. + +```powershell +New-PodeLoggingMethod -File -Name 'requests' -Format 'Simple' | Enable-PodeRequestLogging +``` +A log entry using the Simple format might look like this: + +```arduino +2024-08-01T12:00:00Z INFO MyApp "Request received" +``` + +### Custom Separator +When using the Simple format, you can specify a custom separator for log entries: + +```powershell +New-PodeLoggingMethod -File -Name 'requests' -Format 'Simple' -Separator ',' | Enable-PodeRequestLogging +``` + +A log entry using the Simple format with a comma separator might look like this: +```arduino +2024-08-01T12:00:00Z,INFO,MyApp,"Request received" +``` + +### Maximum Log Entry Length +The MaxLength parameter sets the maximum length of log entries. The default value is -1, which means no limit. + +```powershell +New-PodeLoggingMethod -File -Name 'requests' -MaxLength 500 | Enable-PodeRequestLogging +``` \ No newline at end of file diff --git a/docs/Tutorials/Logging/Methods/Syslog.md b/docs/Tutorials/Logging/Methods/Syslog.md new file mode 100644 index 000000000..eba6db8d2 --- /dev/null +++ b/docs/Tutorials/Logging/Methods/Syslog.md @@ -0,0 +1,56 @@ + +# Syslog + +Pode supports logging items to a Syslog server using the inbuilt Syslog logging method. This method allows you to define various parameters such as the Syslog server address, port, transport protocol, and more. The logging method will convert any item to a string and send it to the configured Syslog server. + +By default, Pode will use UDP as the transport protocol and RFC5424 as the Syslog protocol. You can customize these settings based on your Syslog server requirements. + +## Examples + +### Basic + +The following example will setup the Syslog logging method for logging requests: + +```powershell +New-PodeLoggingMethod -Syslog -Server '192.168.1.1' | Enable-PodeRequestLogging +``` + +### Custom Port + +The following example will configure Syslog logging to use a custom port. The default port is 514, but you can specify a different port if needed: + +```powershell +New-PodeLoggingMethod -Syslog -Server '192.168.1.1' -Port 1514 | Enable-PodeRequestLogging +``` + +### Secure Connection with TLS + +The following example will configure Syslog logging to use TLS for a secure connection. You can also specify the TLS protocol version to use: + +```powershell +New-PodeLoggingMethod -Syslog -Server '192.168.1.1' -Transport 'TLS' -TlsProtocol 'TLS1.2' | Enable-PodeRequestLogging +``` + +### Custom Syslog Protocol + +The following example will configure Syslog logging to use a different Syslog protocol. The default protocol is RFC5424, but you can specify RFC3164 if needed: + +```powershell +New-PodeLoggingMethod -Syslog -Server '192.168.1.1' -SyslogProtocol 'RFC3164' | Enable-PodeRequestLogging +``` + +### Skip Certificate Validation + +The following example will configure Syslog logging to skip certificate validation for TLS connections. This is useful for testing purposes but not recommended for production environments: + +```powershell +New-PodeLoggingMethod -Syslog -Server '192.168.1.1' -Transport 'TLS' -SkipCertificateCheck | Enable-PodeRequestLogging +``` + +### Custom Encoding + +The following example will configure Syslog logging to use a different encoding for the Syslog messages. The default encoding is UTF8: + +```powershell +New-PodeLoggingMethod -Syslog -Server '192.168.1.1' -Encoding 'ASCII' | Enable-PodeRequestLogging +``` \ No newline at end of file diff --git a/docs/Tutorials/Logging/Overview.md b/docs/Tutorials/Logging/Overview.md index ac6527c05..36fb528dd 100644 --- a/docs/Tutorials/Logging/Overview.md +++ b/docs/Tutorials/Logging/Overview.md @@ -1,15 +1,17 @@ # Overview -There are two aspects to logging in Pode: Methods and Types. +Logging in Pode consists of two main components: Methods and Types. -* Methods define how log items should be recorded, such as to a file, terminal, or event viewer. -* Types define how items to log are transformed, and what should be supplied to the Method. +- **Methods**: Define how log items should be recorded, such as to a file, terminal, or event viewer. Each logging method operates in its own runspace, providing isolation and efficiency. The exception to this is the Custom method, which by default runs in the same runspace as the log dispatcher unless the `-UseRunspace` parameter is specified. -For example when you supply an Exception to [`Write-PodeErrorLog`](../../../Functions/Logging/Write-PodeErrorLog), this Exception is first supplied to Pode's inbuilt Error logging type. This type transforms any Exception (or Error Record) into a string which can then be supplied to the File logging method. +- **Types**: Define how log items are transformed and what data should be supplied to the Method. -In Pode you can use File, Terminal, Event Viewer, or a Custom method. As well as Request, Error, or a Custom type. +When you supply an Exception to [`Write-PodeErrorLog`](../../../Functions/Logging/Write-PodeErrorLog), the Exception is first processed by Pode's built-in Error logging type. This type transforms the Exception (or Error Record) into a string format, which can then be recorded by the logging method (e.g., File). + +Pode supports various logging methods, including File, Terminal, Event Viewer, Syslog, Restful, or Custom methods. Additionally, you can utilize different logging types such as Request, Error, or Custom types. + +This flexibility allows you to create a custom logging method that can output logs to various platforms, such as an S3 bucket, Splunk, or any other logging service. -This means you could write a logging method to output to an S3 bucket, Splunk, or any other logging platform. ## Masking Values @@ -109,3 +111,50 @@ Instead of writing logs one-by-one, the above will keep transformed log items in This means that the method's scriptblock will receive an array of items, rather than a single item. You can also sent a `-BatchTimeout` value, in seconds, so that if your batch size it 10 but only 5 log items are added, then after the timeout value the logs items will be sent to your method. + + + +## Configuring Failure Actions for Log Writing + +Defines the behavior in case of failure to write a log. This can happen if the disk is full, the Syslog server is offline, or if the number of logs in the queue reaches the maximum allowed. The options are: +- **Ignore** : Does nothing and continues execution. **(Default)** +- **Report** : Writes a message to the console for any failure. +- **Halt** : Writes a message to the console and shuts down the Pode server. + +```powershell +New-PodeLoggingMethod -File -Path './logs' -Name 'errors' -FailureAction 'Report' | Enable-PodeRequestLogging +``` + +## QueueLimit +Defines the maximum number of logs allowed in the queue before throwing an event. +The default value is 500. The exception is handled based on the `-FailureAction` parameter. + +```powershell +@{ + Server = @{ + Logging = @{ + QueueLimit = 1000 + } + } +} +``` + +## DataFormat +The date format to use for the log entries. The default format is `'dd/MMM/yyyy:HH:mm:ss zzz'`. + +```powershell +New-PodeLoggingMethod -File -Path './logs' -Name 'access' -DataFormat 'yyyy-MM-dd HH:mm:ss' | Enable-PodeErrorLogging +``` + +## ISO8601 +If set, the date format will be ISO 8601 compliant (equivalent to `-DataFormat 'yyyy-MM-ddTHH:mm:ssK'`). This parameter is mutually exclusive with DataFormat. + +```powershell +New-PodeLoggingMethod -File -Path './logs' -Name 'access' -ISO8601 | Enable-PodeErrorLogging +``` + +## AsUTC +If set, the time will be logged in UTC instead of local time. + +```powershell +New-PodeLoggingMethod -File -Path './logs' -Name 'access' -AsUTC -ISO8601 | Enable-PodeErrorLogging \ No newline at end of file diff --git a/docs/Tutorials/Logging/Types/General.md b/docs/Tutorials/Logging/Types/General.md new file mode 100644 index 000000000..1fc2daee2 --- /dev/null +++ b/docs/Tutorials/Logging/Types/General.md @@ -0,0 +1,70 @@ + +# General Logging + +Pode supports general logging, allowing you to define custom logging methods and log levels. This feature enables you to write logs based on specified methods, ensuring flexibility and control over logging outputs. + +To enable general logging, use the `Add-PodeLoggingMethod` function. This function takes a hashtable defining the logging method, including a ScriptBlock for log output. You can specify various log levels to be enabled, such as Error, Emergency, Alert, Critical, Warning, Notice, Informational, Info, Verbose, and Debug. + +## Enabling General Logging + +To enable general logging, use the `Add-PodeLoggingMethod` function, supplying the necessary parameters: + +- `Method`: The hashtable defining the logging method, including the ScriptBlock for log output. +- `Levels`: An array of log levels to be enabled for the logging method (default includes Error, Emergency, Alert, Critical, Warning, Notice, Informational, Info, Verbose, Debug). +- `Name`: The name of the logging method to be enabled. +- `Raw`: If set, the raw log data will be included in the logging output. + +### Example + +```powershell +$method = New-PodeLoggingMethod -syslog -Server 127.0.0.1 -Transport UDP +$method | Add-PodeLoggingMethod -Name "mysyslog" +``` + +## Disabling General Logging + +To disable a general logging method, use the `Remove-PodeLoggingMethod` function with the `Name` parameter: + +### Example + +```powershell +Remove-PodeLoggingMethod -Name 'mysyslog' +``` + +With these functions, Pode ensures robust and customizable logging capabilities, allowing you to manage logs effectively based on your specific requirements. + +## Writing to General Logs + +Pode allows you to write logs to configured custom or inbuilt logging methods using the `Write-PodeLog` function. This function supports both custom and inbuilt logging methods, enabling structured logging with various log levels and messages. + +### Writing to General Logs + +To write logs, you can use the `Write-PodeLog` function with different parameters to specify the logging method, log level, message, and other details. + +#### Example Usage + +##### Logging an Object + +To write an object to a configured logging method: + +```powershell +$logItem = @{ + Date = [datetime]::Now + Level = 'Informational' + Server = 'MyServer' + Category = 'General' + Message = 'This is a log message' + StackTrace = '' +} +$logItem | Write-PodeLog -Name 'mysyslog' +``` + +##### Logging with Custom Levels and Messages + +To log a custom message with a specific log level: + +```powershell +Write-PodeLog -Name 'mysyslog' -Level 'Error' -Message 'An error occurred.' -Tag 'MyApp' +``` + +In these examples, `Write-PodeLog` is used to write structured log items or custom messages to the specified logging methods, helping you maintain organized and detailed logs. \ No newline at end of file diff --git a/docs/Tutorials/Logging/Types/Requests.md b/docs/Tutorials/Logging/Types/Requests.md index 60a812d62..96ba2d805 100644 --- a/docs/Tutorials/Logging/Types/Requests.md +++ b/docs/Tutorials/Logging/Types/Requests.md @@ -6,7 +6,10 @@ Pode has inbuilt Request logging logic, that will parse and return a valid log i To enable and use the Request logging you use the [`Enable-PodeRequestLogging`](../../../../Functions/Logging/Enable-PodeRequestLogging) function, supplying a logging method from [`New-PodeLoggingMethod`](../../../../Functions/Logging/New-PodeLoggingMethod). -The Request type logic will format a string using [Combined Log Format](https://httpd.apache.org/docs/1.3/logs.html#combined). This string is then supplied to the logging method's scriptblock. If you're using a Custom logging method and want the raw hashtable instead, you can supply `-Raw` to [`Enable-PodeRequestLogging`](../../../../Functions/Logging/Enable-PodeRequestLogging). +The Request type logic will format a string using [Combined Log Format](https://httpd.apache.org/docs/1.3/logs.html#combined). +This string is then supplied to the logging method's scriptblock. You can customize the log format using the `-LogFormat` parameter with options like `Extended`, `Common`, `Combined`, and `JSON`. + +If you're using a Custom logging method and want the raw hashtable instead, you can supply `-Raw` to [`Enable-PodeRequestLogging`](../../../../Functions/Logging/Enable-PodeRequestLogging). ## Examples @@ -18,6 +21,23 @@ The following example simply enables Request logging, and will output all items New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging ``` +### Log Format + +#### Extended Log Format +The following example enables Request logging using the Extended Log Format: + +```powershell +New-PodeLoggingMethod -File -Path './logs' -Name 'requests' | Enable-PodeRequestLogging -LogFormat 'Extended' +``` + +#### JSON Format +The following example enables Request logging using JSON Format: + +```powershell +New-PodeLoggingMethod -File -Path './logs' -Name 'requests' | Enable-PodeRequestLogging -LogFormat 'Json' +``` + + ### Using Raw Item The following example uses a Custom logging method, and sets Request logging to return and supply the raw hashtable to the Custom method's scriptblock. The Custom method simply logs the Host an StatusCode to the terminal (but could be to something like an S3 bucket): @@ -70,4 +90,4 @@ The raw Request hashtable that will be supplied to any Custom logging methods wi Size = '9001' } } -``` +``` \ No newline at end of file diff --git a/examples/Logging.ps1 b/examples/Logging.ps1 index 2625991bf..979c16ce9 100644 --- a/examples/Logging.ps1 +++ b/examples/Logging.ps1 @@ -22,11 +22,20 @@ License: MIT License #> + +param( + [ValidateSet('Terminal', 'File', 'mylog', 'Syslog', 'EventViewer', 'Custom')] + [string[]] + $LoggingType = @( 'file', 'Custom', 'Syslog'), + + [switch] + $Raw +) + try { - # Determine the script path and Pode module path + #Determine the script path and Pode module path $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) $podePath = Split-Path -Parent -Path $ScriptPath - # Import the Pode module from the source path if it exists, otherwise from installed modules if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop @@ -36,44 +45,86 @@ try { } } catch { throw } - # or just: # Import-Module Pode -$LOGGING_TYPE = 'terminal' # Terminal, File, Custom - # create a server, and start listening on port 8081 -Start-PodeServer { +Start-PodeServer -browse { Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http Set-PodeViewEngine -Type Pode + $logging = @() - switch ($LOGGING_TYPE.ToLowerInvariant()) { - 'terminal' { - New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging + if ( $LoggingType -icontains 'terminal') { + $logging += New-PodeLoggingMethod -Terminal + } + + if ( $LoggingType -icontains 'file') { + $logging += New-PodeFileLoggingMethod -Name 'file' -MaxDays 4 -Format Simple -ISO8601 + $requestLogging = New-PodeLoggingMethod -File -Name 'requests' -MaxDays 4 + } + + if ( $LoggingType -icontains 'custom') { + $logging += New-PodeLoggingMethod -Custom -ArgumentList 'arg1', 'arg2', 'arg3' -ScriptBlock { + param($item, $arg1 , $arg2, $arg3, $rawItem) + $item | Out-File './examples/logs/customLegacy.log' -Append + $arg1 , $arg2, $arg3 -join ',' | Out-File './examples/logs/customLegacy_argumentList.log' -Append + $rawItem | Out-File './examples/logs/customLegacy_rawItem.log' -Append } - 'file' { - New-PodeLoggingMethod -File -Name 'requests' -MaxDays 4 | Enable-PodeRequestLogging + $logging += New-PodeCustomLoggingMethod -CustomOptions @{ 'opt1' = 'something'; 'opt2' = 'else' } -ScriptBlock { + $item | Out-File './examples/logs/customWithRunspace.log' -Append + $options | Out-File './examples/logs/customWithRunspace_options.log' -Append + $rawItem | Out-File './examples/logs/customWithRunspace_rawItem.log' -Append } + } - 'custom' { - $type = New-PodeLoggingMethod -Custom -ScriptBlock { - param($item) - # send request row to S3 - } + if ( $LoggingType -icontains 'eventviewer') { + $logging += New-PodeLoggingMethod -EventViewer + } - $type | Enable-PodeRequestLogging - } + if ( $LoggingType -icontains 'syslog') { + $logging += New-PodeSyslogLoggingMethod -Server 127.0.0.1 -Transport UDP -AsUTC -ISO8601 -FailureAction Report + } + + if ($logging.Count -eq 0) { + throw 'No logging selected' } + if ( $requestLogging) { + $requestLogging | Enable-PodeRequestLogging -LogFormat Extended + } + + New-PodeFileLoggingMethod -Name 'error' -MaxDays 4 -Format RFC5424 -ISO8601 | Enable-PodeErrorLogging -Raw -Levels Error + @( + (New-PodeFileLoggingMethod -Name 'default' -MaxDays 4 -Format Simple -ISO8601 -DefaultTag 'filetest') + (New-PodeFileLoggingMethod -Name 'defaultRFC5424' -MaxDays 4 -Format RFC5424 -ISO8601 -DefaultTag 'filetestRFC5424') + (New-PodeSyslogLoggingMethod -Server 127.0.0.1 -Transport UDP -AsUTC -ISO8601 -SyslogProtocol RFC3164 -FailureAction Report -DefaultTag 'test') + ) | Enable-PodeDefaultLogging -Raw + $logging | Add-PodeLoggingMethod -Name 'mylog' -Raw:$Raw + Write-PodeLog -Name 'mylog' -Message 'just started' -Level 'Info' # GET request for web page on "localhost:8081/" Add-PodeRoute -Method Get -Path '/' -ScriptBlock { + Write-PodeLog -Name 'mylog' -Message 'My custom log' -Level 'Info' + Write-PodeLog -Message 'This is for the deafult log.' + Start-Sleep -Seconds 2 + Write-PodeLog -Message 'An allert with a new tag.' -Tag 'newTag' -Level Alert Write-PodeViewResponse -Path 'simple' -Data @{ 'numbers' = @(1, 2, 3); } } # GET request throws fake "500" server error status code Add-PodeRoute -Method Get -Path '/error' -ScriptBlock { + Disable-PodeRequestLogging + Set-PodeResponseStatus -Code 500 + } + + Add-PodeRoute -Method Get -Path '/exception' -ScriptBlock { + try { + throw 'something happened' + } + catch { + $_ | Write-PodeErrorLog + } Set-PodeResponseStatus -Code 500 } @@ -82,4 +133,4 @@ Start-PodeServer { Set-PodeResponseAttachment -Path 'Anger.jpg' } -} +} \ No newline at end of file diff --git a/examples/Session-Data.ps1 b/examples/Session-Data.ps1 new file mode 100644 index 000000000..b2ce0bd3a --- /dev/null +++ b/examples/Session-Data.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS + Demonstrates session management using Pode with basic authentication. + +.DESCRIPTION + This script sets up a Pode web server with a basic authentication endpoint. It tracks user sessions and + increments a session counter each time the endpoint is accessed. + +.EXAMPLE + To run the sample: ./SessionData.ps1 + $result=Invoke-WebRequest -Uri "http://localhost:8081/auth/basic" -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cGlja2xl' } + $session = ($result.Headers['pode.sid'] | Select-Object -First 1) + + $result = Invoke-WebRequest -Uri "$($Endpoint)/auth/basic" -Method Post -Headers @{ 'pode.sid' = $session } + $content = ($result.Content | ConvertFrom-Json) + $content.Result #should be 2 + + $result = Invoke-WebRequest -Uri "$($Endpoint)/auth/basic" -Method Post -Headers @{ 'pode.sid' = $session } + $content = ($result.Content | ConvertFrom-Json) + $content.Result #should be 3 and so on... + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/SessionData.ps1 + +.NOTES + Author: Pode Team + License: MIT License +#> + +try { + # Determine the script directory path + $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Check if Pode is available from source; otherwise, load it from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } # Stop execution if Pode module fails to load + +# Start the Pode web server +Start-PodeServer -ScriptBlock { + + # Define an HTTP endpoint on localhost:8081 + Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http + + # Add a route to gracefully stop the server + Add-PodeRoute -Method Get -Path '/close' -ScriptBlock { + Close-PodeServer + } + + # Enable session middleware with secret-based authentication and session persistence + Enable-PodeSessionMiddleware -Secret 'schwifty' -Duration 5 -Extend -UseHeaders + + # Define a basic authentication scheme + New-PodeAuthScheme -Basic | Add-PodeAuth -Name 'Auth' -ScriptBlock { + param($username, $password) + + # Authenticate user based on predefined credentials + if (($username -eq 'morty') -and ($password -eq 'pickle')) { + return @{ User = @{ ID = 'M0R7Y302' } } # Return user ID if authentication is successful + } + + return @{ Message = 'Invalid details supplied' } # Return error message for failed authentication + } + + # Define a route that requires authentication and maintains session state + Add-PodeRoute -Method Post -Path '/auth/basic' -Authentication Auth -ScriptBlock { + + # Increment session view count for the authenticated user + $WebEvent.Session.Data.Views++ + + # Return JSON response with session details + Write-PodeJsonResponse -Value @{ + Result = 'OK' + Username = $WebEvent.Auth.User.ID + Views = $WebEvent.Session.Data.Views + } + } +} diff --git a/examples/server.psd1 b/examples/server.psd1 index ec5b15edf..86a66de67 100644 --- a/examples/server.psd1 +++ b/examples/server.psd1 @@ -40,6 +40,7 @@ '(?AppleWebKit\/)\d+\.\d+(?( @@ -335,7 +342,7 @@ public void StartReceive() /// Thrown if the request cannot be upgraded to a WebSocket. public async Task UpgradeWebSocket(string clientId = null) { - PodeHelpers.WriteErrorMessage($"Upgrading Websocket", Listener, PodeLoggingLevel.Verbose, this); + PodeLogger.LogMessage($"Upgrading Websocket", Listener, PodeLoggingLevel.Verbose, this); if (!IsWebSocket) { @@ -377,7 +384,7 @@ public async Task UpgradeWebSocket(string clientId = null) var signal = new PodeSignal(this, HttpRequest.Url.AbsolutePath, clientId); Request = new PodeSignalRequest(HttpRequest, signal); Listener.AddSignal(SignalRequest.Signal); - PodeHelpers.WriteErrorMessage($"Websocket upgraded", Listener, PodeLoggingLevel.Verbose, this); + PodeLogger.LogMessage($"Websocket upgraded", Listener, PodeLoggingLevel.Verbose, this); } /// @@ -397,7 +404,7 @@ public void Dispose(bool force) { lock (_lockable) { - PodeHelpers.WriteErrorMessage($"Disposing Context", Listener, PodeLoggingLevel.Verbose, this); + PodeLogger.LogMessage($"Disposing Context", Listener, PodeLoggingLevel.Verbose, this); Listener.RemoveProcessingContext(this); if (IsClosed) @@ -467,14 +474,14 @@ public void Dispose(bool force) } catch (Exception ex) { - PodeHelpers.WriteException(ex, Listener, PodeLoggingLevel.Error); + PodeLogger.LogException(ex, Listener, PodeLoggingLevel.Error); } finally { // Handle re-receiving or socket clean-up. if ((_awaitingBody || (IsKeepAlive && !IsErrored && !IsTimeout && !Response.SseEnabled)) && !force) { - PodeHelpers.WriteErrorMessage($"Re-receiving Request", Listener, PodeLoggingLevel.Verbose, this); + PodeLogger.LogMessage($"Re-receiving Request", Listener, PodeLoggingLevel.Verbose, this); StartReceive(); } else diff --git a/src/Listener/PodeFileWatcher.cs b/src/Listener/PodeFileWatcher.cs index 941b78be6..932e143c5 100644 --- a/src/Listener/PodeFileWatcher.cs +++ b/src/Listener/PodeFileWatcher.cs @@ -87,7 +87,7 @@ private void FileEventHandler(object _, FileSystemEventArgs e) private void FileErrorEventHandler(object _, FileWatcherErrorEventArgs e) { - PodeHelpers.WriteException(e.Error, Watcher); + PodeLogger.LogException(e.Error, Watcher); } } } \ No newline at end of file diff --git a/src/Listener/PodeFormat.cs b/src/Listener/PodeFormat.cs new file mode 100644 index 000000000..f363d880b --- /dev/null +++ b/src/Listener/PodeFormat.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections; +using System.Text; +using System.Text.RegularExpressions; + +namespace Pode +{ + public static class PodeFormat + { + /// + /// Sanitizes input by returning a default value if the input is null or whitespace. + /// + /// The object value to be sanitized. + /// A sanitized string, or "-" if the input is null or whitespace. + private static string Sanitize(object value) + { + return value == null || string.IsNullOrWhiteSpace(value.ToString()) ? "-" : value.ToString(); + } + + /// + /// Formats error log entries based on the provided options, including Date, Level, ThreadId, Server, Category, Message, and StackTrace. + /// + /// A hashtable containing log details. + /// A hashtable containing format options such as Levels, Raw, and DataFormat. + /// A formatted log string, or the original item if Raw is specified, or null if Level is not in options.Levels. + public static object ErrorsLog(Hashtable item, Hashtable options) + { + if (item == null || options == null) return null; + + // Check for required keys in the log item + if (!item.ContainsKey("Level") || !item.ContainsKey("Date") || !item.ContainsKey("ThreadId") || + !item.ContainsKey("Server") || !item.ContainsKey("Category") || !item.ContainsKey("Message") || !item.ContainsKey("StackTrace")) + { + return null; + } + + // Ensure the log level is present in the allowed levels + if (options.ContainsKey("Levels") && !((IList)options["Levels"]).Contains(item["Level"])) + { + return null; + } + + // Return raw item if Raw option is set + if (options.ContainsKey("Raw") && (bool)options["Raw"]) + { + return item; + } + + // Set the date format or use a default + string dataFormat = options.ContainsKey("DataFormat") ? options["DataFormat"].ToString() : "yyyy-MM-dd HH:mm:ss"; + + // Build the log entry string + StringBuilder sb = new StringBuilder(); + return sb.Append("Date: ").Append(((DateTime)item["Date"]).ToString(dataFormat)).Append(" Level: ").Append(Sanitize(item["Level"])) + .Append(" ThreadId: ").Append(Sanitize(item["ThreadId"])).Append(" Server: ").Append(Sanitize(item["Server"])).Append(" Category: ") + .Append(Sanitize(item["Category"])).Append(" Message: ").Append(Sanitize(item["Message"])).Append(" StackTrace: ") + .Append(Sanitize(item["StackTrace"])).ToString(); + } + + /// + /// Formats request log entries based on the specified format in options, supporting formats like "extended", "common", "json", and "combined". + /// + /// A hashtable containing request log details. + /// A hashtable containing format options such as LogFormat and Raw. + /// A formatted request log string, or the original item if Raw is specified. + public static object RequestLog(Hashtable item, Hashtable options) + { + if (item == null || options == null) return null; + + // Return raw item if Raw option is set + if (options.ContainsKey("Raw") && (bool)options["Raw"]) + { + return item; + } + + // Retrieve the log format, defaulting to "combined" + string logFormat = options.ContainsKey("LogFormat") ? options["LogFormat"].ToString().ToLowerInvariant() : "combined"; + + StringBuilder sb = new StringBuilder(); + + switch (logFormat) + { + case "extended": + if (item.ContainsKey("Host") && item.ContainsKey("User") && item.ContainsKey("Request") && item.ContainsKey("Response") && + item["Request"] is Hashtable requestExtended && item["Response"] is Hashtable responseExtended) + { + return sb.Append(((DateTime)item["Date"]).ToString("yyyy-MM-dd")).Append(' ').Append(((DateTime)item["Date"]).ToString("HH:mm:ss")).Append(' ') + .Append(Sanitize(item["Host"])).Append(' ').Append(Sanitize(item["RfcUserIdentity"])).Append(' ') + .Append(Sanitize(item["User"])).Append(' ').Append(Sanitize(requestExtended["Method"])).Append(' ') + .Append(Sanitize(requestExtended["Resource"])).Append(' ').Append("- ").Append(Sanitize(responseExtended["StatusCode"])).Append(' ') + .Append(Sanitize(responseExtended["Size"])).Append(' ').Append("\"").Append(Sanitize(requestExtended["Agent"])).Append("\"") + .ToString(); + } + break; + + case "common": + if (item.ContainsKey("Host") && item.ContainsKey("RfcUserIdentity") && item.ContainsKey("User") && item.ContainsKey("Request") && item.ContainsKey("Response") && + item["Request"] is Hashtable requestCommon && item["Response"] is Hashtable responseCommon) + { + return sb.Append(Sanitize(item["Host"])).Append(' ').Append(Sanitize(item["RfcUserIdentity"])).Append(' ').Append(Sanitize(item["User"])).Append(" [") + .Append(Regex.Replace(((DateTime)item["Date"]).ToString("dd/MMM/yyyy:HH:mm:ss zzz"), @"([+-]\d{2}):(\d{2})", "$1$2")).Append("] \"") + .Append(Sanitize(requestCommon["Method"])).Append(' ').Append(Sanitize(requestCommon["Resource"])).Append(' ') + .Append(Sanitize(requestCommon["Protocol"])).Append("\" ").Append(Sanitize(responseCommon["StatusCode"])) + .Append(' ').Append(Sanitize(responseCommon["Size"])).ToString(); + } + break; + + case "json": + if (item.ContainsKey("Host") && item.ContainsKey("User") && item.ContainsKey("Request") && item.ContainsKey("Response") && + item["Request"] is Hashtable requestJson && item["Response"] is Hashtable responseJson) + { + return sb.Append("{\"time\": \"").Append(((DateTime)item["Date"]).ToString("yyyy-MM-ddTHH:mm:ssK")).Append("\",\"remote_ip\": \"") + .Append(Sanitize(item["Host"])).Append("\",\"user\": \"").Append(Sanitize(item["User"])).Append("\",\"method\": \"") + .Append(Sanitize(requestJson["Method"])).Append("\",\"uri\": \"").Append(Sanitize(requestJson["Resource"])) + .Append("\",\"query\": \"").Append(Sanitize(requestJson["Query"])).Append("\",\"status\": ") + .Append(Sanitize(responseJson["StatusCode"])).Append(",\"response_size\": ") + .Append(Sanitize(responseJson["Size"])).Append(",\"user_agent\": \"").Append(Sanitize(requestJson["Agent"])) + .Append("\"}").ToString(); + } + break; + + default: + if (item.ContainsKey("Host") && item.ContainsKey("RfcUserIdentity") && item.ContainsKey("User") && item.ContainsKey("Request") && item.ContainsKey("Response") && + item["Request"] is Hashtable requestCombined && item["Response"] is Hashtable responseCombined) + { + return sb.Append(Sanitize(item["Host"])).Append(' ').Append(Sanitize(item["RfcUserIdentity"])).Append(' ').Append(Sanitize(item["User"])).Append(" [") + .Append(Regex.Replace(((DateTime)item["Date"]).ToString("dd/MMM/yyyy:HH:mm:ss zzz"), @"([+-]\d{2}):(\d{2})", "$1$2")).Append("] \"") + .Append(Sanitize(requestCombined["Method"])).Append(' ').Append(Sanitize(requestCombined["Resource"])).Append(' ') + .Append(Sanitize(requestCombined["Protocol"])).Append("\" ").Append(Sanitize(responseCombined["StatusCode"])) + .Append(' ').Append(Sanitize(responseCombined["Size"])).Append(" \"") + .Append(Sanitize(requestCombined["Referrer"])).Append("\" \"").Append(Sanitize(requestCombined["Agent"])).Append("\"") + .ToString(); + } + break; + } + return null; + } + + /// + /// Formats general log entries by checking for level filtering and required fields. + /// + /// A hashtable containing general log details. + /// A hashtable containing format options such as Levels, Raw, and DataFormat. + /// A formatted general log string, or the original item if Raw is specified, or null if Level is not in options.Levels. + public static object GeneralLog(Hashtable item, Hashtable options) + { + if (item == null || options == null) return null; + + // Ensure the log level is present in the allowed levels + if (options.ContainsKey("Levels") && !((IList)options["Levels"]).Contains(item["Level"])) + { + return null; + } + + // Return raw item if Raw option is set + if (options.ContainsKey("Raw") && (bool)options["Raw"]) + { + return item; + } + + // Set the date format or use a default + string dataFormat = options.ContainsKey("DataFormat") ? options["DataFormat"].ToString() : "yyyy-MM-dd HH:mm:ss"; + + // Build the log entry string + StringBuilder sb = new StringBuilder(); + return sb.Append('[').Append(((DateTime)item["Date"]).ToString(dataFormat)).Append("] ") + .Append(Sanitize(item["Level"])).Append(' ').Append(Sanitize(item["Tag"])).Append(' ').Append(Sanitize(item["ThreadId"])).Append(' ').Append(Sanitize(item["Message"])) + .ToString(); + } + + /// + /// Formats a syslog message from raw data and applies masking where necessary. + /// + /// A hashtable representing raw log data. + /// A hashtable containing options for message formatting, such as Format, DataFormat, and MaxLength. + /// A hashtable with masking patterns to obfuscate sensitive information in the message. + /// A formatted syslog message string. + public static string Syslog(Hashtable rawItem, Hashtable options, Hashtable masking) + { + int maxLength = -1; + string message = string.Empty; + + // Process message and stack trace, applying masking if available + if (rawItem.ContainsKey("Message")) + { + if (rawItem.ContainsKey("StackTrace") && !string.IsNullOrEmpty(rawItem["StackTrace"] as string)) + { + message = $"{rawItem["Level"].ToString().ToUpperInvariant()}: {PodeLogger.ProtectLogItem(rawItem["Message"].ToString(), masking)}. Exception Type: {rawItem["Category"]}. Stack Trace: {rawItem["StackTrace"]}"; + } + else + { + message = PodeLogger.ProtectLogItem(rawItem["Message"].ToString(), masking); + } + } + + // Map log level to syslog severity + int severity; + string level = rawItem["Level"].ToString().ToLowerInvariant(); + switch (level) + { + case "emergency": severity = 0; break; + case "alert": severity = 1; break; + case "critical": severity = 2; break; + case "error": severity = 3; break; + case "warning": severity = 4; break; + case "notice": severity = 5; break; + case "info": + case "informational": severity = 6; break; + case "debug": severity = 7; break; + default: severity = 6; break; + } + + // Set tag and priority + string tag = string.IsNullOrEmpty(rawItem["Tag"] as string) + ? (options != null && options.ContainsKey("DefaultTag") ? options["DefaultTag"].ToString() : "DefaultTag") + : rawItem["Tag"].ToString(); + + int facility = 1; // User-level messages + int priority = (facility * 8) + severity; + int processId = System.Diagnostics.Process.GetCurrentProcess().Id; + string fullSyslogMessage; + string timestamp; + + // Determine syslog format and format accordingly + switch (options["Format"].ToString().ToUpper()) + { + case "RFC3164": + maxLength = 1024; + timestamp = ((DateTime)rawItem["Date"]).ToString("MMM dd HH:mm:ss"); + fullSyslogMessage = string.Format("<{0}>{1} {2} {3}: {4}", priority, timestamp, Environment.MachineName, tag, message); + break; + + case "RFC5424": + maxLength = 2048; + timestamp = ((DateTime)rawItem["Date"]).ToString("yyyy-MM-ddTHH:mm:ss.ffffffK"); + fullSyslogMessage = string.Format("<{0}>1 {1} {2} {3} {4} - - {5}", priority, timestamp, Environment.MachineName, tag, processId, message); + break; + + default: + maxLength = options != null && options.ContainsKey("MaxLength") ? Convert.ToInt32(options["MaxLength"]) : -1; + string dataFormat = options != null && options.ContainsKey("DataFormat") ? options["DataFormat"].ToString() : null; + string separator = options != null && options.ContainsKey("Separator") ? options["Separator"].ToString() : " "; + timestamp = !string.IsNullOrEmpty(dataFormat) ? ((DateTime)rawItem["Date"]).ToString(dataFormat) : string.Empty; + fullSyslogMessage = string.Format("{0}{1}{2}{3}{4}{5}{6}", timestamp, separator, rawItem["Level"], separator, tag, separator, message); + break; + } + + // Truncate message if it exceeds maxLength + if (maxLength > 0 && fullSyslogMessage.Length > maxLength) + { + return fullSyslogMessage.Substring(0, maxLength); + } + + return fullSyslogMessage; + } + } +} diff --git a/src/Listener/PodeHelpers.cs b/src/Listener/PodeHelpers.cs index 657f57668..404170848 100644 --- a/src/Listener/PodeHelpers.cs +++ b/src/Listener/PodeHelpers.cs @@ -5,14 +5,16 @@ using System.Security.Cryptography; using System.Reflection; using System.Runtime.Versioning; -using System.Threading.Tasks; +using System.Collections; +using System.Diagnostics.CodeAnalysis; using System.Threading; +using System.Threading.Tasks; using System.Text; using System.IO.Compression; namespace Pode { - public class PodeHelpers + public static class PodeHelpers { public static readonly string[] HTTP_METHODS = new string[] { "CONNECT", "DELETE", "GET", "HEAD", "MERGE", "OPTIONS", "PATCH", "POST", "PUT", "TRACE" }; public const string WEB_SOCKET_MAGIC_KEY = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; @@ -42,29 +44,7 @@ public static bool IsNetFramework } } - public static void WriteException(Exception ex, PodeConnector connector = default, PodeLoggingLevel level = PodeLoggingLevel.Error) - { - if (ex == default(Exception)) - { - return; - } - - // return if logging disabled, or if level isn't being logged - if (connector != default(PodeConnector) && (!connector.ErrorLoggingEnabled || !connector.ErrorLoggingLevels.Contains(level.ToString(), StringComparer.InvariantCultureIgnoreCase))) - { - return; - } - - // write the exception to terminal - Console.WriteLine($"[{level}] {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine(ex.StackTrace); - if (ex.InnerException != null) - { - Console.WriteLine($"[{level}] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); - Console.WriteLine(ex.InnerException.StackTrace); - } - } public static void HandleAggregateException(AggregateException aex, PodeConnector connector = default, PodeLoggingLevel level = PodeLoggingLevel.Error, bool handled = false) { @@ -77,7 +57,7 @@ public static void HandleAggregateException(AggregateException aex, PodeConnecto return true; } - WriteException(ex, connector, level); + PodeLogger.LogException(ex, connector, level); return false; }); } @@ -90,29 +70,7 @@ public static void HandleAggregateException(AggregateException aex, PodeConnecto } } - public static void WriteErrorMessage(string message, PodeConnector connector = default, PodeLoggingLevel level = PodeLoggingLevel.Error, PodeContext context = default) - { - // do nothing if no message - if (string.IsNullOrWhiteSpace(message)) - { - return; - } - - // return if logging disabled, or if level isn't being logged - if (connector != default(PodeConnector) && (!connector.ErrorLoggingEnabled || !connector.ErrorLoggingLevels.Contains(level.ToString(), StringComparer.InvariantCultureIgnoreCase))) - { - return; - } - if (context == default(PodeContext)) - { - Console.WriteLine($"[{level}]: {message}"); - } - else - { - Console.WriteLine($"[{level}]: [ContextId: {context.ID}] {message}"); - } - } public static string NewGuid(int length = 16) { diff --git a/src/Listener/PodeListener.cs b/src/Listener/PodeListener.cs index 46bea89b3..ab26a7354 100644 --- a/src/Listener/PodeListener.cs +++ b/src/Listener/PodeListener.cs @@ -265,37 +265,37 @@ public override void Start() protected override void Close() { // shutdown the sockets - PodeHelpers.WriteErrorMessage($"Closing sockets", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing sockets", this, PodeLoggingLevel.Verbose); for (var i = Sockets.Count - 1; i >= 0; i--) { Sockets[i].Dispose(); } Sockets.Clear(); - PodeHelpers.WriteErrorMessage($"Closed sockets", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed sockets", this, PodeLoggingLevel.Verbose); // close existing contexts - PodeHelpers.WriteErrorMessage($"Closing contexts", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing contexts", this, PodeLoggingLevel.Verbose); foreach (var _context in Contexts.ToArray()) { _context.Dispose(true); } Contexts.Clear(); - PodeHelpers.WriteErrorMessage($"Closed contexts", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed contexts", this, PodeLoggingLevel.Verbose); // close connected signals - PodeHelpers.WriteErrorMessage($"Closing signals", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing signals", this, PodeLoggingLevel.Verbose); foreach (var _signal in Signals.Values.ToArray()) { _signal.Dispose(); } Signals.Clear(); - PodeHelpers.WriteErrorMessage($"Closed signals", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed signals", this, PodeLoggingLevel.Verbose); // close connected server events - PodeHelpers.WriteErrorMessage($"Closing server events", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing server events", this, PodeLoggingLevel.Verbose); foreach (var _sseName in ServerEvents.Values.ToArray()) { foreach (var _sse in _sseName.Values.ToArray()) @@ -307,7 +307,7 @@ protected override void Close() } ServerEvents.Clear(); - PodeHelpers.WriteErrorMessage($"Closed server events", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed server events", this, PodeLoggingLevel.Verbose); } } } \ No newline at end of file diff --git a/src/Listener/PodeLogger.cs b/src/Listener/PodeLogger.cs new file mode 100644 index 000000000..c2aa96d34 --- /dev/null +++ b/src/Listener/PodeLogger.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Concurrent; +using System.Collections; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Pode +{ + public static class PodeLogger + { + /// + /// The name used for the request logger. + /// + public const string RequestLogName = "__pode_log_requests__"; + + /// + /// The name used for the default logger. + /// + public const string DefaultLogName = "__pode_log_Defaults__"; + + /// + /// The name used for the error logger. + /// + public const string ErrorLogName = "__pode_log_errors__"; + + /// + /// The name used for the error logger. + /// + public const string ListenerLogName = "__pode_log_Listener__"; + + + // Static fields to control logging and store log entries in a thread-safe queue + private static bool _enabled; + private static ConcurrentQueue _queue; + + /// + /// Enables or disables writing logs to the console. + /// + public static bool Terminal { get; set; } + + /// + /// Enables or disables logging. Initializes or clears the queue based on the value. + /// + public static bool Enabled + { + get => _enabled; + set + { + _enabled = value; + if (_enabled) + { + // Initializes the queue for logging + _queue = new ConcurrentQueue(); + } + else + { + // Clears the queue if logging is disabled + _queue = null; + } + } + } + + /// + /// Gets the count of items in the log queue. + /// + public static int Count => _queue != null ? _queue.Count : 0; + + /// + /// Adds a log entry to the queue. + /// + /// The log entry as a Hashtable. + public static void Enqueue(Hashtable table) + { + if (_queue != null) + { + _queue.Enqueue(table); + } + } + + /// + /// Attempts to dequeue a log entry from the queue. + /// + /// The dequeued log entry. + /// True if a log entry was dequeued, false otherwise. + public static bool TryDequeue(out Hashtable table) + { + if (_queue != null) + { + return _queue.TryDequeue(out table); + } + table = null; + return false; + } + + /// + /// Dequeues a log entry from the queue. Returns null if the queue is empty. + /// + /// The dequeued log entry as a Hashtable. + public static Hashtable Dequeue() + { + if (_queue != null && _queue.TryDequeue(out Hashtable table)) + { + return table; + } + return null; + } + + /// + /// Clears all entries from the log queue. + /// + public static void Clear() + { + if (_queue != null) + { + while (_queue.TryDequeue(out _)) { } + } + } + + /// + /// Logs an exception by adding it to the queue and optionally writing it to the console. + /// + /// The exception to log. + /// Optional PodeConnector to control logging based on settings. + /// The logging level (default is Error). + public static void LogException(Exception ex, PodeConnector connector = default(PodeConnector), PodeLoggingLevel level = PodeLoggingLevel.Error) + { + if (ex == null) + { + return; + } + + // Exit if logging is disabled or the logging level isn’t configured in the connector + if (connector != default(PodeConnector) && (!connector.ErrorLoggingEnabled || !connector.ErrorLoggingLevels.Contains(level.ToString(), StringComparer.InvariantCultureIgnoreCase))) + { + return; + } + + // If Terminal logging is enabled, output exception details to the console + if (Terminal) + { + Console.WriteLine($"[{level}] {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + + if (ex.InnerException != null) + { + Console.WriteLine($"[{level}] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + Console.WriteLine(ex.InnerException.StackTrace); + } + } + + // Add the exception to the log queue if logging is enabled + if (Enabled) + { + Hashtable logEntry = new Hashtable + { + ["Name"] = ListenerLogName, + ["Item"] = ex + }; + + Enqueue(logEntry); + } + } + + /// + /// Logs a message by adding it to the queue and optionally writing it to the console. + /// + /// The message to log. + /// Optional PodeConnector to control logging based on settings. + /// The logging level (default is Error). + /// Optional PodeContext to include context ID in the log entry. + public static void LogMessage(string message, PodeConnector connector = default(PodeConnector), PodeLoggingLevel level = PodeLoggingLevel.Error, PodeContext context = default(PodeContext)) + { + // Exit if message is empty or whitespace + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + // Exit if logging is disabled or the level isn’t configured in the connector + if (connector != default(PodeConnector) && (!connector.ErrorLoggingEnabled || !connector.ErrorLoggingLevels.Contains(level.ToString(), StringComparer.InvariantCultureIgnoreCase))) + { + return; + } + + // If Terminal logging is enabled, output message to the console, including context ID if provided + if (Terminal) + { + if (context == default(PodeContext)) + { + Console.WriteLine($"[{level}]: {message}"); + } + else + { + Console.WriteLine($"[{level}]: [ContextId: {context.ID}] {message}"); + } + } + + // Add the log message to the log queue if logging is enabled + if (Enabled) + { + Hashtable logEntry = new Hashtable + { + ["Name"] = ListenerLogName, + ["Item"] = new Hashtable + { + ["Message"] = message, + ["Level"] = level, + ["ThreadId"] = Environment.CurrentManagedThreadId + } + }; + + // Add the context ID to the log entry if a context is provided + if (context != null) + { + ((Hashtable)logEntry["Item"])["TargetObject"] = context.ID; + } + + Enqueue(logEntry); + } + } + + /// + /// Masks sensitive information in a log item based on specified regex patterns. + /// + /// The log item to mask. + /// A Hashtable containing masking patterns and a mask character. + /// The masked log item as a string. + public static string ProtectLogItem(string item, Hashtable masking) + { + // Exit if there are no masking patterns or if the mask value is null, empty, or missing + if (masking == null || masking.Count == 0 || !masking.ContainsKey("Mask") || string.IsNullOrEmpty(masking["Mask"]?.ToString())) + { + return item; + } + + // Retrieve the mask character and patterns from the masking hashtable + string mask = masking["Mask"].ToString(); + object[] patterns = (object[])masking["Patterns"]; + + // Apply each regex pattern to the log item + foreach (string regexPattern in patterns.Cast()) + { + Regex regex = new Regex(regexPattern, RegexOptions.IgnoreCase); + Match match = regex.Match(item); + + if (match.Success) + { + // Check for keep_before and keep_after groups in the match to retain surrounding text + if (match.Groups["keep_before"].Success && match.Groups["keep_after"].Success) + { + item = regex.Replace(item, $"{match.Groups["keep_before"].Value}{mask}{match.Groups["keep_after"].Value}"); + } + else if (match.Groups["keep_before"].Success) + { + item = regex.Replace(item, $"{match.Groups["keep_before"].Value}{mask}"); + } + else if (match.Groups["keep_after"].Success) + { + item = regex.Replace(item, $"{mask}{match.Groups["keep_after"].Value}"); + } + else + { + item = regex.Replace(item, mask); + } + } + } + + return item; + } + } +} diff --git a/src/Listener/PodeReceiver.cs b/src/Listener/PodeReceiver.cs index 21cff8f2c..95372e640 100644 --- a/src/Listener/PodeReceiver.cs +++ b/src/Listener/PodeReceiver.cs @@ -93,7 +93,7 @@ public void RemoveProcessingWebSocketRequest(PodeWebSocketRequest request) protected override void Close() { // disconnect websockets - PodeHelpers.WriteErrorMessage($"Closing client web sockets", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing client web sockets", this, PodeLoggingLevel.Verbose); foreach (var _webSocket in WebSockets.Values.ToArray()) { @@ -101,10 +101,10 @@ protected override void Close() } WebSockets.Clear(); - PodeHelpers.WriteErrorMessage($"Closed client web sockets", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed client web sockets", this, PodeLoggingLevel.Verbose); // close existing websocket requests - PodeHelpers.WriteErrorMessage($"Closing client web sockets requests", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing client web sockets requests", this, PodeLoggingLevel.Verbose); foreach (var _req in Requests.ToArray()) { @@ -112,7 +112,7 @@ protected override void Close() } Requests.Clear(); - PodeHelpers.WriteErrorMessage($"Closed client web requests", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed client web requests", this, PodeLoggingLevel.Verbose); } } } \ No newline at end of file diff --git a/src/Listener/PodeRequest.cs b/src/Listener/PodeRequest.cs index 021d7ea1a..53c2ea84e 100644 --- a/src/Listener/PodeRequest.cs +++ b/src/Listener/PodeRequest.cs @@ -138,7 +138,7 @@ public async Task Open(CancellationToken cancellationToken) } else { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Debug); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Debug); } State = PodeStreamState.Error; @@ -187,7 +187,7 @@ await ssl.AuthenticateAsServerAsync(Certificate, AllowClientCertificate, Protoco } catch (Exception ex) when (ex is OperationCanceledException || ex is IOException || ex is ObjectDisposedException) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Verbose); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Verbose); ssl?.Dispose(); State = PodeStreamState.Error; Error = new PodeRequestException(ex, 500); @@ -195,14 +195,14 @@ await ssl.AuthenticateAsServerAsync(Certificate, AllowClientCertificate, Protoco catch (AuthenticationException ex) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Debug); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Debug); ssl?.Dispose(); State = PodeStreamState.Error; Error = new PodeRequestException(ex, 400); } catch (Exception ex) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Error); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Error); ssl?.Dispose(); State = PodeStreamState.Error; Error = new PodeRequestException(ex, 502); @@ -268,7 +268,7 @@ public async Task Receive(CancellationToken cancellationToken) } catch (Exception ex) when (ex is IOException || ex is ObjectDisposedException) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Debug); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Debug); break; } if (read <= 0) @@ -303,20 +303,20 @@ public async Task Receive(CancellationToken cancellationToken) } catch (OperationCanceledException ex) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Verbose); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Verbose); } catch (IOException ex) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Verbose); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Verbose); } catch (PodeRequestException ex) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Error); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Error); Error = ex; } catch (Exception ex) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Error); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Error); Error = new PodeRequestException(ex, 500); } finally @@ -460,7 +460,7 @@ public virtual void PartialDispose() } catch (Exception ex) { - PodeHelpers.WriteException(ex, Context.Listener, PodeLoggingLevel.Error); + PodeLogger.LogException(ex, Context.Listener, PodeLoggingLevel.Error); } } @@ -490,6 +490,7 @@ protected virtual void Dispose(bool disposing) } PartialDispose(); + PodeLogger.LogMessage($"Request disposed", Context.Listener, PodeLoggingLevel.Verbose, Context); } } diff --git a/src/Listener/PodeResponse.cs b/src/Listener/PodeResponse.cs index 3bd9ed78d..0fa67670e 100644 --- a/src/Listener/PodeResponse.cs +++ b/src/Listener/PodeResponse.cs @@ -92,13 +92,13 @@ public async Task Send() return; } - PodeHelpers.WriteErrorMessage($"Sending response", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Sending response", Context.Listener, PodeLoggingLevel.Verbose, Context); try { await SendHeaders(Context.IsTimeout).ConfigureAwait(false); await SendBody(Context.IsTimeout).ConfigureAwait(false); - PodeHelpers.WriteErrorMessage($"Response sent", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Response sent", Context.Listener, PodeLoggingLevel.Verbose, Context); } catch (OperationCanceledException) { } catch (IOException) { } @@ -108,7 +108,7 @@ public async Task Send() } catch (Exception ex) { - PodeHelpers.WriteException(ex, Context.Listener); + PodeLogger.LogException(ex, Context.Listener); throw; } finally @@ -124,13 +124,13 @@ public async Task SendTimeout() return; } - PodeHelpers.WriteErrorMessage($"Sending response timed-out", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Sending response timed-out", Context.Listener, PodeLoggingLevel.Verbose, Context); StatusCode = 408; try { await SendHeaders(true).ConfigureAwait(false); - PodeHelpers.WriteErrorMessage($"Response timed-out sent", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Response timed-out sent", Context.Listener, PodeLoggingLevel.Verbose, Context); } catch (OperationCanceledException) { } catch (IOException) { } @@ -140,7 +140,7 @@ public async Task SendTimeout() } catch (Exception ex) { - PodeHelpers.WriteException(ex, Context.Listener); + PodeLogger.LogException(ex, Context.Listener); throw; } finally @@ -370,7 +370,7 @@ public async Task Write(byte[] buffer, bool flush = false) } catch (Exception ex) { - PodeHelpers.WriteException(ex, Context.Listener); + PodeLogger.LogException(ex, Context.Listener); throw; } } @@ -466,7 +466,7 @@ public void Dispose() OutputStream = default; } - PodeHelpers.WriteErrorMessage($"Response disposed", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Response disposed", Context.Listener, PodeLoggingLevel.Verbose, Context); } } } \ No newline at end of file diff --git a/src/Listener/PodeResponseHeaders.cs b/src/Listener/PodeResponseHeaders.cs index e619a5a86..f45eb259b 100644 --- a/src/Listener/PodeResponseHeaders.cs +++ b/src/Listener/PodeResponseHeaders.cs @@ -3,15 +3,29 @@ namespace Pode { + /// + /// Represents a collection of response headers that supports multiple values per header. + /// public class PodeResponseHeaders { + /// + /// Gets or sets the first value of the specified header. + /// + /// The name of the header. public object this[string name] { - get => (Headers.ContainsKey(name) ? Headers[name][0] : string.Empty); + get => Headers.TryGetValue(name, out IList value) ? value[0] : string.Empty; set => Set(name, value); } + /// + /// Gets the number of headers. + /// public int Count => Headers.Count; + + /// + /// Gets the collection of header names. + /// public ICollection Keys => Headers.Keys; private IDictionary> Headers; @@ -21,37 +35,54 @@ public PodeResponseHeaders() Headers = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); } + /// + /// Determines whether the collection contains the specified header. + /// public bool ContainsKey(string name) { return Headers.ContainsKey(name); } + /// + /// Gets the list of values associated with the specified header. + /// public IList Get(string name) { - return Headers.ContainsKey(name) ? Headers[name] : default(IList); + return Headers.TryGetValue(name, out IList value) ? value : default(IList); } + /// + /// Sets the specified header to the provided value, replacing any existing values. + /// public void Set(string name, object value) { - if (!Headers.ContainsKey(name)) + if (!Headers.TryGetValue(name, out var list)) { - Headers.Add(name, new List()); + list = new List(); + Headers[name] = list; } Headers[name].Clear(); Headers[name].Add(value); } + /// + /// Adds a value to the specified header, preserving any existing values. + /// public void Add(string name, object value) { - if (!Headers.ContainsKey(name)) + if (!Headers.TryGetValue(name, out var list)) { - Headers.Add(name, new List()); + list = new List(); + Headers[name] = list; } - Headers[name].Add(value); + list.Add(value); } + /// + /// Removes the specified header. + /// public void Remove(string name) { if (Headers.ContainsKey(name)) @@ -60,6 +91,9 @@ public void Remove(string name) } } + /// + /// Clears all headers. + /// public void Clear() { Headers.Clear(); diff --git a/src/Listener/PodeSignalRequest.cs b/src/Listener/PodeSignalRequest.cs index 13e2fa420..439a871f3 100644 --- a/src/Listener/PodeSignalRequest.cs +++ b/src/Listener/PodeSignalRequest.cs @@ -134,11 +134,11 @@ protected override async Task Parse(byte[] bytes, CancellationToken cancel protected override void Dispose(bool disposing) { if (IsDisposed) return; - + if (disposing) { // Send close frame - PodeHelpers.WriteErrorMessage($"Closing Websocket", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Closing Websocket", Context.Listener, PodeLoggingLevel.Verbose, Context); // Wait for the close frame to be sent Context.Response.WriteFrame(string.Empty, PodeWsOpCode.Close).Wait(); diff --git a/src/Listener/PodeSmtpRequest.cs b/src/Listener/PodeSmtpRequest.cs index 1c3bfca08..b4f458edc 100644 --- a/src/Listener/PodeSmtpRequest.cs +++ b/src/Listener/PodeSmtpRequest.cs @@ -265,7 +265,7 @@ protected override async Task Parse(byte[] bytes, CancellationToken cancel public void Reset() { - PodeHelpers.WriteErrorMessage($"Request reset", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Request reset", Context.Listener, PodeLoggingLevel.Verbose, Context); _canProcess = false; Headers = new Hashtable(StringComparer.InvariantCultureIgnoreCase); diff --git a/src/Listener/PodeSocket.cs b/src/Listener/PodeSocket.cs index 335166558..ea52bd24e 100644 --- a/src/Listener/PodeSocket.cs +++ b/src/Listener/PodeSocket.cs @@ -180,7 +180,7 @@ private async Task StartReceive(Socket acceptedSocket) // Create the context for the connection. var context = new PodeContext(acceptedSocket, this, Listener); - PodeHelpers.WriteErrorMessage($"Opening Receive", Listener, PodeLoggingLevel.Verbose, context); + PodeLogger.LogMessage($"Opening Receive", Listener, PodeLoggingLevel.Verbose, context); // Initialize the context. await context.Initialise().ConfigureAwait(false); @@ -200,7 +200,7 @@ private async Task StartReceive(Socket acceptedSocket) /// The context to start receiving for. public void StartReceive(PodeContext context) { - PodeHelpers.WriteErrorMessage($"Starting Receive", Listener, PodeLoggingLevel.Verbose, context); + PodeLogger.LogMessage($"Starting Receive", Listener, PodeLoggingLevel.Verbose, context); try { @@ -210,12 +210,12 @@ public void StartReceive(PodeContext context) catch (OperationCanceledException ex) { // Handle cancellation. - PodeHelpers.WriteException(ex, Listener, PodeLoggingLevel.Verbose); + PodeLogger.LogException(ex, Listener, PodeLoggingLevel.Verbose); } catch (IOException ex) { // Handle I/O exceptions. - PodeHelpers.WriteException(ex, Listener, PodeLoggingLevel.Verbose); + PodeLogger.LogException(ex, Listener, PodeLoggingLevel.Verbose); } catch (AggregateException aex) { @@ -226,7 +226,7 @@ public void StartReceive(PodeContext context) catch (Exception ex) { // Handle any other exceptions. - PodeHelpers.WriteException(ex, Listener); + PodeLogger.LogException(ex, Listener); context.Socket.Close(); } } @@ -250,7 +250,7 @@ private void ProcessAccept(SocketAsyncEventArgs args) { if (error != SocketError.Success) { - PodeHelpers.WriteErrorMessage($"Closing accepting socket: {error}", Listener, PodeLoggingLevel.Debug); + PodeLogger.LogMessage($"Closing accepting socket: {error}", Listener, PodeLoggingLevel.Debug); } // Close socket if it was accepted but there's an error. @@ -269,12 +269,12 @@ private void ProcessAccept(SocketAsyncEventArgs args) catch (OperationCanceledException ex) { // Handle cancellation. - PodeHelpers.WriteException(ex, Listener, PodeLoggingLevel.Verbose); + PodeLogger.LogException(ex, Listener, PodeLoggingLevel.Verbose); } catch (IOException ex) { // Handle I/O exceptions. - PodeHelpers.WriteException(ex, Listener, PodeLoggingLevel.Verbose); + PodeLogger.LogException(ex, Listener, PodeLoggingLevel.Verbose); } catch (AggregateException aex) { @@ -284,7 +284,7 @@ private void ProcessAccept(SocketAsyncEventArgs args) catch (Exception ex) { // Handle any other exceptions. - PodeHelpers.WriteException(ex, Listener); + PodeLogger.LogException(ex, Listener); } } @@ -310,7 +310,7 @@ public async Task HandleContext(PodeContext context) // Check if the request is aborted with a non-StatusCode of 408 (Request Timeout). if (context.Request.IsAborted) { - PodeHelpers.WriteException(context.Request.Error, Listener, context.Request.Error.LoggingLevel); + PodeLogger.LogException(context.Request.Error, Listener, context.Request.Error.LoggingLevel); } context.Dispose(true); @@ -352,13 +352,13 @@ public async Task HandleContext(PodeContext context) { if (context.IsWebSocket) { - PodeHelpers.WriteErrorMessage($"Received client signal", Listener, PodeLoggingLevel.Verbose, context); + PodeLogger.LogMessage($"Received client signal", Listener, PodeLoggingLevel.Verbose, context); Listener.AddClientSignal(context.SignalRequest.NewClientSignal()); context.Dispose(); } else { - PodeHelpers.WriteErrorMessage($"Received request", Listener, PodeLoggingLevel.Verbose, context); + PodeLogger.LogMessage($"Received request", Listener, PodeLoggingLevel.Verbose, context); Listener.AddContext(context); } } @@ -366,7 +366,7 @@ public async Task HandleContext(PodeContext context) catch (Exception ex) { // Log any exceptions that occur while handling the context. - PodeHelpers.WriteException(ex, Listener); + PodeLogger.LogException(ex, Listener); } } @@ -470,7 +470,7 @@ public void Dispose() } catch (Exception ex) { - PodeHelpers.WriteException(ex, Listener); + PodeLogger.LogException(ex, Listener); } } finally diff --git a/src/Listener/PodeTcpRequest.cs b/src/Listener/PodeTcpRequest.cs index debd03b94..749ce6925 100644 --- a/src/Listener/PodeTcpRequest.cs +++ b/src/Listener/PodeTcpRequest.cs @@ -66,7 +66,7 @@ protected override Task Parse(byte[] bytes, CancellationToken cancellation public void Reset() { - PodeHelpers.WriteErrorMessage($"Request reset", Context.Listener, PodeLoggingLevel.Verbose, Context); + PodeLogger.LogMessage($"Request reset", Context.Listener, PodeLoggingLevel.Verbose, Context); _body = string.Empty; RawBody = default; } diff --git a/src/Listener/PodeWatcher.cs b/src/Listener/PodeWatcher.cs index ed3134cb6..2307ba638 100644 --- a/src/Listener/PodeWatcher.cs +++ b/src/Listener/PodeWatcher.cs @@ -52,7 +52,7 @@ public override void Start() protected override void Close() { // dispose watchers - PodeHelpers.WriteErrorMessage($"Closing file watchers", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing file watchers", this, PodeLoggingLevel.Verbose); foreach (var _watcher in FileWatchers.ToArray()) { @@ -60,10 +60,10 @@ protected override void Close() } FileWatchers.Clear(); - PodeHelpers.WriteErrorMessage($"Closed file watchers", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed file watchers", this, PodeLoggingLevel.Verbose); // dispose existing file events - PodeHelpers.WriteErrorMessage($"Closing file events", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing file events", this, PodeLoggingLevel.Verbose); foreach (var _evt in FileEvents.ToArray()) { @@ -71,7 +71,7 @@ protected override void Close() } FileEvents.Clear(); - PodeHelpers.WriteErrorMessage($"Closed file events", this, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed file events", this, PodeLoggingLevel.Verbose); } } } \ No newline at end of file diff --git a/src/Listener/PodeWebSocket.cs b/src/Listener/PodeWebSocket.cs index 87cf4ed79..71f91ba99 100644 --- a/src/Listener/PodeWebSocket.cs +++ b/src/Listener/PodeWebSocket.cs @@ -105,7 +105,7 @@ public async Task Receive() catch (IOException) { } catch (WebSocketException ex) { - PodeHelpers.WriteException(ex, Receiver, PodeLoggingLevel.Debug); + PodeLogger.LogException(ex, Receiver, PodeLoggingLevel.Debug); Dispose(); } finally @@ -139,7 +139,7 @@ public async Task Disconnect(PodeWebSocketCloseFrom closeFrom) if (IsConnected) { - PodeHelpers.WriteErrorMessage($"Closing client web socket: {Name}", Receiver, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closing client web socket: {Name}", Receiver, PodeLoggingLevel.Verbose); // only close output in client closing if (closeFrom == PodeWebSocketCloseFrom.Client) @@ -153,12 +153,12 @@ public async Task Disconnect(PodeWebSocketCloseFrom closeFrom) await WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None).ConfigureAwait(false); } - PodeHelpers.WriteErrorMessage($"Closed client web socket: {Name}", Receiver, PodeLoggingLevel.Verbose); + PodeLogger.LogMessage($"Closed client web socket: {Name}", Receiver, PodeLoggingLevel.Verbose); } WebSocket.Dispose(); - WebSocket = default; - PodeHelpers.WriteErrorMessage($"Disconnected client web socket: {Name}", Receiver, PodeLoggingLevel.Verbose); + WebSocket = default(ClientWebSocket); + PodeLogger.LogMessage($"Disconnected client web socket: {Name}", Receiver, PodeLoggingLevel.Verbose); } public void Dispose() diff --git a/src/Locales/ar/Pode.psd1 b/src/Locales/ar/Pode.psd1 index 08c1d306f..c072b0147 100644 --- a/src/Locales/ar/Pode.psd1 +++ b/src/Locales/ar/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "لم يتم توفير معلمة باسم '{0}' في الطلب أو لا توجد بيانات متاحة." cacheStorageNotFoundForSetExceptionMessage = "لم يتم العثور على مخزن ذاكرة التخزين المؤقت بالاسم '{0}' عند محاولة تعيين العنصر المخزن مؤقتًا '{1}'" methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: تم التعريف بالفعل.' - errorLoggingAlreadyEnabledExceptionMessage = 'تم تمكين تسجيل الأخطاء بالفعل.' valueForUsingVariableNotFoundExceptionMessage = "لم يتم العثور على قيمة لـ '`$using:{0}'." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'أداة الوثائق RapidPdf لا تدعم OpenAPI 3.1' oauth2ClientSecretRequiredExceptionMessage = 'تتطلب OAuth2 سر العميل عند عدم استخدام PKCE.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "تتطلب الخطة '{0}' المقدمة لمحقق المصادقة '{1}' ScriptBlock صالح." sseFailedToBroadcastExceptionMessage = 'فشل بث SSE بسبب مستوى البث SSE المحدد لـ {0}: {1}' adModuleWindowsOnlyExceptionMessage = 'وحدة Active Directory متاحة فقط على نظام Windows.' - requestLoggingAlreadyEnabledExceptionMessage = 'تم تمكين تسجيل الطلبات بالفعل.' invalidAccessControlMaxAgeDurationExceptionMessage = 'مدة Access-Control-Max-Age غير صالحة المقدمة: {0}. يجب أن تكون أكبر من 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'تعريف OpenAPI باسم {0} موجود بالفعل.' renamePodeOADefinitionTagExceptionMessage = "لا يمكن استخدام Rename-PodeOADefinitionTag داخل Select-PodeOADefinition 'ScriptBlock'." + loggingAlreadyEnabledExceptionMessage = "تم تمكين تسجيل '{0}' بالفعل." + invalidEncodingExceptionMessage = 'ترميز غير صالح: {0}' + syslogProtocolExceptionMessage = 'يمكن لبروتوكول Syslog استخدام RFC3164 أو RFC5424 فقط.' taskProcessDoesNotExistExceptionMessage = 'عملية المهمة غير موجودة: {0}' scheduleProcessDoesNotExistExceptionMessage = 'عملية الجدول الزمني غير موجودة: {0}' definitionTagChangeNotAllowedExceptionMessage = 'لا يمكن تغيير علامة التعريف لمسار.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "الدالة '{0}' لا تقبل مصفوفة كمدخل لأنبوب البيانات." unsupportedStreamCompressionEncodingExceptionMessage = 'تشفير الضغط غير مدعوم للتشفير {0}' localEndpointConflictExceptionMessage = "تم تعريف كل من '{0}' و '{1}' كنقاط نهاية محلية لـ OpenAPI، لكن يُسمح فقط بنقطة نهاية محلية واحدة لكل تعريف API." + deprecatedFunctionWarningMessage = "تحذير: الدالة '{0}' مهملة وستتم إزالتها في الإصدارات المستقبلية. يُرجى استخدام الدالة '{1}' بدلاً منها." serviceAlreadyRegisteredException = "الخدمة '{0}' مسجلة بالفعل." serviceIsNotRegisteredException = "الخدمة '{0}' غير مسجلة." serviceCommandFailedException = "فشل الأمر '{0}' في الخدمة '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = 'قاعدة الحد الأقصى للمعدل غير موجودة: {0}' accessLimitRuleAlreadyExistsExceptionMessage = 'تم تعريف قاعدة الحد الأقصى للوصول بالفعل: {0}' accessLimitRuleDoesNotExistExceptionMessage = 'قاعدة الحد الأقصى للوصول غير موجودة: {0}' + loggerDoesNotExistExceptionMessage = "المسجل '{0}' غير موجود." invalidPodeStateFormatExceptionMessage = 'ملف PodeState "{0}" يحتوي على تنسيق غير صالح. كان متوقعًا هيكل يشبه القاموس (ConcurrentDictionary أو Hashtable أو OrderedDictionary)، ولكن تم العثور على [{1}]. يرجى التحقق من محتوى الملف أو إعادة تهيئة الحالة.' unknownJsonDictionaryTypeExceptionMessage = 'نوع قاموس/مجموعة غير معروف في JSON: {0}' invalidPodeStateDataExceptionMessage = 'البيانات المقدمة لا تمثل حالة Pode صالحة.' diff --git a/src/Locales/de/Pode.psd1 b/src/Locales/de/Pode.psd1 index 464caa43d..1dc4a5c11 100644 --- a/src/Locales/de/Pode.psd1 +++ b/src/Locales/de/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "Ein Parameter namens '{0}' wurde in der Anfrage nicht angegeben oder es sind keine Daten verfügbar." cacheStorageNotFoundForSetExceptionMessage = "Der Cache-Speicher mit dem Namen '{0}' wurde nicht gefunden, als versucht wurde, das zwischengespeicherte Element '{1}' zu setzen." methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Bereits definiert.' - errorLoggingAlreadyEnabledExceptionMessage = 'Die Fehlerprotokollierung wurde bereits aktiviert.' valueForUsingVariableNotFoundExceptionMessage = "Der Wert für '`$using:{0}' konnte nicht gefunden werden." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'Das Dokumentationstool RapidPdf unterstützt OpenAPI 3.1 nicht.' oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 erfordert ein Client Secret, wenn PKCE nicht verwendet wird.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "Das bereitgestellte '{0}'-Schema für den Authentifizierungsvalidator '{1}' erfordert einen gültigen ScriptBlock." sseFailedToBroadcastExceptionMessage = 'SSE konnte aufgrund des definierten SSE-Broadcast-Levels für {0}: {1} nicht übertragen werden.' adModuleWindowsOnlyExceptionMessage = 'Active Directory-Modul nur unter Windows verfügbar.' - requestLoggingAlreadyEnabledExceptionMessage = 'Die Anforderungsprotokollierung wurde bereits aktiviert.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Ungültige Access-Control-Max-Age-Dauer angegeben: {0}. Sollte größer als 0 sein.' openApiDefinitionAlreadyExistsExceptionMessage = 'Die OpenAPI-Definition mit dem Namen {0} existiert bereits.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag kann nicht innerhalb eines 'ScriptBlock' von Select-PodeOADefinition verwendet werden." + loggingAlreadyEnabledExceptionMessage = "Das Logging '{0}' wurde bereits aktiviert." + invalidEncodingExceptionMessage = 'Ungültige Codierung: {0}' + syslogProtocolExceptionMessage = 'Das Syslog-Protokoll kann nur RFC3164 oder RFC5424 verwenden.' taskProcessDoesNotExistExceptionMessage = "Der Aufgabenprozess '{0}' existiert nicht." scheduleProcessDoesNotExistExceptionMessage = "Der Aufgabenplanerprozess '{0}' existiert nicht." definitionTagChangeNotAllowedExceptionMessage = 'Definitionstag für eine Route kann nicht geändert werden.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Die Funktion '{0}' akzeptiert kein Array als Pipeline-Eingabe." unsupportedStreamCompressionEncodingExceptionMessage = 'Die Stream-Komprimierungskodierung wird nicht unterstützt: {0}' localEndpointConflictExceptionMessage = "Sowohl '{0}' als auch '{1}' sind als lokale OpenAPI-Endpunkte definiert, aber es ist nur ein lokaler Endpunkt pro API-Definition erlaubt." + deprecatedFunctionWarningMessage = "WARNUNG: Die Funktion '{0}' ist veraltet und wird in zukünftigen Versionen entfernt. Bitte verwenden Sie stattdessen die Funktion '{1}'." serviceAlreadyRegisteredException = "Der Dienst '{0}' ist bereits registriert." serviceIsNotRegisteredException = "Der Dienst '{0}' ist nicht registriert." serviceCommandFailedException = "Der Dienstbefehl '{0}' ist bei dem Dienst '{1}' fehlgeschlagen." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "Die Rate-Limit-Regel mit dem Namen '{0}' existiert nicht." accessLimitRuleAlreadyExistsExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert bereits." accessLimitRuleDoesNotExistExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert nicht." + loggerDoesNotExistExceptionMessage = "Logger '{0}' existiert nicht." missingKeyForAlgorithmExceptionMessage = 'Ein {0}-Schlüssel ist für {1}-Algorithmen ({2}) erforderlich.' jwtIssuedInFutureExceptionMessage = "Der 'iat' (Issued At)-Zeitstempel des JWT ist in der Zukunft gesetzt. Das Token ist noch nicht gültig." jwtInvalidIssuerExceptionMessage = "Der JWT-Anspruch 'iss' (Issuer) ist ungültig oder fehlt. Erwarteter Herausgeber: '{0}'." diff --git a/src/Locales/en-us/Pode.psd1 b/src/Locales/en-us/Pode.psd1 index 3912880b7..31e0c8d0e 100644 --- a/src/Locales/en-us/Pode.psd1 +++ b/src/Locales/en-us/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "A parameter called '{0}' was not supplied in the request or has no data available." cacheStorageNotFoundForSetExceptionMessage = "Cache storage with name '{0}' not found when attempting to set cached item '{1}'" methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Already defined.' - errorLoggingAlreadyEnabledExceptionMessage = 'Error Logging has already been enabled.' valueForUsingVariableNotFoundExceptionMessage = "Value for '`$using:{0}' could not be found." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = "The Document tool RapidPdf doesn't support OpenAPI 3.1" oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 requires a Client Secret when not using PKCE.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "The supplied '{0}' Scheme for the '{1}' authentication validator requires a valid ScriptBlock." sseFailedToBroadcastExceptionMessage = 'SSE failed to broadcast due to defined SSE broadcast level for {0}: {1}' adModuleWindowsOnlyExceptionMessage = 'Active Directory module only available on Windows OS.' - requestLoggingAlreadyEnabledExceptionMessage = 'Request Logging has already been enabled.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Invalid Access-Control-Max-Age duration supplied: {0}. Should be greater than 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'OpenAPI definition named {0} already exists.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag cannot be used inside a Select-PodeOADefinition 'ScriptBlock'." + loggingAlreadyEnabledExceptionMessage = "Logging '{0}' has already been enabled." + invalidEncodingExceptionMessage = 'Invalid encoding: {0}' + syslogProtocolExceptionMessage = 'The Syslog protocol can use only RFC3164 or RFC5424.' taskProcessDoesNotExistExceptionMessage = 'Task process does not exist: {0}' scheduleProcessDoesNotExistExceptionMessage = 'Schedule process does not exist: {0}' definitionTagChangeNotAllowedExceptionMessage = 'Definition Tag for a Route cannot be changed.' @@ -333,6 +334,8 @@ rateLimitRuleDoesNotExistExceptionMessage = "A rate limit rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An access limit rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An access limit rule with the name '{0}' does not exist." + deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." + loggerDoesNotExistExceptionMessage = "Logger '{0}' does not exist." missingKeyForAlgorithmExceptionMessage = 'A {0} key is required for {1} algorithms ({2}).' jwtIssuedInFutureExceptionMessage = "The JWT's 'iat' (Issued At) timestamp is set in the future. The token is not valid yet." jwtInvalidIssuerExceptionMessage = "The JWT 'iss' (Issuer) claim is invalid or missing. Expected issuer: '{0}'." diff --git a/src/Locales/en/Pode.psd1 b/src/Locales/en/Pode.psd1 index cc4f43f53..a6b71d0db 100644 --- a/src/Locales/en/Pode.psd1 +++ b/src/Locales/en/Pode.psd1 @@ -154,7 +154,6 @@ parameterNotSuppliedInRequestExceptionMessage = "A parameter called '{0}' was not supplied in the request or has no data available." cacheStorageNotFoundForSetExceptionMessage = "Cache storage with name '{0}' not found when attempting to set cached item '{1}'" methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Already defined.' - errorLoggingAlreadyEnabledExceptionMessage = 'Error Logging has already been enabled.' valueForUsingVariableNotFoundExceptionMessage = "Value for '`$using:{0}' could not be found." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = "The Document tool RapidPdf doesn't support OpenAPI 3.1" oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 requires a Client Secret when not using PKCE.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "The supplied '{0}' Scheme for the '{1}' authentication validator requires a valid ScriptBlock." sseFailedToBroadcastExceptionMessage = 'SSE failed to broadcast due to defined SSE broadcast level for {0}: {1}' adModuleWindowsOnlyExceptionMessage = 'Active Directory module only available on Windows OS.' - requestLoggingAlreadyEnabledExceptionMessage = 'Request Logging has already been enabled.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Invalid Access-Control-Max-Age duration supplied: {0}. Should be greater than 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'OpenAPI definition named {0} already exists.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag cannot be used inside a Select-PodeOADefinition 'ScriptBlock'." + loggingAlreadyEnabledExceptionMessage = "Logging '{0}' has already been enabled." + invalidEncodingExceptionMessage = 'Invalid encoding: {0}' + syslogProtocolExceptionMessage = 'The Syslog protocol can use only RFC3164 or RFC5424.' taskProcessDoesNotExistExceptionMessage = 'Task process does not exist: {0}' scheduleProcessDoesNotExistExceptionMessage = 'Schedule process does not exist: {0}' definitionTagChangeNotAllowedExceptionMessage = 'Definition Tag for a Route cannot be changed.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "The function '{0}' does not accept an array as pipeline input." unsupportedStreamCompressionEncodingExceptionMessage = 'Unsupported stream compression encoding: {0}' localEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition." + deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." serviceAlreadyRegisteredException = "Service '{0}' is already registered." serviceIsNotRegisteredException = "Service '{0}' is not registered." serviceCommandFailedException = "Service command '{0}' failed on service '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "A Rate Limit Rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An Access Limit Rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An Access Limit Rule with the name '{0}' does not exist." + loggerDoesNotExistExceptionMessage = "Logger '{0}' does not exist." missingKeyForAlgorithmExceptionMessage = 'A {0} key is required for {1} algorithms ({2}).' jwtIssuedInFutureExceptionMessage = "The JWT's 'iat' (Issued At) timestamp is set in the future. The token is not valid yet." jwtInvalidIssuerExceptionMessage = "The JWT 'iss' (Issuer) claim is invalid or missing. Expected issuer: '{0}'." diff --git a/src/Locales/es/Pode.psd1 b/src/Locales/es/Pode.psd1 index 23f450eed..8a8b37f43 100644 --- a/src/Locales/es/Pode.psd1 +++ b/src/Locales/es/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "No se ha proporcionado un parámetro llamado '{0}' en la solicitud o no hay datos disponibles." cacheStorageNotFoundForSetExceptionMessage = "No se encontró el almacenamiento en caché con el nombre '{0}' al intentar establecer el elemento en caché '{1}'." methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Ya está definido.' - errorLoggingAlreadyEnabledExceptionMessage = 'El registro de errores ya está habilitado.' valueForUsingVariableNotFoundExceptionMessage = "No se pudo encontrar el valor para '`$using:{0}'." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'La herramienta de documentación RapidPdf no admite OpenAPI 3.1' oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 requiere un Client Secret cuando no se usa PKCE.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "El esquema '{0}' proporcionado para el validador de autenticación '{1}' requiere un ScriptBlock válido." sseFailedToBroadcastExceptionMessage = 'SSE no pudo transmitir debido al nivel de transmisión SSE definido para {0}: {1}.' adModuleWindowsOnlyExceptionMessage = 'El módulo de Active Directory solo está disponible en Windows.' - requestLoggingAlreadyEnabledExceptionMessage = 'El registro de solicitudes ya está habilitado.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Duración inválida para Access-Control-Max-Age proporcionada: {0}. Debe ser mayor que 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'La definición de OpenAPI con el nombre {0} ya existe.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag no se puede usar dentro de un 'ScriptBlock' de Select-PodeOADefinition." + loggingAlreadyEnabledExceptionMessage = "El registro '{0}' ya ha sido habilitado." + invalidEncodingExceptionMessage = 'Codificación inválida: {0}' + syslogProtocolExceptionMessage = 'El protocolo Syslog solo puede usar RFC3164 o RFC5424.' taskProcessDoesNotExistExceptionMessage = "El proceso de la tarea '{0}' no existe." scheduleProcessDoesNotExistExceptionMessage = "El proceso del programación '{0}' no existe." definitionTagChangeNotAllowedExceptionMessage = 'La etiqueta de definición para una Route no se puede cambiar.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La función '{0}' no acepta una matriz como entrada de canalización." unsupportedStreamCompressionEncodingExceptionMessage = 'La codificación de compresión de transmisión no es compatible: {0}' localEndpointConflictExceptionMessage = "Tanto '{0}' como '{1}' están definidos como puntos finales locales de OpenAPI, pero solo se permite un punto final local por definición de API." + deprecatedFunctionWarningMessage = "ADVERTENCIA: La función '{0}' está obsoleta y será eliminada en futuras versiones. Por favor, use la función '{1}' en su lugar." serviceAlreadyRegisteredException = "El servicio '{0}' ya está registrado." serviceIsNotRegisteredException = "El servicio '{0}' no está registrado." serviceCommandFailedException = "El comando del servicio '{0}' falló en el servicio '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regla de límite de velocidad con el nombre '{0}' no existe." accessLimitRuleAlreadyExistsExceptionMessage = "La regla de límite de acceso con el nombre '{0}' ya existe." accessLimitRuleDoesNotExistExceptionMessage = "La regla de límite de acceso con el nombre '{0}' no existe." + loggerDoesNotExistExceptionMessage = "El registrador '{0}' no existe." missingKeyForAlgorithmExceptionMessage = 'Se requiere una clave {0} para los algoritmos {1} ({2}).' jwtIssuedInFutureExceptionMessage = "La marca de tiempo 'iat' (Issued At) del JWT está configurada en el futuro. El token aún no es válido." jwtInvalidIssuerExceptionMessage = "La reclamación 'iss' (Issuer) del JWT es inválida o falta. Emisor esperado: '{0}'." diff --git a/src/Locales/fr/Pode.psd1 b/src/Locales/fr/Pode.psd1 index 3944c211c..156d694e3 100644 --- a/src/Locales/fr/Pode.psd1 +++ b/src/Locales/fr/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "Un paramètre nommé '{0}' n'a pas été fourni dans la demande ou aucune donnée n'est disponible." cacheStorageNotFoundForSetExceptionMessage = "Le stockage de cache nommé '{0}' est introuvable lors de la tentative de définition de l'élément mis en cache '{1}'." methodPathAlreadyDefinedExceptionMessage = '[{0}] {1} : Déjà défini.' - errorLoggingAlreadyEnabledExceptionMessage = 'La journalisation des erreurs est déjà activée.' valueForUsingVariableNotFoundExceptionMessage = "Valeur pour '`$using:{0}' introuvable." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = "L'outil de documentation RapidPdf ne prend pas en charge OpenAPI 3.1" oauth2ClientSecretRequiredExceptionMessage = "OAuth2 nécessite un Client Secret lorsque PKCE n'est pas utilisé." @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "Le schéma '{0}' fourni pour le validateur d'authentification '{1}' nécessite un ScriptBlock valide." sseFailedToBroadcastExceptionMessage = 'SSE a échoué à diffuser en raison du niveau de diffusion SSE défini pour {0} : {1}.' adModuleWindowsOnlyExceptionMessage = 'Le module Active Directory est uniquement disponible sur Windows.' - requestLoggingAlreadyEnabledExceptionMessage = 'La journalisation des requêtes est déjà activée.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Durée Access-Control-Max-Age invalide fournie : {0}. Doit être supérieure à 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'La définition OpenAPI nommée {0} existe déjà.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag ne peut pas être utilisé à l'intérieur d'un 'ScriptBlock' de Select-PodeOADefinition." + loggingAlreadyEnabledExceptionMessage = "La journalisation '{0}' a déjà été activée." + invalidEncodingExceptionMessage = 'Encodage invalide : {0}' + syslogProtocolExceptionMessage = 'Le protocole Syslog ne peut utiliser que RFC3164 ou RFC5424.' taskProcessDoesNotExistExceptionMessage = "Le processus de la tâche '{0}' n'existe pas." scheduleProcessDoesNotExistExceptionMessage = "Le processus de l'horaire '{0}' n'existe pas." definitionTagChangeNotAllowedExceptionMessage = 'Le tag de définition pour une Route ne peut pas être modifié.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La fonction '{0}' n'accepte pas un tableau en tant qu'entrée de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = "La compression de flux {0} n'est pas prise en charge." localEndpointConflictExceptionMessage = "Les deux '{0}' et '{1}' sont définis comme des points de terminaison locaux pour OpenAPI, mais un seul point de terminaison local est autorisé par définition d'API." + deprecatedFunctionWarningMessage = "AVERTISSEMENT : La fonction '{0}' est obsolète et sera supprimée dans les versions futures. Veuillez utiliser la fonction '{1}' à la place." serviceAlreadyRegisteredException = "Le service '{0}' est déjà enregistré." serviceIsNotRegisteredException = "Le service '{0}' n'est pas enregistré." serviceCommandFailedException = "La commande de service '{0}' a échoué sur le service '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "La règle de limite de taux '{0}' n'existe pas." accessLimitRuleAlreadyExistsExceptionMessage = "Une règle de limite d'accès nommée '{0}' existe déjà." accessLimitRuleDoesNotExistExceptionMessage = "La règle de limite d'accès '{0}' n'existe pas." + loggerDoesNotExistExceptionMessage = "Le journaliseur '{0}' n'existe pas." invalidPodeStateFormatExceptionMessage = 'Il file PodeState "{0}" contiene un formato non valido. Era prevista una struttura simile a un dizionario (ConcurrentDictionary, Hashtable o OrderedDictionary), ma è stato trovato [{1}]. Verifica il contenuto del file o reinizializza lo stato.' unknownJsonDictionaryTypeExceptionMessage = 'Type de dictionnaire/collection inconnu dans le JSON : {0}' invalidPodeStateDataExceptionMessage = 'Les données fournies ne représentent pas un état valide de Pode.' diff --git a/src/Locales/it/Pode.psd1 b/src/Locales/it/Pode.psd1 index b229026c2..ccb3bc189 100644 --- a/src/Locales/it/Pode.psd1 +++ b/src/Locales/it/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "Un parametro chiamato '{0}' non è stato fornito nella richiesta o non ci sono dati disponibili." cacheStorageNotFoundForSetExceptionMessage = "Memoria cache con nome '{0}' non trovata durante il tentativo di impostare l'elemento memorizzato nella cache '{1}'." methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Già definito.' - errorLoggingAlreadyEnabledExceptionMessage = 'La registrazione degli errori è già abilitata.' valueForUsingVariableNotFoundExceptionMessage = "Impossibile trovare il valore per '`$using:{0}'." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'Lo strumento di documentazione RapidPdf non supporta OpenAPI 3.1' oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 richiede un Client Secret quando non si utilizza PKCE.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "Lo schema '{0}' fornito per il validatore di autenticazione '{1}' richiede uno ScriptBlock valido." sseFailedToBroadcastExceptionMessage = 'SSE non è riuscito a trasmettere a causa del livello di trasmissione SSE definito per {0}: {1}.' adModuleWindowsOnlyExceptionMessage = 'Il modulo Active Directory è disponibile solo su Windows OS.' - requestLoggingAlreadyEnabledExceptionMessage = 'La registrazione delle richieste è già abilitata.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Durata non valida fornita per Access-Control-Max-Age: {0}. Deve essere maggiore di 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'La definizione OpenAPI denominata {0} esiste già.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag non può essere utilizzato all'interno di un 'ScriptBlock' di Select-PodeOADefinition." + loggingAlreadyEnabledExceptionMessage = "Il logging '{0}' è già stato abilitato." + invalidEncodingExceptionMessage = 'Codifica non valida: {0}' + syslogProtocolExceptionMessage = 'Il protocollo Syslog può utilizzare solo RFC3164 o RFC5424.' taskProcessDoesNotExistExceptionMessage = "Il processo dell'attività '{0}' non esiste." scheduleProcessDoesNotExistExceptionMessage = "Il processo della programma '{0}' non esiste." definitionTagChangeNotAllowedExceptionMessage = 'Il tag di definizione per una Route non può essere cambiato.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La funzione '{0}' non accetta una matrice come input della pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'La compressione dello stream non è supportata per la codifica {0}' localEndpointConflictExceptionMessage = "Sia '{0}' che '{1}' sono definiti come endpoint locali OpenAPI, ma è consentito solo un endpoint locale per definizione API." + deprecatedFunctionWarningMessage = "AVVISO: La funzione '{0}' è obsoleta e verrà rimossa nelle versioni future. Si prega di utilizzare la funzione '{1}' al suo posto." serviceAlreadyRegisteredException = "Il servizio '{0}' è già registrato." serviceIsNotRegisteredException = "Il servizio '{0}' non è registrato." serviceCommandFailedException = "Il comando '{0}' è fallito sul servizio '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione del tasso con il nome '{0}' non esiste." accessLimitRuleAlreadyExistsExceptionMessage = "Una regola di limitazione dell'accesso con il nome '{0}' esiste già." accessLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione dell'accesso con il nome '{0}' non esiste." + loggerDoesNotExistExceptionMessage = "Il logger '{0}' non esiste." missingKeyForAlgorithmExceptionMessage = 'È necessaria una chiave {0} per gli algoritmi {1} ({2}).' jwtIssuedInFutureExceptionMessage = "Il timestamp 'iat' (Issued At) del JWT è impostato nel futuro. Il token non è ancora valido." jwtInvalidIssuerExceptionMessage = "Il claim 'iss' (Issuer) del JWT non è valido o è mancante. Emittente previsto: '{0}'." diff --git a/src/Locales/ja/Pode.psd1 b/src/Locales/ja/Pode.psd1 index 5107cf479..9fa67d807 100644 --- a/src/Locales/ja/Pode.psd1 +++ b/src/Locales/ja/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "リクエストに '{0}' という名前のパラメータが提供されていないか、データがありません。" cacheStorageNotFoundForSetExceptionMessage = "キャッシュされたアイテム '{1}' を設定しようとしたときに、名前 '{0}' のキャッシュストレージが見つかりません。" methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: 既に定義されています。' - errorLoggingAlreadyEnabledExceptionMessage = 'エラーロギングは既に有効になっています。' valueForUsingVariableNotFoundExceptionMessage = "'`$using:{0}'の値が見つかりませんでした。" rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'ドキュメントツール RapidPdf は OpenAPI 3.1 をサポートしていません' oauth2ClientSecretRequiredExceptionMessage = 'PKCEを使用しない場合、OAuth2にはクライアントシークレットが必要です。' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "'{1}'認証バリデーターのために提供された'{0}'スキームには有効なScriptBlockが必要です。" sseFailedToBroadcastExceptionMessage = '{0}のSSEブロードキャストレベルが定義されているため、SSEのブロードキャストに失敗しました: {1}' adModuleWindowsOnlyExceptionMessage = 'Active DirectoryモジュールはWindowsでのみ利用可能です。' - requestLoggingAlreadyEnabledExceptionMessage = 'リクエストロギングは既に有効になっています。' invalidAccessControlMaxAgeDurationExceptionMessage = '無効な Access-Control-Max-Age 期間が提供されました:{0}。0 より大きくする必要があります。' openApiDefinitionAlreadyExistsExceptionMessage = '名前が {0} の OpenAPI 定義は既に存在します。' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag は Select-PodeOADefinition 'ScriptBlock' 内で使用できません。" + loggingAlreadyEnabledExceptionMessage = "ログ記録 '{0}' は既に有効になっています。" + invalidEncodingExceptionMessage = '無効なエンコーディング: {0}' + syslogProtocolExceptionMessage = 'SyslogプロトコルはRFC3164またはRFC5424のみを使用できます。' taskProcessDoesNotExistExceptionMessage = 'タスクプロセスが存在しません: {0}' scheduleProcessDoesNotExistExceptionMessage = 'スケジュールプロセスが存在しません: {0}' definitionTagChangeNotAllowedExceptionMessage = 'Routeの定義タグは変更できません。' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "関数 '{0}' は配列をパイプライン入力として受け付けません。" unsupportedStreamCompressionEncodingExceptionMessage = 'サポートされていないストリーム圧縮エンコーディングが提供されました: {0}' localEndpointConflictExceptionMessage = "'{0}' と '{1}' は OpenAPI のローカルエンドポイントとして定義されていますが、API 定義ごとに 1 つのローカルエンドポイントのみ許可されます。" + deprecatedFunctionWarningMessage = "警告: 関数 '{0}' は廃止され、将来のリリースで削除されます。代わりに '{1}' 関数を使用してください。" serviceAlreadyRegisteredException = "サービス '{0}' はすでに登録されています。" serviceIsNotRegisteredException = "サービス '{0}' は登録されていません。" serviceCommandFailedException = "サービスコマンド '{0}' はサービス '{1}' で失敗しました。" @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のレート制限ルールは存在しません。" accessLimitRuleAlreadyExistsExceptionMessage = "名前が '{0}' のアクセス制限ルールは既に存在します。" accessLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のアクセス制限ルールは存在しません。" + loggerDoesNotExistExceptionMessage = "ロガー '{0}' は存在しません。" missingKeyForAlgorithmExceptionMessage = '{1} アルゴリズム ({2}) には {0} キーが必要です。' jwtIssuedInFutureExceptionMessage = "JWTの'iat'(発行時刻)タイムスタンプが未来の日付になっています。トークンはまだ有効ではありません。" jwtInvalidIssuerExceptionMessage = "JWTの'iss'(発行者)クレームが無効または欠落しています。期待される発行者: '{0}'。" diff --git a/src/Locales/ko/Pode.psd1 b/src/Locales/ko/Pode.psd1 index 6623c6351..0b06c579a 100644 --- a/src/Locales/ko/Pode.psd1 +++ b/src/Locales/ko/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "요청에 '{0}'라는 이름의 매개변수가 제공되지 않았거나 데이터가 없습니다." cacheStorageNotFoundForSetExceptionMessage = "캐시된 항목 '{1}'을(를) 설정하려고 할 때 이름이 '{0}'인 캐시 스토리지를 찾을 수 없습니다." methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: 이미 정의되었습니다.' - errorLoggingAlreadyEnabledExceptionMessage = '오류 로깅이 이미 활성화되었습니다.' valueForUsingVariableNotFoundExceptionMessage = "'`$using:{0}'에 대한 값을 찾을 수 없습니다." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = '문서 도구 RapidPdf는 OpenAPI 3.1을 지원하지 않습니다.' oauth2ClientSecretRequiredExceptionMessage = 'PKCE를 사용하지 않을 때 OAuth2에는 클라이언트 비밀이 필요합니다.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "'{1}' 인증 검증기에 제공된 '{0}' 스킴에는 유효한 ScriptBlock이 필요합니다." sseFailedToBroadcastExceptionMessage = '{0}에 대해 정의된 SSE 브로드캐스트 수준으로 인해 SSE 브로드캐스트에 실패했습니다: {1}' adModuleWindowsOnlyExceptionMessage = 'Active Directory 모듈은 Windows에서만 사용할 수 있습니다.' - requestLoggingAlreadyEnabledExceptionMessage = '요청 로깅이 이미 활성화되었습니다.' invalidAccessControlMaxAgeDurationExceptionMessage = '잘못된 Access-Control-Max-Age 기간이 제공되었습니다: {0}. 0보다 커야 합니다.' openApiDefinitionAlreadyExistsExceptionMessage = '이름이 {0}인 OpenAPI 정의가 이미 존재합니다.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag은 Select-PodeOADefinition 'ScriptBlock' 내에서 사용할 수 없습니다." + loggingAlreadyEnabledExceptionMessage = "로그 '{0}'이(가) 이미 활성화되었습니다." + invalidEncodingExceptionMessage = '잘못된 인코딩: {0}' + syslogProtocolExceptionMessage = 'Syslog 프로토콜은 RFC3164 또는 RFC5424만 사용할 수 있습니다.' taskProcessDoesNotExistExceptionMessage = '작업 프로세스가 존재하지 않습니다: {0}' scheduleProcessDoesNotExistExceptionMessage = '스케줄 프로세스가 존재하지 않습니다: {0}' definitionTagChangeNotAllowedExceptionMessage = 'Route에 대한 정의 태그는 변경할 수 없습니다.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "함수 '{0}'은(는) 배열을 파이프라인 입력으로 받지 않습니다." unsupportedStreamCompressionEncodingExceptionMessage = '지원되지 않는 스트림 압축 인코딩: {0}' localEndpointConflictExceptionMessage = "'{0}' 와 '{1}' 는 OpenAPI 로컬 엔드포인트로 정의되었지만, API 정의당 하나의 로컬 엔드포인트만 허용됩니다." + deprecatedFunctionWarningMessage = "경고: 함수 '{0}'는 더 이상 지원되지 않으며, 향후 릴리스에서 제거될 예정입니다. 대신 '{1}' 함수를 사용하세요." serviceAlreadyRegisteredException = "서비스 '{0}'가 이미 등록되었습니다." serviceIsNotRegisteredException = "서비스 '{0}'가 등록되지 않았습니다." serviceCommandFailedException = "서비스 명령 '{0}' 이(가) 서비스 '{1}' 에서 실패했습니다." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 비율 제한 규칙이 존재하지 않습니다." accessLimitRuleAlreadyExistsExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 이미 존재합니다." accessLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 존재하지 않습니다." + loggerDoesNotExistExceptionMessage = "로거 '{0}'가 존재하지 않습니다." missingKeyForAlgorithmExceptionMessage = '{1} 알고리즘 ({2}) 에는 {0} 키가 필요합니다.' jwtIssuedInFutureExceptionMessage = "JWT의 'iat' (발행 시간) 타임스탬프가 미래로 설정되어 있습니다. 토큰은 아직 유효하지 않습니다." jwtInvalidIssuerExceptionMessage = "JWT의 'iss' (발행자) 클레임이 잘못되었거나 누락되었습니다. 예상 발행자: '{0}'." diff --git a/src/Locales/nl/Pode.psd1 b/src/Locales/nl/Pode.psd1 index 84b40c199..3eb02b8bb 100644 --- a/src/Locales/nl/Pode.psd1 +++ b/src/Locales/nl/Pode.psd1 @@ -154,7 +154,6 @@ parameterNotSuppliedInRequestExceptionMessage = "Een parameter genaamd '{0}' is niet opgegeven in het verzoek of heeft geen beschikbare gegevens." cacheStorageNotFoundForSetExceptionMessage = "Cache-opslag met naam '{0}' niet gevonden bij poging om gecachte item '{1}' in te stellen" methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Al gedefinieerd.' - errorLoggingAlreadyEnabledExceptionMessage = 'Foutlogboekregistratie is al ingeschakeld.' valueForUsingVariableNotFoundExceptionMessage = "Waarde voor '`$using:{0}' kon niet worden gevonden." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'Het Document-tool RapidPdf ondersteunt OpenAPI 3.1 niet' oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 vereist een Client Secret wanneer PKCE niet wordt gebruikt.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "Het opgegeven '{0}' schema voor de '{1}' authenticatievalidator vereist een geldige ScriptBlock." sseFailedToBroadcastExceptionMessage = 'SSE kon niet uitzenden vanwege het gedefinieerde SSE-uitzendniveau voor {0}: {1}' adModuleWindowsOnlyExceptionMessage = 'Active Directory-module alleen beschikbaar op Windows OS.' - requestLoggingAlreadyEnabledExceptionMessage = 'Verzoeklogboekregistratie is al ingeschakeld.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Ongeldige Access-Control-Max-Age duur opgegeven: {0}. Moet groter zijn dan 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'OpenAPI-definitie met de naam {0} bestaat al.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag kan niet worden gebruikt binnen een Select-PodeOADefinition 'ScriptBlock'." + loggingAlreadyEnabledExceptionMessage = "Logging '{0}' is al ingeschakeld." + invalidEncodingExceptionMessage = 'Ongeldige codering: {0}' + syslogProtocolExceptionMessage = 'Het Syslog-protocol kan alleen RFC3164 of RFC5424 gebruiken.' taskProcessDoesNotExistExceptionMessage = "Taakproces '{0}' bestaat niet." scheduleProcessDoesNotExistExceptionMessage = "Schema-proces '{0}' bestaat niet." definitionTagChangeNotAllowedExceptionMessage = 'Definitietag voor een route kan niet worden gewijzigd.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "De functie '{0}' accepteert geen array als pipeline-invoer." unsupportedStreamCompressionEncodingExceptionMessage = 'Niet-ondersteunde streamcompressie-encodering: {0}' localEndpointConflictExceptionMessage = "Zowel '{0}' als '{1}' zijn gedefinieerd als lokale OpenAPI-eindpunten, maar er is slechts één lokaal eindpunt per API-definitie toegestaan." + deprecatedFunctionWarningMessage = "WAARSCHUWING: De functie '{0}' is verouderd en zal in toekomstige versies worden verwijderd. Gebruik in plaats daarvan de functie '{1}'." serviceAlreadyRegisteredException = "De service '{0}' is al geregistreerd." serviceIsNotRegisteredException = "De service '{0}' is niet geregistreerd." serviceCommandFailedException = "De serviceopdracht '{0}' is mislukt op de service '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "Rate Limit-regel met de naam '{0}' bestaat niet." accessLimitRuleAlreadyExistsExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat al." accessLimitRuleDoesNotExistExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat niet." + loggerDoesNotExistExceptionMessage = "Logger '{0}' bestaat niet." missingKeyForAlgorithmExceptionMessage = 'Een {0}-sleutel is vereist voor {1}-algoritmen ({2}).' jwtIssuedInFutureExceptionMessage = "De 'iat' (Issued At) tijdstempel van de JWT is ingesteld in de toekomst. Het token is nog niet geldig." jwtInvalidIssuerExceptionMessage = "De JWT 'iss' (Issuer) claim is ongeldig of ontbreekt. Verwachte uitgever: '{0}'." diff --git a/src/Locales/pl/Pode.psd1 b/src/Locales/pl/Pode.psd1 index 6ce8a3898..4614d151f 100644 --- a/src/Locales/pl/Pode.psd1 +++ b/src/Locales/pl/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "Parametr o nazwie '{0}' nie został dostarczony w żądaniu lub nie ma dostępnych danych." cacheStorageNotFoundForSetExceptionMessage = "Nie znaleziono magazynu pamięci podręcznej o nazwie '{0}' podczas próby ustawienia elementu w pamięci podręcznej '{1}'." methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Już zdefiniowane.' - errorLoggingAlreadyEnabledExceptionMessage = 'Rejestrowanie błędów jest już włączone.' valueForUsingVariableNotFoundExceptionMessage = "Nie można znaleźć wartości dla '`$using:{0}'." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'Narzędzie do dokumentów RapidPdf nie obsługuje OpenAPI 3.1' oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 wymaga tajemnicy klienta, gdy nie używa się PKCE.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "Dostarczony schemat '{0}' dla walidatora uwierzytelniania '{1}' wymaga ważnego ScriptBlock." sseFailedToBroadcastExceptionMessage = 'SSE nie udało się przesłać z powodu zdefiniowanego poziomu przesyłania SSE dla {0}: {1}' adModuleWindowsOnlyExceptionMessage = 'Moduł Active Directory jest dostępny tylko w systemie Windows.' - requestLoggingAlreadyEnabledExceptionMessage = 'Rejestrowanie żądań jest już włączone.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Podano nieprawidłowy czas trwania Access-Control-Max-Age: {0}. Powinien być większy niż 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'Definicja OpenAPI o nazwie {0} już istnieje.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag nie może być używany wewnątrz 'ScriptBlock' Select-PodeOADefinition." + loggingAlreadyEnabledExceptionMessage = "Rejestrowanie '{0}' jest już włączone." + invalidEncodingExceptionMessage = 'Nieprawidłowe kodowanie: {0}' + syslogProtocolExceptionMessage = 'Protokół Syslog może używać tylko RFC3164 lub RFC5424.' taskProcessDoesNotExistExceptionMessage = "Proces zadania '{0}' nie istnieje." scheduleProcessDoesNotExistExceptionMessage = "Proces harmonogramu '{0}' nie istnieje." definitionTagChangeNotAllowedExceptionMessage = 'Tag definicji dla Route nie może zostać zmieniony.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Funkcja '{0}' nie akceptuje tablicy jako wejścia potoku." unsupportedStreamCompressionEncodingExceptionMessage = 'Kodowanie kompresji strumienia nie jest obsługiwane: {0}' localEndpointConflictExceptionMessage = "Zarówno '{0}', jak i '{1}' są zdefiniowane jako lokalne punkty końcowe OpenAPI, ale na jedną definicję API dozwolony jest tylko jeden lokalny punkt końcowy." + deprecatedFunctionWarningMessage = "OSTRZEŻENIE: Funkcja '{0}' jest przestarzała i zostanie usunięta w przyszłych wersjach. Użyj funkcji '{1}' zamiast niej." serviceAlreadyRegisteredException = "Usługa '{0}' jest już zarejestrowana." serviceIsNotRegisteredException = "Usługa '{0}' nie jest zarejestrowana." serviceCommandFailedException = "Polecenie serwisu '{0}' nie powiodło się w serwisie '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "Reguła limitu szybkości o nazwie '{0}' nie istnieje." accessLimitRuleAlreadyExistsExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' już istnieje." accessLimitRuleDoesNotExistExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' nie istnieje." + loggerDoesNotExistExceptionMessage = "Logger '{0}' nie istnieje." missingKeyForAlgorithmExceptionMessage = 'Klucz {0} jest wymagany dla algorytmów {1} ({2}).' jwtIssuedInFutureExceptionMessage = "Znacznik czasu 'iat' (Issued At) w JWT jest ustawiony w przyszłości. Token nie jest jeszcze ważny." jwtInvalidIssuerExceptionMessage = "Pole 'iss' (Issuer) w JWT jest nieprawidłowe lub nieobecne. Oczekiwany wydawca: '{0}'." diff --git a/src/Locales/pt/Pode.psd1 b/src/Locales/pt/Pode.psd1 index a044485ee..752d5dda3 100644 --- a/src/Locales/pt/Pode.psd1 +++ b/src/Locales/pt/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "Um parâmetro chamado '{0}' não foi fornecido na solicitação ou não há dados disponíveis." cacheStorageNotFoundForSetExceptionMessage = "Armazenamento em cache com o nome '{0}' não encontrado ao tentar definir o item em cache '{1}'." methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: Já definido.' - errorLoggingAlreadyEnabledExceptionMessage = 'O registro de erros já está habilitado.' valueForUsingVariableNotFoundExceptionMessage = "Valor para '`$using:{0}' não pôde ser encontrado." rapidPdfDoesNotSupportOpenApi31ExceptionMessage = 'A ferramenta de documentos RapidPdf não suporta OpenAPI 3.1' oauth2ClientSecretRequiredExceptionMessage = 'OAuth2 requer um Client Secret quando não se usa PKCE.' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "O esquema '{0}' fornecido para o validador de autenticação '{1}' requer um ScriptBlock válido." sseFailedToBroadcastExceptionMessage = 'SSE falhou em transmitir devido ao nível de transmissão SSE definido para {0}: {1}.' adModuleWindowsOnlyExceptionMessage = 'O módulo Active Directory está disponível apenas no Windows.' - requestLoggingAlreadyEnabledExceptionMessage = 'O registro de solicitações já está habilitado.' invalidAccessControlMaxAgeDurationExceptionMessage = 'Duração inválida fornecida para Access-Control-Max-Age: {0}. Deve ser maior que 0.' openApiDefinitionAlreadyExistsExceptionMessage = 'A definição OpenAPI com o nome {0} já existe.' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag não pode ser usado dentro de um 'ScriptBlock' Select-PodeOADefinition." + loggingAlreadyEnabledExceptionMessage = "O registro '{0}' já foi habilitado." + invalidEncodingExceptionMessage = 'Codificação inválida: {0}' + syslogProtocolExceptionMessage = 'O protocolo Syslog só pode usar RFC3164 ou RFC5424.' taskProcessDoesNotExistExceptionMessage = "O processo da tarefa '{0}' não existe." scheduleProcessDoesNotExistExceptionMessage = "O processo do cronograma '{0}' não existe." definitionTagChangeNotAllowedExceptionMessage = 'A Tag de definição para uma Route não pode ser alterada.' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "A função '{0}' não aceita uma matriz como entrada de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'A codificação de compressão de fluxo não é suportada.' localEndpointConflictExceptionMessage = "Tanto '{0}' quanto '{1}' estão definidos como endpoints locais do OpenAPI, mas apenas um endpoint local é permitido por definição de API." + deprecatedFunctionWarningMessage = "AVISO: A função '{0}' está obsoleta e será removida em versões futuras. Por favor, use a função '{1}' em seu lugar." serviceAlreadyRegisteredException = "O serviço '{0}' já está registrado." serviceIsNotRegisteredException = "O serviço '{0}' não está registrado." serviceCommandFailedException = "O comando do serviço '{0}' falhou no serviço '{1}'." @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = "A regra de limite de taxa com o nome '{0}' não existe." accessLimitRuleAlreadyExistsExceptionMessage = "A regra de limite de acesso com o nome '{0}' já existe." accessLimitRuleDoesNotExistExceptionMessage = "A regra de limite de acesso com o nome '{0}' não existe." + loggerDoesNotExistExceptionMessage = "O logger '{0}' não existe." missingKeyForAlgorithmExceptionMessage = 'Uma chave {0} é necessária para os algoritmos {1} ({2}).' jwtIssuedInFutureExceptionMessage = "O carimbo de data/hora 'iat' (Emitido em) do JWT está definido para o futuro. O token ainda não é válido." jwtInvalidIssuerExceptionMessage = "A reivindicação 'iss' (Issuer) do JWT é inválida ou está ausente. Emissor esperado: '{0}'." diff --git a/src/Locales/zh/Pode.psd1 b/src/Locales/zh/Pode.psd1 index 8d953bd1d..90305fe01 100644 --- a/src/Locales/zh/Pode.psd1 +++ b/src/Locales/zh/Pode.psd1 @@ -153,7 +153,6 @@ parameterNotSuppliedInRequestExceptionMessage = "请求中未提供名为 '{0}' 的参数或没有可用数据。" cacheStorageNotFoundForSetExceptionMessage = "尝试设置缓存项 '{1}' 时,找不到名为 '{0}' 的缓存存储。" methodPathAlreadyDefinedExceptionMessage = '[{0}] {1}: 已经定义。' - errorLoggingAlreadyEnabledExceptionMessage = '错误日志记录已启用。' valueForUsingVariableNotFoundExceptionMessage = "未找到 '`$using:{0}' 的值。" rapidPdfDoesNotSupportOpenApi31ExceptionMessage = '文档工具 RapidPdf 不支持 OpenAPI 3.1' oauth2ClientSecretRequiredExceptionMessage = '不使用 PKCE 时, OAuth2 需要一个客户端密钥。' @@ -280,10 +279,12 @@ invalidSchemeForAuthValidatorExceptionMessage = "提供的 '{0}' 方案用于 '{1}' 身份验证验证器,需要一个有效的 ScriptBlock。" sseFailedToBroadcastExceptionMessage = '由于为{0}定义的SSE广播级别, SSE广播失败: {1}' adModuleWindowsOnlyExceptionMessage = '仅支持 Windows 的 Active Directory 模块。' - requestLoggingAlreadyEnabledExceptionMessage = '请求日志记录已启用。' invalidAccessControlMaxAgeDurationExceptionMessage = '提供的 Access-Control-Max-Age 时长无效:{0}。应大于 0。' openApiDefinitionAlreadyExistsExceptionMessage = '名为 {0} 的 OpenAPI 定义已存在。' renamePodeOADefinitionTagExceptionMessage = "Rename-PodeOADefinitionTag 不能在 Select-PodeOADefinition 'ScriptBlock' 内使用。" + loggingAlreadyEnabledExceptionMessage = "日志记录 '{0}' 已启用。" + invalidEncodingExceptionMessage = '无效的编码: {0}' + syslogProtocolExceptionMessage = 'Syslog 协议只能使用 RFC3164 或 RFC5424。' taskProcessDoesNotExistExceptionMessage = "任务进程 '{0}' 不存在。" scheduleProcessDoesNotExistExceptionMessage = "计划进程 '{0}' 不存在。" definitionTagChangeNotAllowedExceptionMessage = 'Route的定义标签无法更改。' @@ -291,6 +292,7 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "函数 '{0}' 不接受数组作为管道输入。" unsupportedStreamCompressionEncodingExceptionMessage = '不支持的流压缩编码: {0}' localEndpointConflictExceptionMessage = "'{0}' 和 '{1}' 都被定义为 OpenAPI 的本地端点,但每个 API 定义仅允许一个本地端点。" + deprecatedFunctionWarningMessage = "警告:函数 '{0}' 已被弃用,并将在未来版本中移除。请改用 '{1}' 函数。" serviceAlreadyRegisteredException = "服务 '{0}' 已经注册。" serviceIsNotRegisteredException = "服务 '{0}' 未注册。" serviceCommandFailedException = "服务命令 '{0}' 在服务 '{1}' 上失败。" @@ -333,6 +335,7 @@ rateLimitRuleDoesNotExistExceptionMessage = '速率限制规则不存在: {0}' accessLimitRuleAlreadyExistsExceptionMessage = '访问限制规则已存在: {0}' accessLimitRuleDoesNotExistExceptionMessage = '访问限制规则不存在: {0}' + loggerDoesNotExistExceptionMessage = "日志记录器 '{0}' 不存在。" missingKeyForAlgorithmExceptionMessage = 'Uma chave {0} é necessária para os algoritmos {1} ({2}).' jwtIssuedInFutureExceptionMessage = "JWT 的 'iat' (签发时间) 时间戳设置在未来。该令牌尚未生效。" jwtInvalidIssuerExceptionMessage = "JWT 的 'iss' (发行者) 声明无效或缺失。预期发行者: '{0}'。" diff --git a/src/Pode.psd1 b/src/Pode.psd1 index 4af2f0c7b..087786bf2 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -304,17 +304,39 @@ # logging 'New-PodeLoggingMethod', + 'New-PodeCustomLoggingMethod', + 'New-PodeEventViewerLoggingMethod', + 'New-PodeFileLoggingMethod', + 'New-PodeSyslogLoggingMethod', + 'New-PodeTerminalLoggingMethod', + 'New-PodeAwsLoggingMethod', + 'New-PodeAzureLoggingMethod', + 'New-PodeDatadogLoggingMethod', + 'New-PodeElasticsearchLoggingMethod', + 'New-PodeGoogleLoggingMethod', + 'New-PodeGraylogLoggingMethod', + 'New-PodeLogInsightLoggingMethod', + 'New-PodeSplunkLoggingMethod', 'Enable-PodeRequestLogging', 'Enable-PodeErrorLogging', + 'Enable-PodeDefaultLogging', + 'Disable-PodeRequestLogging', 'Disable-PodeErrorLogging', + 'Disable-PodeDefaultLogging', + 'Add-PodeLoggingMethod', + 'Remove-PodeLoggingMethod', 'Add-PodeLogger', 'Remove-PodeLogger', - 'Clear-PodeLoggers', + 'Clear-PodeLogger', 'Write-PodeErrorLog', 'Write-PodeLog', 'Protect-PodeLogItem', 'Use-PodeLogging', + 'Enable-PodeLog', + 'Disable-PodeLog', + 'Clear-PodeLogging', + 'Get-PodeLoggingLevel', # core 'Start-PodeServer', @@ -564,7 +586,8 @@ 'Enable-PodeOpenApiViewer', 'Enable-PodeOA', 'Get-PodeOpenApiDefinition', - 'New-PodeOASchemaProperty' + 'New-PodeOASchemaProperty', + 'Clear-PodeLoggers' ) # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. diff --git a/src/Private/Console.ps1 b/src/Private/Console.ps1 index 91cb4a442..a3cb52f3c 100644 --- a/src/Private/Console.ps1 +++ b/src/Private/Console.ps1 @@ -1335,6 +1335,7 @@ function Set-PodeConsoleOverrideConfiguration { $PodeContext.Server.Console.Quiet = $true $PodeContext.Server.Console.DisableConsoleInput = $true $PodeContext.Server.Console.DisableTermination = $true + $PodeContext.Server.Console.Daemon = $true } # Apply IIS-specific overrides diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index f6140392b..da977b2d1 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -1,5 +1,58 @@ using namespace Pode +<# +.SYNOPSIS + Initializes a new Pode context with various server configurations. + +.DESCRIPTION + This function creates and initializes a new Pode context object with server configurations, including threading, schedules, tasks, logging, and more. + It ensures that essential configurations are set, and it can run in different environments such as serverless or IIS. + +.PARAMETER ScriptBlock + The script block to be executed within the Pode context. + +.PARAMETER FilePath + The file path to the script block. + +.PARAMETER Threads + The number of threads to be used. Default is 1. + +.PARAMETER Interval + The interval for server operations. Default is 0. + +.PARAMETER ServerRoot + The root path for the server. + +.PARAMETER Name + The name of the server. If not provided, a random name will be generated. + +.PARAMETER ServerlessType + Specifies if the server is running in a serverless context. + +.PARAMETER StatusPageExceptions + Configuration for displaying exceptions on the status page. + +.PARAMETER ListenerType + The type of listener to be used by the server. + +.PARAMETER EnablePool + An array of pools to enable, such as 'timers', 'tasks', 'schedules', and 'websockets'. + +.PARAMETER DisableTermination + A switch to disable server termination. + +.PARAMETER Quiet + A switch to enable quiet mode, suppressing certain outputs. + +.PARAMETER EnableBreakpoints + A switch to enable debugging breakpoints. + +.PARAMETER Daemon + Configures the server to run as a daemon with minimal console interaction and output. + +.EXAMPLE + $context = New-PodeContext -ScriptBlock $script -FilePath 'path/to/file' -Threads 4 -ServerRoot 'path/to/root' +#> function New-PodeContext { [CmdletBinding()] param( @@ -55,6 +108,9 @@ function New-PodeContext { [string] $ConfigFile, + [switch] + $Daemon, + [hashtable] $Service, @@ -85,7 +141,6 @@ function New-PodeContext { Runspaces = $null RunspaceState = $null Tokens = @{} - LogsToProcess = $null Threading = @{} Server = @{} Metrics = @{} @@ -153,8 +208,11 @@ function New-PodeContext { # basic logging setup $ctx.Server.Logging = @{ - Enabled = $true - Types = @{} + Enabled = $true + Type = @{} + Masking = @{} + QueueLimit = 500 + Method = @{} } # set thread counts @@ -313,7 +371,7 @@ function New-PodeContext { $ctx.Server.IsHeroku = (!$isServerless -and (!(Test-PodeIsEmpty $env:PORT)) -and (!(Test-PodeIsEmpty $env:DYNO))) # Check if the current session is running in a console-like environment - if (Test-PodeHasConsole) { + if (Test-PodeHasConsole -and ! $Daemon) { try { if (! (Test-PodeIsISEHost)) { # If the session is not configured for quiet mode, modify console behavior @@ -333,7 +391,6 @@ function New-PodeContext { } } catch { - $_ | Write-PodeErrorLog # Console support is partial , configure the context for non-console behavior $ctx.Server.Console.DisableTermination = $true # Prevent termination $ctx.Server.Console.DisableConsoleInput = $true # Disable console input @@ -495,9 +552,6 @@ function New-PodeContext { # create new cancellation tokens $ctx.Tokens = Initialize-PodeCancellationToken - # requests that should be logged - $ctx.LogsToProcess = [System.Collections.ArrayList]::new() - # middleware that needs to run $ctx.Server.Middleware = @() $ctx.Server.BodyParsers = @{} @@ -521,6 +575,7 @@ function New-PodeContext { Gui = $null Tasks = $null Files = $null + Logs = $null Timers = $null Service = $null } @@ -636,6 +691,13 @@ function New-PodeRunspacePool { LastId = 0 } + # logs runspace - any log is running here + $PodeContext.RunspacePools.Logs = @{ + Pool = [runspacefactory]::CreateRunspacePool(1, 1, $PodeContext.RunspaceState, $Host) + State = 'Waiting' + LastId = 0 + } + # web runspace - if we have any http/s endpoints if (Test-PodeEndpointByProtocolType -Type Http) { $PodeContext.RunspacePools.Web = @{ @@ -901,7 +963,6 @@ function New-PodeStateContext { RunspacePools = $Context.RunspacePools Tokens = $Context.Tokens Metrics = $Context.Metrics - LogsToProcess = $Context.LogsToProcess Threading = $Context.Threading Server = $Context.Server } @@ -998,14 +1059,18 @@ function Set-PodeServerConfiguration { Files = @() } - # logging - $Context.Server.Logging = @{ - Enabled = (($null -eq $Configuration.Logging.Enable) -or [bool]$Configuration.Logging.Enable) - Masking = @{ - Patterns = (Remove-PodeEmptyItemsFromArray -Array @($Configuration.Logging.Masking.Patterns)) - Mask = (Protect-PodeValue -Value $Configuration.Logging.Masking.Mask -Default '********') + if ($Configuration.ContainsKey('Logging')) { + # logging + if ($Configuration.Logging.ContainsKey('Enable')) { + $Context.Server.Logging.Enabled = ([bool]$Configuration.Logging.Enable) + } + if ($Configuration.Logging.ContainsKey('Masking')) { + $Context.Server.Logging.Masking = @{ + Patterns = (Remove-PodeEmptyItemsFromArray -Array @($Configuration.Logging.Masking.Patterns)) + Mask = (Protect-PodeValue -Value $Configuration.Logging.Masking.Mask -Default '********') + } } - Types = @{} + $Context.Server.Logging.QueueLimit = (Protect-PodeValue -Value $Configuration.Logging.QueueLimit $Context.Server.Logging.QueueLimit) } # sockets diff --git a/src/Private/FileWatchers.ps1 b/src/Private/FileWatchers.ps1 index 97c590ad4..480a19dbf 100644 --- a/src/Private/FileWatchers.ps1 +++ b/src/Private/FileWatchers.ps1 @@ -13,7 +13,7 @@ function New-PodeFileWatcher { param() $watcher = [PodeWatcher]::new($PodeContext.Tokens.Cancellation.Token) $watcher.ErrorLoggingEnabled = (Test-PodeErrorLoggingEnabled) - $watcher.ErrorLoggingLevels = @(Get-PodeErrorLoggingLevel) + $watcher.ErrorLoggingLevels = If ( $listener.ErrorLoggingEnabled) { @(Get-PodeErrorLoggingLevel) } else { @() } return $watcher } @@ -168,4 +168,4 @@ function Start-PodeFileWatcherRunspace { } Add-PodeRunspace -Type Files -Name 'KeepAlive' -ScriptBlock $waitScript -Parameters @{ 'Watcher' = $watcher } -NoProfile -} +} \ No newline at end of file diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 9ebca78c7..a0ea1e31b 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -570,6 +570,9 @@ function Close-PodeServerInternal { # PodeContext doesn't exist return if ($null -eq $PodeContext) { return } try { + #Disable Logging before closing + Disable-PodeLog + # ensure the token is cancelled Write-Verbose 'Cancelling main cancellation token' Close-PodeCancellationTokenRequest -Type Cancellation, Terminate diff --git a/src/Private/Logging.ps1 b/src/Private/Logging.ps1 index 57e5cd128..4d6b40451 100644 --- a/src/Private/Logging.ps1 +++ b/src/Private/Logging.ps1 @@ -1,115 +1,378 @@ +using namespace Pode + +<# +.SYNOPSIS +Defines the method for writing log messages to the terminal. + +.DESCRIPTION +This internal function handles writing log messages to the terminal. +It checks if the server is in quiet mode and protects sensitive information before outputting the log messages. + +.PARAMETER item +The log item to be written to the terminal. + +.PARAMETER options +A hashtable containing options for the terminal logging method. + +.NOTES +This is an internal function and may change in future releases of Pode. +#> function Get-PodeLoggingTerminalMethod { return { - param($item, $options) + param($MethodId) if ($PodeContext.Server.Quiet) { return } - # check if it's an array from batching - if ($item -is [array]) { - $item = ($item -join [System.Environment]::NewLine) - } + $log = @{} + while (!(Test-PodeCancellationTokenRequest -Type Terminate)) { + Start-Sleep -Milliseconds 100 - # protect then write - $item = ($item | Protect-PodeLogItem) - $item.ToString() | Out-PodeHost + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + if ($null -ne $log) { + $Item = $log.Item + # check if it's an array from batching + if ($Item -is [array]) { + $Item = ($Item -join [System.Environment]::NewLine) + } + + # protect then write + $Item = ([pode.PodeLogger]::ProtectLogItem($Item, $PodeContext.Server.Logging.Masking)) #($Item | Protect-PodeLogItem) + $Item.ToString() | Out-PodeHost + } + } + } } } +<# +.SYNOPSIS + Defines the method for writing log messages to a file. +.DESCRIPTION + This internal function handles writing log messages to a file, managing file rotation based on size and date, and removing old log files beyond a specified retention period. + It includes error handling based on user-defined actions. + +.PARAMETER item + The log item to be written to the file. + +.PARAMETER options + A hashtable containing options for the file logging method including Path, Name, MaxDays, MaxSize, Date, FileId, and FailureAction. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> function Get-PodeLoggingFileMethod { return { - param($item, $options) + param($MethodId) - # check if it's an array from batching - if ($item -is [array]) { - $item = ($item -join [System.Environment]::NewLine) - } + $log = @{} + while (!(Test-PodeCancellationTokenRequest -Type Terminate)) { + Start-Sleep -Milliseconds 100 - # mask values - $item = ($item | Protect-PodeLogItem) + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + if ($null -ne $log) { - # variables - $date = [DateTime]::Now.ToString('yyyy-MM-dd') + try { + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem - # do we need to reset the fileId? - if ($options.Date -ine $date) { - $options.Date = $date - $options.FileId = 0 - } + # Variables + $date = [DateTime]::Now.ToString('yyyy-MM-dd') - # get the fileId - if ($options.FileId -eq 0) { - $path = [System.IO.Path]::Combine($options.Path, "$($options.Name)_$($date)_*.log") - $options.FileId = (@(Get-ChildItem -Path $path)).Length - if ($options.FileId -eq 0) { - $options.FileId = 1 - } - } + # Reset the fileId if the date has changed + if ($Options.Date -ine $date) { + $Options.Date = $date + $Options.FileId = 0 + } + + # Get the fileId if it hasn't been set + if ($Options.FileId -eq 0) { + $path = [System.IO.Path]::Combine($Options.Path, "$($Options.Name)_$($date)_*.log") + $Options.FileId = (@(Get-ChildItem -Path $path)).Length + if ($Options.FileId -eq 0) { + $Options.FileId = 1 + } + } + + $id = "$($Options.FileId)".PadLeft(3, '0') + + # Check if file size exceeds MaxSize and increment fileId if necessary + if ($Options.MaxSize -gt 0) { + $path = [System.IO.Path]::Combine($Options.Path, "$($Options.Name)_$($date)_$($id).log") + if ((Get-Item -Path $path -Force).Length -ge $Options.MaxSize) { + $Options.FileId++ + $id = "$($Options.FileId)".PadLeft(3, '0') + } + } + + # Get the file to write to + $path = [System.IO.Path]::Combine($Options.Path, "$($Options.Name)_$($date)_$($id).log") + + if ($Options.Format -eq 'Default') { + # Check if the item is an array from batching + if ($Item -is [array]) { + $Item = ($Item -join [System.Environment]::NewLine) + } - $id = "$($options.FileId)".PadLeft(3, '0') - if ($options.MaxSize -gt 0) { - $path = [System.IO.Path]::Combine($options.Path, "$($options.Name)_$($date)_$($id).log") - if ((Get-Item -Path $path -Force).Length -ge $options.MaxSize) { - $options.FileId++ - $id = "$($options.FileId)".PadLeft(3, '0') + # Mask values + $outString = ([pode.PodeLogger]::ProtectLogItem($Item, $PodeContext.Server.Logging.Masking)).ToString() + } + else { + if ($RawItem -is [array]) { + $tmpStrings = @() + foreach ($ritem in $RawItem) { + $tmpStrings += [pode.PodeFormat]::Syslog($RawItem, $Options, $PodeContext.Server.Logging.Masking) + } + $outString = $tmpStrings -join [System.Environment]::NewLine + + } + else { + $outString = [pode.PodeFormat]::Syslog($RawItem, $Options, $PodeContext.Server.Logging.Masking) + } + + } + # Write the item to the file + $outString | Out-File -FilePath $path -Encoding $Options.Encoding -Append -Force + + # Remove log files beyond the MaxDays retention period, ensuring this runs once a day + if (($Options.MaxDays -gt 0) -and ($Options.NextClearDown -le [DateTime]::Now.Date)) { + $date = [DateTime]::Now.Date.AddDays(-$Options.MaxDays) + + $null = Get-ChildItem -Path $options.Path -Filter "$($options.Name)_*.log" -Force | + Where-Object { $_.CreationTime -lt $date } | + Remove-Item -Force + + $Options.NextClearDown = [DateTime]::Now.Date.AddDays(1) + } + } + catch { + Invoke-PodeHandleFailure -Message "Failed to log a message: $_" -FailureAction $Options.FailureAction + } + } } } + } +} + + +<# +.SYNOPSIS + Handles the sending of log messages to a Syslog server using various transport protocols. + +.DESCRIPTION + This function defines the logic for sending log messages to a Syslog server using different transport protocols including UDP, TCP, TLS, Splunk, and VMware LogInsight. + It supports both RFC 3164 and RFC 5424 formats and includes error handling based on user-defined actions. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Get-PodeLoggingSysLogMethod { + return { + param($MethodId) - # get the file to write to - $path = [System.IO.Path]::Combine($options.Path, "$($options.Name)_$($date)_$($id).log") + $log = @{} + $socketCreated = $false + try { + while (!(Test-PodeCancellationTokenRequest -Type Terminate)) { + Start-Sleep -Milliseconds 100 - # write the item to the file - $item.ToString() | Out-File -FilePath $path -Encoding utf8 -Append -Force + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { - # if set, remove log files beyond days set (ensure this is only run once a day) - if (($options.MaxDays -gt 0) -and ($options.NextClearDown -le [DateTime]::Now.Date)) { - $date = [DateTime]::Now.Date.AddDays(-$options.MaxDays) + $Options = $log.Options + $RawItem = $log.RawItem - $null = Get-ChildItem -Path $options.Path -Filter "$($options.Name)_*.log" -Force | - Where-Object { $_.CreationTime -lt $date } | - Remove-Item -Force + if ($RawItem -isnot [array]) { + $RawItem = @($RawItem) + } - $options.NextClearDown = [DateTime]::Now.Date.AddDays(1) + # Create the socket if it hasn't been created already + if (!$socketCreated) { + switch ($Options.Transport.ToUpperInvariant()) { + 'UDP' { + $udpClient = [System.Net.Sockets.UdpClient]::new() + } + 'TCP' { + # Create a TCP client for non-secure communication + $tcpClient = [System.Net.Sockets.TcpClient]::new() + $tcpClient.Connect($Options.Server, $Options.Port) + $networkStream = $tcpClient.GetStream() + } + 'TLS' { + # Create a TCP client for secure communication + $tcpClient = [System.Net.Sockets.TcpClient]::new() + $tcpClient.Connect($Options.Server, $Options.Port) + + $sslStream = if ($Options.SkipCertificateCheck) { + [System.Net.Security.SslStream]::new($tcpClient.GetStream(), $false, { $true }) + } + else { + [System.Net.Security.SslStream]::new($tcpClient.GetStream(), $false) + } + + # Define the TLS protocol version + $tlsProtocol = if ($Options.TlsProtocols) { + $Options.TlsProtocols + } + else { + [System.Security.Authentication.SslProtocols]::Tls12 # Default to TLS 1.2 + } + + # Authenticate as client with specific TLS protocol + $sslStream.AuthenticateAsClient($Options.Server, $null, $tlsProtocol, $false) + } + default { + $udpClient = [System.Net.Sockets.UdpClient]::new() + } + } + $socketCreated = $true + } + + for ($i = 0; $i -lt $RawItem.Length; $i++) { + $fullSyslogMessage = [pode.PodeFormat]::Syslog($RawItem[$i], $Options, $PodeContext.Server.Logging.Masking) + + # Convert the message to a byte array + $byteMessage = $($Options.Encoding).GetBytes($fullSyslogMessage) + + # Determine the transport protocol and send the message + switch ($Options.Transport.ToUpperInvariant()) { + 'UDP' { + try { + # Send the message to the syslog server + $udpClient.Send($byteMessage, $byteMessage.Length, $Options.Server, $Options.Port) + } + catch { + Invoke-PodeHandleFailure -Message "Failed to send UDP message: $_" -FailureAction $Options.FailureAction + } + } + 'TCP' { + try { + # Send the message + $networkStream.Write($byteMessage, 0, $byteMessage.Length) + $networkStream.Flush() + } + catch { + Invoke-PodeHandleFailure -Message "Failed to send TCP message: $_" -FailureAction $Options.FailureAction + } + } + 'TLS' { + try { + # Send the message + $sslStream.Write($byteMessage) + $sslStream.Flush() + } + catch { + Invoke-PodeHandleFailure -Message "Failed to send secure TLS message: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + } + finally { + # Close the sockets and cleanup + switch ($Options.Transport.ToUpperInvariant()) { + 'UDP' { + # Close the UDP client + if ($udpClient) { + $udpClient.Close() + } + } + 'TCP' { + # Close the TCP client + if ($networkStream) { $networkStream.Close() } + if ($tcpClient) { $tcpClient.Close() } + } + 'TLS' { + # Close the TCP client + if ($sslStream) { $sslStream.Close() } + if ($tcpClient) { $tcpClient.Close() } + } + } + $socketCreated = $false } } } +<# +.SYNOPSIS +Defines the method for sending log messages to the Windows Event Viewer. + +.DESCRIPTION +This internal function handles the sending of log messages to the Windows Event Viewer, converting log levels and creating event log entries. It includes error handling based on user-defined actions. + +.NOTES +This is an internal function and may change in future releases of Pode. +#> function Get-PodeLoggingEventViewerMethod { return { - param($item, $options, $rawItem) + param($MethodId) - if ($item -isnot [array]) { - $item = @($item) - } + $log = @{} + while (!(Test-PodeCancellationTokenRequest -Type Terminate)) { + Start-Sleep -Milliseconds 100 - if ($rawItem -isnot [array]) { - $rawItem = @($rawItem) - } + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + if ($null -ne $log) { + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem - for ($i = 0; $i -lt $item.Length; $i++) { - # convert log level - info if no level present - $entryType = ConvertTo-PodeEventViewerLevel -Level $rawItem[$i].Level + # Ensure item and rawItem are arrays + if ($Item -isnot [array]) { + $Item = @($Item) + } - # create log instance - $entryInstance = [System.Diagnostics.EventInstance]::new($options.ID, 0, $entryType) + if ($RawItem -isnot [array]) { + $RawItem = @($RawItem) + } - # create event log - $entryLog = [System.Diagnostics.EventLog]::new() - $entryLog.Log = $options.LogName - $entryLog.Source = $options.Source + for ($i = 0; $i -lt $RawItem.Length; $i++) { + # Convert log level to Event Viewer entry type - default to 'Information' if no level present + $entryType = ConvertTo-PodeEventViewerLevel -Level $RawItem[$i].Level - try { - $message = ($item[$i] | Protect-PodeLogItem) - $entryLog.WriteEvent($entryInstance, $message) - } - catch { - $_ | Write-PodeErrorLog -Level Debug + # Create EventInstance for the log entry + $entryInstance = [System.Diagnostics.EventInstance]::new($Options.ID, 0, $entryType) + + # Create EventLog object and set the log name and source + $entryLog = [System.Diagnostics.EventLog]::new() + $entryLog.Log = $Options.LogName + $entryLog.Source = $Options.Source + + try { + # Mask values and write the event to the Event Viewer + $message = ([pode.PodeLogger]::ProtectLogItem($Item[$i], $PodeContext.Server.Logging.Masking)) + $entryLog.WriteEvent($entryInstance, $message) + } + catch { + Invoke-PodeHandleFailure -Message "Failed to write an Event Viewer message: $_" -FailureAction $Options.FailureAction + } + } + } } } } } +<# +.SYNOPSIS +Converts a log level string to a corresponding EventLogEntryType. + +.DESCRIPTION +This internal function converts a provided log level string to the corresponding `System.Diagnostics.EventLogEntryType` enumeration value. +It defaults to `Information` if the level is empty or unrecognized. + +.PARAMETER Level +The log level string to be converted (e.g., 'error', 'warning'). + +.RETURNS +Returns a `System.Diagnostics.EventLogEntryType` enumeration value corresponding to the provided log level. + +.NOTES +This is an internal function and may change in future releases of Pode. +#> function ConvertTo-PodeEventViewerLevel { param( [Parameter()] @@ -132,10 +395,26 @@ function ConvertTo-PodeEventViewerLevel { return [System.Diagnostics.EventLogEntryType]::Information } +<# +.SYNOPSIS +Gets the script block for a specified inbuilt logging type. + +.DESCRIPTION +This function returns a script block that formats log entries for a specified inbuilt logging type in Pode. The supported types are 'Errors', 'Requests', 'General', and 'Default'. Each type has its own formatting logic. + +.PARAMETER Type +The type of logging to get the script block for. Must be one of 'Errors', 'Requests', 'General', or 'Default'. + +.EXAMPLE +$script = Get-PodeLoggingInbuiltType -Type 'Requests' + +.EXAMPLE +$script = Get-PodeLoggingInbuiltType -Type 'Errors' +#> function Get-PodeLoggingInbuiltType { param( [Parameter(Mandatory = $true)] - [ValidateSet('Errors', 'Requests','service')] + [ValidateSet('Errors', 'Requests', 'General', 'Default', 'Listener')] [string] $Type ) @@ -144,55 +423,27 @@ function Get-PodeLoggingInbuiltType { 'requests' { $script = { param($item, $options) - - # just return the item if Raw is set - if ($options.Raw) { - return $item - } - - function sg($value) { - if ([string]::IsNullOrWhiteSpace($value)) { - return '-' - } - - return $value - } - - # build the url with http method - $url = "$(sg $item.Request.Method) $(sg $item.Request.Resource) $(sg $item.Request.Protocol)" - - # build and return the request row - return "$(sg $item.Host) $(sg $item.RfcUserIdentity) $(sg $item.User) [$(sg $item.Date)] `"$($url)`" $(sg $item.Response.StatusCode) $(sg $item.Response.Size) `"$(sg $item.Request.Referrer)`" `"$(sg $item.Request.Agent)`"" + return [Pode.PodeFormat]::RequestLog($item, $options) } } 'errors' { $script = { param($item, $options) + return [Pode.PodeFormat]::ErrorsLog($item, $options) + } + } + 'general' { + $script = { + param($item, $options) + return [Pode.PodeFormat]::GeneralLog($item, $options) + } + } - # do nothing if the error level isn't present - if (@($options.Levels) -inotcontains $item.Level) { - return - } - - # just return the item if Raw is set - if ($options.Raw) { - return $item - } - - # build the exception details - $row = @( - "Date: $($item.Date.ToString('yyyy-MM-dd HH:mm:ss'))", - "Level: $($item.Level)", - "ThreadId: $($item.ThreadId)", - "Server: $($item.Server)", - "Category: $($item.Category)", - "Message: $($item.Message)", - "StackTrace: $($item.StackTrace)" - ) - - # join the details and return - return "$($row -join "`n")`n" + 'Default' { + $script = { + param($item, $options) + return [Pode.PodeFormat]::GeneralLog($item, $options) } } 'service' { @@ -216,7 +467,8 @@ function Get-PodeLoggingInbuiltType { "ThreadId: $($item.ThreadId)", "Server: $($item.Server)", "Category: $($item.Category)", - "Message: $($item.Message)" + "Message: $($item.Message)", + "StackTrace: $($item.StackTrace)" ) # join the details and return @@ -228,14 +480,6 @@ function Get-PodeLoggingInbuiltType { return $script } -function Get-PodeRequestLoggingName { - return '__pode_log_requests__' -} - -function Get-PodeErrorLoggingName { - return '__pode_log_errors__' -} - <# .SYNOPSIS Retrieves a Pode logger by name. @@ -260,20 +504,75 @@ function Get-PodeLogger { [string] $Name ) + if (!$PodeContext.Server.Logging.Type.ContainsKey($Name)) { + throw $PodeLocale.loggerDoesNotExistExceptionMessage + } + return $PodeContext.Server.Logging.Type[$Name] +} + +<# +.SYNOPSIS +Tests if a specified logger is a standard logger. + +.DESCRIPTION +This function checks if the specified logger is configured as a standard logger in the Pode context. - return $PodeContext.Server.Logging.Types[$Name] +.PARAMETER Name +The name of the logger to test. + +.OUTPUTS +[bool] - Returns $true if the logger is a standard logger, otherwise $false. + +.EXAMPLE +Test-PodeStandardLogger -Name 'MyLogger' +#> +function Test-PodeStandardLogger { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + if (!$PodeContext.Server.Logging.Type.ContainsKey($Name)) { + throw $PodeLocale.loggerDoesNotExistExceptionMessage + } + # Check if the specified logger is a standard logger + return $PodeContext.Server.Logging.Type[$Name].Standard } +<# +.SYNOPSIS +Determines if a specified logger is enabled. + +.DESCRIPTION +This function checks if a specified logger is enabled by verifying if logging is enabled in the Pode context and if the logger exists within the logging configuration. + +.PARAMETER Name +The name of the logger to check. + +.EXAMPLE +Test-PodeLoggerEnabled -Name 'MyLogger' + +# This command checks if the logger named 'MyLogger' is enabled. +#> function Test-PodeLoggerEnabled { param( - [Parameter(Mandatory = $true)] [string] $Name ) - return ($PodeContext.Server.Logging.Enabled -and $PodeContext.Server.Logging.Types.ContainsKey($Name)) + if ($Name) { + # Check if logging is enabled and if the specified logger exists + return ([pode.PodeLogger]::Enabled -and $PodeContext -and $PodeContext.Server.Logging.Type.ContainsKey($Name)) + } + else { + # Check if logging is generally enabled + return [pode.PodeLogger]::Enabled + } } + <# .SYNOPSIS Gets the error logging levels for Pode. @@ -291,62 +590,115 @@ function Test-PodeLoggerEnabled { This is an internal function and may change in future releases of Pode. #> function Get-PodeErrorLoggingLevel { - return (Get-PodeLogger -Name (Get-PodeErrorLoggingName)).Arguments.Levels + return (Get-PodeLogger -Name ([Pode.PodeLogger]::ErrorLogName)).Arguments.Levels } +<# +.SYNOPSIS +Tests if error logging is enabled. + +.DESCRIPTION +This function checks if error logging is enabled by testing the logger configuration for error logging. + +.EXAMPLE +Test-PodeErrorLoggingEnabled +#> function Test-PodeErrorLoggingEnabled { - return (Test-PodeLoggerEnabled -Name (Get-PodeErrorLoggingName)) + # Get the name of the error logger and test if it is enabled + return (Test-PodeLoggerEnabled -Name ([Pode.PodeLogger]::ErrorLogName)) } +<# +.SYNOPSIS +Tests if request logging is enabled. + +.DESCRIPTION +This function checks if request logging is enabled by testing the logger configuration for request logging. + +.EXAMPLE +Test-PodeRequestLoggingEnabled +#> function Test-PodeRequestLoggingEnabled { - return (Test-PodeLoggerEnabled -Name (Get-PodeRequestLoggingName)) + # Get the name of the request logger and test if it is enabled + return (Test-PodeLoggerEnabled -Name ([Pode.PodeLogger]::RequestLogName)) } + +<# +.SYNOPSIS +Writes a log entry for a Pode web request. + +.DESCRIPTION +This function writes a log entry for a Pode web request. It logs details about the request and response, including method, resource, status code, and user information. The log entry is enqueued for processing if logging is enabled. + +.PARAMETER Request +The Pode web request object. + +.PARAMETER Response +The Pode web response object. + +.PARAMETER Path +The path of the request. + +.EXAMPLE +Write-PodeRequestLog -Request $webEvent.Request -Response $webEvent.Response -Path $webEvent.Path +#> function Write-PodeRequestLog { param( [Parameter(Mandatory = $true)] + [PodeHttpRequest] $Request, [Parameter(Mandatory = $true)] + [PodeResponse] $Response, [Parameter()] [string] $Path ) - - # do nothing if logging is disabled, or request logging isn't setup - $name = Get-PodeRequestLoggingName + # Do nothing if logging is disabled, or request logging isn't set up + $name = [Pode.PodeLogger]::RequestLogName if (!(Test-PodeLoggerEnabled -Name $name)) { return } - # build a request object + # Determine the current date and time, respecting the AsUTC setting + if ($PodeContext.Server.Logging.Type[$Name].Method.Arguments.AsUTC) { + $date = [datetime]::UtcNow + } + else { + $date = [datetime]::Now + } + + # Build a request object $item = @{ Host = $Request.RemoteEndPoint.Address.IPAddressToString RfcUserIdentity = '-' User = '-' - Date = [DateTime]::Now.ToString('dd/MMM/yyyy:HH:mm:ss zzz') + Date = $date Request = @{ Method = $Request.HttpMethod.ToUpperInvariant() Resource = $Path Protocol = "HTTP/$($Request.ProtocolVersion)" Referrer = $Request.UrlReferrer Agent = $Request.UserAgent + Query = ($Request.url -split '\?')[1] } Response = @{ StatusCode = $Response.StatusCode StatusDescription = $Response.StatusDescription Size = '-' } + Level = 'info' } - # set size if >0 + # Set size if >0 if ($Response.ContentLength64 -gt 0) { $item.Response.Size = $Response.ContentLength64 } - # set username - dot spaces + # Set username - dot spaces if (Test-PodeAuthUser -IgnoreSession) { $userProps = (Get-PodeLogger -Name $name).Properties.Username.Split('.') @@ -360,13 +712,27 @@ function Write-PodeRequestLog { } } - # add the item to be processed - $null = $PodeContext.LogsToProcess.Add(@{ + # Add the item to be processed + $null = [Pode.PodeLogger]::Enqueue(@{ Name = $name Item = $item }) } + +<# +.SYNOPSIS +Adds request logging endware to a Pode web event. + +.DESCRIPTION +This function adds endware to a Pode web event for logging request and response details. It checks if request logging is enabled and configured before attaching the logging logic to the web event's end handler. + +.PARAMETER WebEvent +The Pode web event to which the logging endware will be added. + +.EXAMPLE +Add-PodeRequestLogEndware -WebEvent $webEvent +#> function Add-PodeRequestLogEndware { param( [Parameter(Mandatory = $true)] @@ -374,13 +740,13 @@ function Add-PodeRequestLogEndware { $WebEvent ) - # do nothing if logging is disabled, or request logging isn't setup - $name = Get-PodeRequestLoggingName + # Do nothing if logging is disabled, or request logging isn't set up + $name = [Pode.PodeLogger]::RequestLogName if (!(Test-PodeLoggerEnabled -Name $name)) { return } - # add the request logging endware + # Add the request logging endware $WebEvent.OnEnd += @{ Logic = { Write-PodeRequestLog -Request $WebEvent.Request -Response $WebEvent.Response -Path $WebEvent.Path @@ -388,87 +754,135 @@ function Add-PodeRequestLogEndware { } } +<# +.SYNOPSIS +Tests if any loggers are configured or if logging is enabled. + +.DESCRIPTION +This function checks if any loggers are configured or if logging is enabled within the Pode context. It returns a boolean value indicating the presence of configured loggers or the status of logging. + +.EXAMPLE +Test-PodeLoggersExist +#> function Test-PodeLoggersExist { - if (($null -eq $PodeContext.Server.Logging) -or ($null -eq $PodeContext.Server.Logging.Types)) { + # Check if the logging context or logging types are null + if (($null -eq $PodeContext.Server.Logging) -or ($null -eq $PodeContext.Server.Logging.Type)) { return $false } - return (($PodeContext.Server.Logging.Types.Count -gt 0) -or ($PodeContext.Server.Logging.Enabled)) + # Return true if there are any logging types configured or if logging is enabled + return (($PodeContext.Server.Logging.Type.Count -gt 0) -or ($PodeContext.Server.Logging.Enabled)) } -function Start-PodeLoggingRunspace { - # skip if there are no loggers configured, or logging is disabled +<# +.SYNOPSIS +Starts the Pode logger dispatcher which processes and dispatches log entries. + +.DESCRIPTION +This function initializes and starts a logger dispatcher runspace that processes log entries from a queue and dispatches them to the appropriate logging methods. It handles batching of log entries and ensures that log entries are processed in a timely manner. + +.EXAMPLE +Start-PodeLoggerDispatcher +#> +function Start-PodeLoggerDispatcher { + # Skip if there are no loggers configured, or logging is disabled if (!(Test-PodeLoggersExist)) { return } - $script = { + $scriptBlock = { + + $log = @{} + # Waits for the Pode server to fully start before proceeding with further operations. + Wait-PodeCancellationTokenRequest -Type Start try { while (!(Test-PodeCancellationTokenRequest -Type Terminate)) { - # Check for suspension token and wait for the debugger to reset if active - Test-PodeSuspensionToken - try { - # if there are no logs to process, just sleep for a few seconds - but after checking the batch - if ($PodeContext.LogsToProcess.Count -eq 0) { - Test-PodeLoggerBatch - Start-Sleep -Seconds 5 - continue + # Check if the log queue has reached its limit + if ([Pode.PodeLogger]::Count -ge $PodeContext.Server.Logging.QueueLimit) { + Invoke-PodeHandleFailure -Message "Reached the log Queue Limit of $($PodeContext.Server.Logging.QueueLimit)" -FailureAction $logger.Method.Arguments.FailureAction } - # safely pop off the first log from the array - $log = (Lock-PodeObject -Return -Object $PodeContext.LogsToProcess -ScriptBlock { - $log = $PodeContext.LogsToProcess[0] - $null = $PodeContext.LogsToProcess.RemoveAt(0) - return $log - }) + # Try to dequeue a log entry from the queue + if ( [Pode.PodeLogger]::TryDequeue([ref]$log)) { + # If the log is null, check batch then sleep and skip + if ($null -eq $log) { + Start-Sleep -Milliseconds 100 + continue + } + if ($log.Name -eq [pode.PodeLogger]::ListenerLogName) { - # run the log item through the appropriate method - $logger = Get-PodeLogger -Name $log.Name - $now = [datetime]::Now + if ($log.Item -is [System.Exception]) { + Write-PodeErrorLog -Exception $log.Item -Level 'Error' -ThreadId $log.Item.ThreadId + } + else { + Write-PodeLog -Message $log.Item.Message -ThreadId $log.Item.ThreadId -Tag 'Listener' -Level $log.Item.Level - # if the log is null, check batch then sleep and skip - if ($null -eq $log) { - Start-Sleep -Milliseconds 100 - continue - } + } + continue + } - # convert to log item into a writable format - $rawItems = $log.Item - $_args = @($log.Item) + @($logger.Arguments) - $result = @(Invoke-PodeScriptBlock -ScriptBlock $logger.ScriptBlock -Arguments $_args -UsingVariables $logger.UsingVariables -Return -Splat) - - # check batching - $batch = $logger.Method.Batch - if ($batch.Size -gt 1) { - # add current item to batch - $batch.Items += $result - $batch.RawItems += $log.Item - $batch.LastUpdate = $now - - # if the current amount of items matches the batch, write - $result = $null - if ($batch.Items.Length -ge $batch.Size) { - $result = $batch.Items - $rawItems = $batch.RawItems + # Run the log item through the appropriate method + $logger = $PodeContext.Server.Logging.Type[$log.Name] + $now = [datetime]::Now + + # Convert the log item into a writable format + $rawItem = $log.Item + $_args = @($log.Item) + @($logger.Arguments) + + $item = @(Invoke-PodeScriptBlock -ScriptBlock $logger.ScriptBlock -Arguments $_args -UsingVariables $logger.UsingVariables -Return -Splat) + + # Check batching + $batch = $logger.Method.Batch + if ($batch.Size -gt 1) { + # Add current item to batch + $batch.Items += $item + $batch.RawItems += $log.Item + $batch.LastUpdate = $now + + # If the current amount of items matches the batch size, write + $item = $null + if ($batch.Items.Length -ge $batch.Size) { + $item = $batch.Items + $rawItem = $batch.RawItems + } + + # If we're writing, reset the items + if ($null -ne $item) { + $batch.Items = @() + $batch.RawItems = @() + } } - # if we're writing, reset the items - if ($null -ne $result) { - $batch.Items = @() - $batch.RawItems = @() + # Send the writable log item off to the log writer + if ($null -ne $item) { + foreach ($method in $logger.Method) { + if ($method.NoRunspace) { + # Legacy for custom methods + # $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Logging.Method[$method.Id].ScriptBlock -Arguments $_args -UsingVariables $method.UsingVariables -Splat + $_args = @(, $item) + @($method.Arguments) + @(, $rawItem) + $null = Invoke-PodeScriptBlock -ScriptBlock $logger.Method.ScriptBlock -Arguments $_args -UsingVariables $logger.Method.UsingVariables -Splat + } + else { + $_args = @{ + Item = $item + Options = $method.Arguments + RawItem = $rawItem + } + $PodeContext.Server.Logging.Method[$method.Id].Queue.Enqueue($_args) + } + } } - } - # send the writable log item off to the log writer - if ($null -ne $result) { - $_args = @(, $result) + @($logger.Method.Arguments) + @(, $rawItems) - $null = Invoke-PodeScriptBlock -ScriptBlock $logger.Method.ScriptBlock -Arguments $_args -UsingVariables $logger.Method.UsingVariables -Splat + # Small sleep to lower CPU usage + Start-Sleep -Milliseconds 100 + } + else { + # Check the logger batch + Test-PodeLoggerBatch + Start-Sleep -Seconds 5 } - - # small sleep to lower cpu usage - Start-Sleep -Milliseconds 100 } catch { $_ | Write-PodeErrorLog @@ -482,9 +896,25 @@ function Start-PodeLoggingRunspace { $_ | Write-PodeErrorLog throw $_.Exception } + + } + + # Retrieve unique method IDs + $uniqueMethodIds = ($PodeContext.Server.Logging.Type.values.Method.Id | Select-Object -Unique) + if ($uniqueMethodIds.Count -gt 0) { + # Set maximum runspaces for the logs pool + if ($PodeContext.RunspacePools['logs'].Pool.SetMaxRunspaces($uniqueMethodIds.Count + 1)) { + foreach ($methodId in $uniqueMethodIds) { + if ($null -ne $PodeContext.Server.Logging.Method[$methodId]) { + $PodeContext.Server.Logging.Method[$methodId].Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + $PodeContext.Server.Logging.Method[$methodId].Runspace = Add-PodeRunspace -PassThru -Type Logs -ScriptBlock $PodeContext.Server.Logging.Method[$methodId].ScriptBlock -Parameters @{ MethodId = $methodId } -Name 'Method' | Out-Null + } + } + } } - Add-PodeRunspace -Type Main -Name 'Logging' -ScriptBlock $script + # Add the logger dispatcher runspace + Add-PodeRunspace -Type Logs -ScriptBlock $scriptBlock -Name 'Dispatcher' } <# @@ -501,7 +931,7 @@ function Test-PodeLoggerBatch { $now = [datetime]::Now # check each logger, and see if its batch needs to be written - foreach ($logger in $PodeContext.Server.Logging.Types.Values) { + foreach ($logger in $PodeContext.Server.Logging.Type.Values) { $batch = $logger.Method.Batch if (($batch.Size -gt 1) -and ($batch.Items.Length -gt 0) -and ($batch.Timeout -gt 0) ` -and ($null -ne $batch.LastUpdate) -and ($batch.LastUpdate.AddSeconds($batch.Timeout) -le $now) @@ -517,3 +947,221 @@ function Test-PodeLoggerBatch { } } } + +<# +.SYNOPSIS + Creates a new log batch information object. + +.DESCRIPTION + The `New-PodeLogBatchInfo` function initializes and returns a hashtable that contains the details of a log batch, + including a unique batch identifier, size, timeout, and placeholders for items to be logged. + +.OUTPUTS + [hashtable] + Returns a hashtable with the following keys: + - `Id`: A unique identifier for the log batch, generated using `New-PodeGuid`. + - `Size`: The number of log items to be batched. + - `Timeout`: The timeout (in seconds) for sending log items if a new log isn't received. + - `LastUpdate`: Initially set to `$null`, this tracks the last time the batch was updated. + - `Items`: An empty array to hold formatted log items. + - `RawItems`: An empty array to hold unformatted/raw log items. + +.EXAMPLE + $logBatch = New-PodeLogBatchInfo -Batch 10 -BatchTimeout 30 + + This creates a new log batch with a size of 10 items and a timeout of 30 seconds before the batch is processed. + +.NOTES + This function is used for batching log items before they are processed. The size and timeout determine + how many items or how much time can pass before a batch of logs is processed. + + This is an internal function and may change in future releases of Pode. +#> + +function New-PodeLogBatchInfo { + # batch details + return @{ + Id = New-PodeGuid + Size = $Batch + Timeout = $BatchTimeout + LastUpdate = $null + Items = @() + RawItems = @() + } +} + +<# +.SYNOPSIS + Tests whether a given date format string is valid. + +.DESCRIPTION + The `Test-PodeDateFormat` function checks if a provided date format string can successfully format and parse a date. + It uses the current date and time to validate the format. If the format is valid, it returns `$true`. + If the format is invalid, it returns `$false`. + +.PARAMETER DateFormat + The date format string to be tested. This can be any custom date format supported by .NET. + +.EXAMPLE + Test-PodeDateFormat -DateFormat 'yyyy-MM-dd' + + This command checks if the 'yyyy-MM-dd' date format is valid and returns `$true` if it is, or `$false` if it isn't. + +.EXAMPLE + Test-PodeDateFormat -DateFormat 'invalidFormat' + + This command tests the string 'invalidFormat' as a date format and returns `$false` since it's not a valid format. + +.OUTPUTS + [bool] + Returns `$true` if the provided date format string is valid, otherwise returns `$false`. + +.NOTES + This function attempts to format and then parse the current date using the provided date format string. + If an exception is thrown during the process, the format is deemed invalid. + + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeDateFormat { + param ( + [string]$DateFormat + ) + + $sampleDate = [DateTime]::Now + try { + # Try to format the sample date using the provided format + $formattedDate = $sampleDate.ToString($DateFormat) + + # Try to parse the formatted date back to a DateTime object using the same format + [DateTime]::ParseExact($formattedDate, $DateFormat, $null) + + # If no exceptions are thrown, the format is valid + return $true + } + catch { + # If an exception is thrown, the format is invalid + return $false + } +} + + + +function Enable-PodeLoggingInternal { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true )] + [hashtable[]] + $Method, + + [Parameter(Mandatory = $true)] + [ValidateSet('Errors', 'Default' )] + [string] + $Type, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Verbose', 'Debug', '*')] + [string[]] + $Levels , + + [switch] + $Raw + ) + switch ($Type.ToLowerInvariant()) { + + 'errors' { + $name = [Pode.PodeLogger]::ErrorLogName + $scriptBlock = (Get-PodeLoggingInbuiltType -Type Errors) + } + 'Default' { + $name = [Pode.PodeLogger]::DefaultLogName + $scriptBlock = (Get-PodeLoggingInbuiltType -Type Default) + } + } + # error if it's already enabled + if ($PodeContext.Server.Logging.Type.Contains($Name)) { + # Error Logging has already been enabled + throw ($PodeLocale.loggingAlreadyEnabledExceptionMessage -f 'Error') + } + # all errors? + if ($Levels -contains '*') { + $Levels = @('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Verbose', 'Debug') + } + + # add the error logger + $PodeContext.Server.Logging.Type[$name] = @{ + Method = $Method + ScriptBlock = $scriptBlock + Arguments = @{ + Raw = $Raw.IsPresent + Levels = $Levels + DataFormat = $Method.Arguments.DataFormat + } + Standard = $true + } + + $Method.ForEach({ $_.Logger += $name }) + + return $PodeContext.Server.Logging.Type[$name] + +} + +<# +.SYNOPSIS + Handles failure actions based on the provided parameters. + +.DESCRIPTION + This function processes failure scenarios by either ignoring the failure, + reporting it on the console and continuing, or reporting it on the console + and halting the server. The behavior is controlled by the 'FailureAction' + parameter. + +.PARAMETER Message + The message to be displayed in case of a failure. + +.PARAMETER FailureAction + Specifies the action to take in case of failure. Accepted values are: + - 'Ignore': Do nothing and continue execution. + - 'Report': Display the message on the console and continue execution. + - 'Halt': Display the message on the console and halt the server. + +.EXAMPLE + Invoke-PodeHandleFailure -Message "An error occurred." -FailureAction "Report" + This will display the message "An error occurred." on the console and continue execution. + +.EXAMPLE + Invoke-PodeHandleFailure -Message "Critical failure." -FailureAction "Halt" + This will display the message "Critical failure." on the console and halt the server. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Invoke-PodeHandleFailure { + param( + [Parameter(Mandatory = $true)] + [string] + $Message, + + [Parameter(Mandatory = $true)] + [string] + [ValidateSet('Ignore', 'Report', 'Halt' )] + $FailureAction + + ) + switch ($FailureAction.ToLowerInvariant()) { + 'ignore' { + # Do nothing and continue + } + 'report' { + # Report on console and continue + Write-PodeHost $Message -ForegroundColor Yellow + } + 'halt' { + # Report on console and halt + Write-PodeHost $Message -ForegroundColor Red + Write-PodeHost 'Pode Server shutting down.' -ForegroundColor Red + Close-PodeServer + } + } +} + diff --git a/src/Private/PodeServer.ps1 b/src/Private/PodeServer.ps1 index b8e6453c6..2eface74f 100644 --- a/src/Private/PodeServer.ps1 +++ b/src/Private/PodeServer.ps1 @@ -88,7 +88,7 @@ function Start-PodeWebServer { # Create the listener $listener = & $("New-Pode$($PodeContext.Server.ListenerType)Listener") -CancellationToken $PodeContext.Tokens.Cancellation.Token $listener.ErrorLoggingEnabled = (Test-PodeErrorLoggingEnabled) - $listener.ErrorLoggingLevels = @(Get-PodeErrorLoggingLevel) + $listener.ErrorLoggingLevels = If ( $listener.ErrorLoggingEnabled) { @(Get-PodeErrorLoggingLevel) } else { @() } $listener.RequestTimeout = $PodeContext.Server.Request.Timeout $listener.RequestBodySize = $PodeContext.Server.Request.BodySize $listener.ShowServerDetails = [bool]$PodeContext.Server.Security.ServerDetails diff --git a/src/Private/Server.ps1 b/src/Private/Server.ps1 index 9573ba06e..02db9fa00 100644 --- a/src/Private/Server.ps1 +++ b/src/Private/Server.ps1 @@ -98,7 +98,7 @@ function Start-PodeInternalServer { if (!$PodeContext.Server.IsServerless) { # start runspace for loggers - Start-PodeLoggingRunspace + Start-PodeLoggerDispatcher # start runspace for schedules Start-PodeScheduleRunspace @@ -257,7 +257,7 @@ function Restart-PodeInternalServer { $PodeContext.Server.Views.Clear() $PodeContext.Timers.Items.Clear() - $PodeContext.Server.Logging.Types.Clear() + Clear-PodeLogging # clear schedules $PodeContext.Schedules.Items.Clear() diff --git a/src/Private/SmtpServer.ps1 b/src/Private/SmtpServer.ps1 index e6ce3a762..a8becafab 100644 --- a/src/Private/SmtpServer.ps1 +++ b/src/Private/SmtpServer.ps1 @@ -67,7 +67,7 @@ function Start-PodeSmtpServer { # create the listener $listener = [PodeListener]::new($PodeContext.Tokens.Cancellation.Token) $listener.ErrorLoggingEnabled = (Test-PodeErrorLoggingEnabled) - $listener.ErrorLoggingLevels = @(Get-PodeErrorLoggingLevel) + $listener.ErrorLoggingLevels = If ( $listener.ErrorLoggingEnabled) { @(Get-PodeErrorLoggingLevel) } else { @() } $listener.RequestTimeout = $PodeContext.Server.Request.Timeout $listener.RequestBodySize = $PodeContext.Server.Request.BodySize diff --git a/src/Private/TcpServer.ps1 b/src/Private/TcpServer.ps1 index 12b829d1b..a5c63dd8c 100644 --- a/src/Private/TcpServer.ps1 +++ b/src/Private/TcpServer.ps1 @@ -61,7 +61,7 @@ function Start-PodeTcpServer { # create the listener $listener = [PodeListener]::new($PodeContext.Tokens.Cancellation.Token) $listener.ErrorLoggingEnabled = (Test-PodeErrorLoggingEnabled) - $listener.ErrorLoggingLevels = @(Get-PodeErrorLoggingLevel) + $listener.ErrorLoggingLevels = If ( $listener.ErrorLoggingEnabled) { @(Get-PodeErrorLoggingLevel) } else { @() } $listener.RequestTimeout = $PodeContext.Server.Request.Timeout $listener.RequestBodySize = $PodeContext.Server.Request.BodySize diff --git a/src/Private/WebSockets.ps1 b/src/Private/WebSockets.ps1 index cb7c92928..fb1377ce6 100644 --- a/src/Private/WebSockets.ps1 +++ b/src/Private/WebSockets.ps1 @@ -22,7 +22,7 @@ function New-PodeWebSocketReceiver { try { $receiver = [PodeReceiver]::new($PodeContext.Tokens.Cancellation.Token) $receiver.ErrorLoggingEnabled = (Test-PodeErrorLoggingEnabled) - $receiver.ErrorLoggingLevels = @(Get-PodeErrorLoggingLevel) + $receiver.ErrorLoggingLevels = If ( $listener.ErrorLoggingEnabled) { @(Get-PodeErrorLoggingLevel) } else { @() } $PodeContext.Server.WebSockets.Receiver = $receiver $PodeContext.Receivers += $receiver } diff --git a/src/Public/Core.ps1 b/src/Public/Core.ps1 index a0157c17a..e759c73c2 100644 --- a/src/Public/Core.ps1 +++ b/src/Public/Core.ps1 @@ -308,6 +308,7 @@ function Start-PodeServer { EnableBreakpoints = $EnableBreakpoints IgnoreServerConfig = $IgnoreServerConfig ConfigFile = $ConfigFile + Daemon = $Daemon Service = $monitorService ApplicationName = $ApplicationName } @@ -331,6 +332,10 @@ function Start-PodeServer { # Call the function using splatting Set-PodeConsoleOverrideConfiguration @ConfigParameters + if ($PodeContext.Server.Logging.Enabled) { + Enable-PodeLog + } + # start the file monitor for interally restarting Start-PodeFileMonitor diff --git a/src/Public/EndWare.ps1 b/src/Public/EndWare.ps1 new file mode 100644 index 000000000..191f22a2b --- /dev/null +++ b/src/Public/EndWare.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS +Adds a ScriptBlock as Endware to run at the end of each web Request. + +.DESCRIPTION +Adds a ScriptBlock as Endware to run at the end of each web Request. + +.PARAMETER ScriptBlock +The ScriptBlock to add. It will be supplied the current web event. + +.PARAMETER ArgumentList +An array of arguments to supply to the Endware's ScriptBlock. + +.EXAMPLE +Add-PodeEndware -ScriptBlock { /* logic */ } +#> +function Add-PodeEndware { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [scriptblock] + $ScriptBlock, + + [Parameter()] + [object[]] + $ArgumentList + ) + + # check for scoped vars + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + + # add the scriptblock to array of endware that needs to be run + $PodeContext.Server.Endware += @{ + Logic = $ScriptBlock + UsingVariables = $usingVars + Arguments = $ArgumentList + } +} + +<# +.SYNOPSIS +Automatically loads endware ps1 files + +.DESCRIPTION +Automatically loads endware ps1 files from either a /endware folder, or a custom folder. Saves space dot-sourcing them all one-by-one. + +.PARAMETER Path +Optional Path to a folder containing ps1 files, can be relative or literal. + +.EXAMPLE +Use-PodeEndware + +.EXAMPLE +Use-PodeEndware -Path './endware' +#> +function Use-PodeEndware { + [CmdletBinding()] + param( + [Parameter()] + [string] + $Path + ) + + Use-PodeFolder -Path $Path -DefaultPath 'endware' +} \ No newline at end of file diff --git a/src/Public/Logging.ps1 b/src/Public/Logging.ps1 index 727a1fcd3..98741d1c0 100644 --- a/src/Public/Logging.ps1 +++ b/src/Public/Logging.ps1 @@ -1,15 +1,30 @@ + + + <# .SYNOPSIS -Create a new method of outputting logs. + Creates a new method for outputting logs (Deprecated). .DESCRIPTION -Create a new method of outputting logs. + This function has been deprecated and will be removed in future versions. It creates various logging methods such as Terminal, File, Event Viewer, and Custom logging. + Please use the appropriate new functions for each logging method: + - `New-PodeTerminalLoggingMethod` for terminal logging. + - `New-PodeFileLoggingMethod` for file logging. + - `New-PodeEventViewerLoggingMethod` for Event Viewer logging. + - `New-PodeCustomLoggingMethod` for custom logging. .PARAMETER Terminal -If supplied, will use the inbuilt Terminal logging output method. + Deprecated. Please use `New-PodeTerminalLoggingMethod` instead. + If supplied, will use the inbuilt Terminal logging output method. .PARAMETER File -If supplied, will use the inbuilt File logging output method. + Deprecated. Please use `New-PodeFileLoggingMethod` instead. + If supplied, will use the inbuilt File logging output method. + +.PARAMETER EventViewer + Deprecated. Please use `New-PodeEventViewerLoggingMethod` instead. + If supplied, will use the inbuilt Event Viewer logging output method. + .PARAMETER Path The File Path of where to store the logs. @@ -148,106 +163,88 @@ function New-PodeLoggingMethod { $ArgumentList ) - # batch details - $batchInfo = @{ - Size = $Batch - Timeout = $BatchTimeout - LastUpdate = $null - Items = @() - RawItems = @() - } # return info on appropriate logging type switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { 'terminal' { - return @{ - ScriptBlock = (Get-PodeLoggingTerminalMethod) - Batch = $batchInfo - Arguments = @{} - } + # WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use '{0}' function instead. + Write-PodeHost ($PodeLocale.deprecatedFunctionWarningMessage -f 'New-PodeLoggingMethod', 'New-PodeTerminalLoggingMethod') -ForegroundColor Yellow + + return New-PodeTerminalLoggingMethod } 'file' { - $Path = (Protect-PodeValue -Value $Path -Default './logs') - $Path = (Get-PodeRelativePath -Path $Path -JoinRoot) - $null = New-Item -Path $Path -ItemType Directory -Force - - return @{ - ScriptBlock = (Get-PodeLoggingFileMethod) - Batch = $batchInfo - Arguments = @{ - Name = $Name - Path = $Path - MaxDays = $MaxDays - MaxSize = $MaxSize - FileId = 0 - Date = $null - NextClearDown = [datetime]::Now.Date - } + # WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use '{0}' function instead. + Write-PodeHost ($PodeLocale.deprecatedFunctionWarningMessage -f 'New-PodeLoggingMethod', 'New-PodeFileLoggingMethod') -ForegroundColor Yellow + + $fileParams = @{ + Path = $PSBoundParameters['Path'] + Name = $PSBoundParameters['Name'] + MaxDays = $PSBoundParameters['MaxDays'] + MaxSize = $PSBoundParameters['MaxSize'] } + return New-PodeFileLoggingMethod @fileParams } 'eventviewer' { - # only windows - if (!(Test-PodeIsWindows)) { - # Event Viewer logging only supported on Windows - throw ($PodeLocale.eventViewerLoggingSupportedOnWindowsOnlyExceptionMessage) - } + # WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use '{0}' function instead. + Write-PodeHost ($PodeLocale.deprecatedFunctionWarningMessage -f 'New-PodeLoggingMethod', 'New-PodeEventViewerLoggingMethod') -ForegroundColor Yellow - # create source - if (![System.Diagnostics.EventLog]::SourceExists($Source)) { - $null = [System.Diagnostics.EventLog]::CreateEventSource($Source, $EventLogName) - } - - return @{ - ScriptBlock = (Get-PodeLoggingEventViewerMethod) - Batch = $batchInfo - Arguments = @{ - LogName = $EventLogName - Source = $Source - ID = $EventID - } + $eventViewerParams = @{ + EventLogName = $PSBoundParameters['EventLogName'] + Source = $PSBoundParameters['Source'] + EventID = $PSBoundParameters['EventID'] } + return New-PodeEventViewerLoggingMethod @eventViewerParams } 'custom' { + # WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use '{0}' function instead. + Write-PodeHost ($PodeLocale.deprecatedFunctionWarningMessage -f 'New-PodeLoggingMethod', 'New-PodeCustomLoggingMethod') -ForegroundColor Yellow + + # Convert scoped variables for the script block if not using a runspace $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState return @{ + Id = New-PodeGuid ScriptBlock = $ScriptBlock UsingVariables = $usingVars - Batch = $batchInfo + Batch = New-PodeLogBatchInfo + Logger = @() Arguments = $ArgumentList + NoRunspace = $true } } } } - <# .SYNOPSIS -Enables Request Logging using a supplied output method. + Enables Request Logging using a supplied output method. .DESCRIPTION -Enables Request Logging using a supplied output method. + Enables Request Logging using a supplied output method. .PARAMETER Method -The Method to use for output the log entry (From New-PodeLoggingMethod). + The Method to use for output the log entry (From New-PodeLoggingMethod). .PARAMETER UsernameProperty -An optional property path within the $WebEvent.Auth.User object for the user's Username. (Default: Username). + An optional property path within the $WebEvent.Auth.User object for the user's Username. (Default: Username). .PARAMETER Raw -If supplied, the log item returned will be the raw Request item as a hashtable and not a string (for Custom methods). + If supplied, the log item returned will be the raw Request item as a hashtable and not a string. + +.PARAMETER LogFormat + The format to use for the log entries. Options are: Extended, Common, Combined, JSON (Default: Combined). .EXAMPLE -New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging #> function Enable-PodeRequestLogging { [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [hashtable] + [hashtable[]] $Method, [Parameter()] @@ -255,126 +252,318 @@ function Enable-PodeRequestLogging { $UsernameProperty, [switch] - $Raw + $Raw, + + [string] + [ValidateSet('Extended', 'Common', 'Combined', 'JSON' )] + $LogFormat = 'Combined' ) + begin { + $pipelineMethods = @() - Test-PodeIsServerless -FunctionName 'Enable-PodeRequestLogging' -ThrowError + Test-PodeIsServerless -FunctionName 'Enable-PodeRequestLogging' -ThrowError - $name = Get-PodeRequestLoggingName + $name = [Pode.PodeLogger]::RequestLogName - # error if it's already enabled - if ($PodeContext.Server.Logging.Types.Contains($name)) { - # Request Logging has already been enabled - throw ($PodeLocale.requestLoggingAlreadyEnabledExceptionMessage) - } + # error if it's already enabled + if ($PodeContext.Server.Logging.Type.Contains($name)) { + # Request Logging has already been enabled + throw ($PodeLocale.loggingAlreadyEnabledExceptionMessage -f 'Request') + } - # ensure the Method contains a scriptblock - if (Test-PodeIsEmpty $Method.ScriptBlock) { - # The supplied output Method for Request Logging requires a valid ScriptBlock - throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f 'Request') + # username property + if ([string]::IsNullOrWhiteSpace($UsernameProperty)) { + $UsernameProperty = 'Username' + } } - - # username property - if ([string]::IsNullOrWhiteSpace($UsernameProperty)) { - $UsernameProperty = 'Username' + process { + # ensure the Method contains a scriptblock + if ((! $PodeContext.Server.Logging.Method.ContainsKey($_.Id)) -and (! $_.ContainsKey('Scriptblock'))) { + # The supplied output Method for Request Logging requires a valid ScriptBlock + throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f 'Request') + } + $pipelineMethods += $_ } + end { - # add the request logger - $PodeContext.Server.Logging.Types[$name] = @{ - Method = $Method - ScriptBlock = (Get-PodeLoggingInbuiltType -Type Requests) - Properties = @{ - Username = $UsernameProperty + if ($pipelineMethods.Count -gt 1) { + $Method = $pipelineMethods } - Arguments = @{ - Raw = $Raw + + # add the request logger + $PodeContext.Server.Logging.Type[$name] = @{ + Method = $Method + ScriptBlock = (Get-PodeLoggingInbuiltType -Type Requests) + Properties = @{ + Username = $UsernameProperty + } + Arguments = @{ + Raw = $Raw.IsPresent + DataFormat = $Method.Arguments.DataFormat + LogFormat = $LogFormat + } + Standard = $true } + + $Method.ForEach({ $_.Logger += $name }) } } + <# .SYNOPSIS -Disables Request Logging. + Enables Error Logging using a supplied output method. .DESCRIPTION -Disables Request Logging. + Enables Error Logging using a supplied output method. + +.PARAMETER Method + The Method to use for output the log entry (From New-PodeLoggingMethod). + +.PARAMETER Levels + The Levels of errors that should be logged (default is Error). + +.PARAMETER Raw + If supplied, the log item returned will be the raw Error item as a hashtable and not a string (for Custom methods). + +.PARAMETER DisableDefaultLog + If supplied, the error logs will NOT be duplicated to the default logging method. .EXAMPLE -Disable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging #> -function Disable-PodeRequestLogging { +function Enable-PodeErrorLogging { [CmdletBinding()] - param() + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [hashtable[]] + $Method, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Verbose', 'Debug', '*')] + [string[]] + $Levels = @('Error'), + + [switch] + $Raw, + + [switch] + $DisableDefaultLog + ) + + begin { + $pipelineMethods = @() + } + + process { + # ensure the Method contains a scriptblock + if ((! $PodeContext.Server.Logging.Method.ContainsKey($_.Id)) -and (! $_.ContainsKey('Scriptblock'))) { + # The supplied output Method for Error Logging requires a valid ScriptBlock + throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f 'Error') + } + $pipelineMethods += $_ + } + + end { + + if ($pipelineMethods.Count -gt 1) { + $Method = $pipelineMethods + } - Remove-PodeLogger -Name (Get-PodeRequestLoggingName) + $logging = Enable-PodeLoggingInternal -Method $Method -Type Errors -Levels $Levels -Raw:$Raw + + + $logging.DuplicateToDefaultLog = ! $DisableDefaultLog.IsPresent + $Method.ForEach({ $_.Logger += $name }) + } } <# .SYNOPSIS -Enables Error Logging using a supplied output method. + Enables Default Logging using a supplied output method. .DESCRIPTION -Enables Error Logging using a supplied output method. + Enables Default Logging using a supplied output method. .PARAMETER Method -The Method to use for output the log entry (From New-PodeLoggingMethod). + The Method to use for output the log entry (From New-PodeLoggingMethod). .PARAMETER Levels -The Levels of errors that should be logged (default is Error). + The Levels that should be logged (default is 'Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational'). .PARAMETER Raw -If supplied, the log item returned will be the raw Error item as a hashtable and not a string (for Custom methods). + If supplied, the log item returned will be the raw Default item as a hashtable and not a string (for Custom methods). .EXAMPLE -New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + New-PodeLoggingMethod -Terminal | Enable-PodeDefaultLogging #> -function Enable-PodeErrorLogging { +function Enable-PodeDefaultLogging { [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [hashtable] + [hashtable[]] $Method, [Parameter()] [ValidateNotNullOrEmpty()] - [ValidateSet('Error', 'Warning', 'Informational', 'Verbose', 'Debug', '*')] + [ValidateSet('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Verbose', 'Debug', '*')] [string[]] - $Levels = @('Error'), + $Levels = @('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational'), [switch] $Raw ) - $name = Get-PodeErrorLoggingName + begin { + $pipelineMethods = @() + } + + process { + # ensure the Method contains a scriptblock + if ((! $PodeContext.Server.Logging.Method.ContainsKey($_.Id)) -and (! $_.ContainsKey('Scriptblock'))) { + # The supplied output Method for Error Logging requires a valid ScriptBlock + throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f 'Error') + } + $pipelineMethods += $_ + } + + end { - # error if it's already enabled - if ($PodeContext.Server.Logging.Types.Contains($name)) { - # Error Logging has already been enabled - throw ($PodeLocale.errorLoggingAlreadyEnabledExceptionMessage) + if ($pipelineMethods.Count -gt 1) { + $Method = $pipelineMethods + } + Enable-PodeLoggingInternal -Method $Method -Type Default -Levels $Levels -Raw:$Raw + $Method.ForEach({ $_.Logger += $name }) } +} +<# +.SYNOPSIS + Enables a generic logging method in Pode. + +.DESCRIPTION + This function enables a generic logging method in Pode, allowing logs to be written based on the defined method and log levels. It ensures the method is not already enabled and validates the provided script block. + +.PARAMETER Method + The hashtable defining the logging method, including the ScriptBlock for log output. + +.PARAMETER Levels + An array of log levels to be enabled for the logging method (Default: 'Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Info', 'Verbose', 'Debug'). + +.PARAMETER Name + The name of the logging method to be enabled. + +.PARAMETER Raw + If set, the raw log data will be included in the logging output. + +.EXAMPLE + $method = New-PodeLoggingMethod -syslog -Server 127.0.0.1 -Transport UDP + $method | Add-PodeLoggingMethod -Name "mysyslog" +#> +function Add-PodeLoggingMethod { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [hashtable[]] + $Method, + + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Verbose', 'Debug', '*')] + [string[]] + $Levels = @('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational'), + + [switch] + $Raw + ) + begin { + $pipelineMethods = @() + # error if it's already enabled + if ($PodeContext.Server.Logging.Type.Contains($Name)) { + throw ($PodeLocale.loggingAlreadyEnabledExceptionMessage -f $Name) + } + + if ($Levels -contains '*') { + $Levels = @('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Info', 'Verbose', 'Debug') + } - # ensure the Method contains a scriptblock - if (Test-PodeIsEmpty $Method.ScriptBlock) { - # The supplied output Method for Error Logging requires a valid ScriptBlock - throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f 'Error') } - # all errors? - if ($Levels -contains '*') { - $Levels = @('Error', 'Warning', 'Informational', 'Verbose', 'Debug') + process { + # ensure the Method contains a scriptblock + if ((! $PodeContext.Server.Logging.Method.ContainsKey($_.Id)) -and (! $_.ContainsKey('Scriptblock'))) { + # The supplied output Method for the '{0}' Logging method requires a valid ScriptBlock. + throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f $Name) + } + $pipelineMethods += $_ } + end { - # add the error logger - $PodeContext.Server.Logging.Types[$name] = @{ - Method = $Method - ScriptBlock = (Get-PodeLoggingInbuiltType -Type Errors) - Arguments = @{ - Raw = $Raw - Levels = $Levels + if ($pipelineMethods.Count -gt 1) { + $Method = $pipelineMethods } + + # add the error logger + $PodeContext.Server.Logging.Type[$Name] = @{ + Method = $Method + ScriptBlock = (Get-PodeLoggingInbuiltType -Type General) + Arguments = @{ + Raw = $Raw.IsPresent + Levels = $Levels + DataFormat = $Method.Arguments.DataFormat + } + Standard = $true + } + + $Method.ForEach({ $_.Logger += $Name }) } } +<# +.SYNOPSIS + Disables Request Logging. + +.DESCRIPTION + Disables Request Logging. + +.EXAMPLE + Disable-PodeRequestLogging +#> +function Disable-PodeRequestLogging { + [CmdletBinding()] + param() + + Remove-PodeLogger -Name ([Pode.PodeLogger]::RequestLogName) +} + +<# +.SYNOPSIS + Disables a generic logging method in Pode. + +.DESCRIPTION + This function disables a generic logging method in Pode. + +.PARAMETER Name + The name of the logging method to be disable. + +.EXAMPLE + Remove-PodeLoggingMethod -Name 'TestLog' +#> +function Remove-PodeLoggingMethod { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + Remove-PodeLogger -Name $Name +} + <# .SYNOPSIS Disables Error Logging. @@ -389,30 +578,50 @@ function Disable-PodeErrorLogging { [CmdletBinding()] param() - Remove-PodeLogger -Name (Get-PodeErrorLoggingName) + Remove-PodeLogger -Name ([Pode.PodeLogger]::ErrorLogName) + +} + + +<# +.SYNOPSIS +Disables Default Logging. + +.DESCRIPTION +Disables Default Logging. + +.EXAMPLE +Disable-PodeDefaultLogging +#> +function Disable-PodeDefaultLogging { + [CmdletBinding()] + param() + + Remove-PodeLogger -Name ([Pode.PodeLogger]::DefaultLogName) + } <# .SYNOPSIS -Adds a custom Logging method for parsing custom log items. + Adds a custom Logging method for parsing custom log items. .DESCRIPTION -Adds a custom Logging method for parsing custom log items. + Adds a custom Logging method for parsing custom log items. .PARAMETER Name -A unique Name for the Logging method. + A unique Name for the Logging method. .PARAMETER Method -The Method to use for output the log entry (From New-PodeLoggingMethod). + The Method to use for output the log entry (From New-PodeLoggingMethod). .PARAMETER ScriptBlock -The ScriptBlock defining logic that transforms an item, and returns it for outputting. + The ScriptBlock defining logic that transforms an item, and returns it for outputting. .PARAMETER ArgumentList -An array of arguments to supply to the Custom Logger's ScriptBlock. + An array of arguments to supply to the Custom Logger's ScriptBlock. .EXAMPLE -New-PodeLoggingMethod -Terminal | Add-PodeLogger -Name 'Main' -ScriptBlock { /* logic */ } + New-PodeLoggingMethod -Terminal | Add-PodeLogger -Name 'Default' -ScriptBlock { /* logic */ } #> function Add-PodeLogger { [CmdletBinding()] @@ -442,42 +651,57 @@ function Add-PodeLogger { $ArgumentList ) - # ensure the name doesn't already exist - if ($PodeContext.Server.Logging.Types.ContainsKey($Name)) { - # Logging method already defined - throw ($PodeLocale.loggingMethodAlreadyDefinedExceptionMessage -f $Name) + Begin { + $pipelineItemCount = 0 } - # ensure the Method contains a scriptblock - if (Test-PodeIsEmpty $Method.ScriptBlock) { - # The supplied output Method for the Logging method requires a valid ScriptBlock - throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f $Name) + Process { + $pipelineItemCount++ } - # check for scoped vars - $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + End { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + + # ensure the name doesn't already exist + if ($PodeContext.Server.Logging.Type.ContainsKey($Name)) { + # Logging method already defined + throw ($PodeLocale.loggingMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # ensure the Method contains a scriptblock + if (Test-PodeIsEmpty $Method.ScriptBlock) { + # The supplied output Method for the Logging method requires a valid ScriptBlock + throw ($PodeLocale.loggingMethodRequiresValidScriptBlockExceptionMessage -f $Name) + } + + # check for scoped vars + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState - # add logging method to server - $PodeContext.Server.Logging.Types[$Name] = @{ - Method = $Method - ScriptBlock = $ScriptBlock - UsingVariables = $usingVars - Arguments = $ArgumentList + # add logging method to server + $PodeContext.Server.Logging.Type[$Name] = @{ + Method = $Method + ScriptBlock = $ScriptBlock + UsingVariables = $usingVars + Arguments = $ArgumentList + } } } <# .SYNOPSIS -Removes a configured Logging method. + Removes a configured Logging method. .DESCRIPTION -Removes a configured Logging method. + Removes a configured Logging method by its name. + This function handles the removal of the logging method and ensures that any associated runspaces and script blocks are properly disposed of if they are no longer in use. .PARAMETER Name -The Name of the Logging method. + The Name of the Logging method. .EXAMPLE -Remove-PodeLogger -Name 'LogName' + Remove-PodeLogger -Name 'LogName' #> function Remove-PodeLogger { [CmdletBinding()] @@ -486,8 +710,47 @@ function Remove-PodeLogger { [string] $Name ) + Process { + + # Check if the specified logging type exists + if ($PodeContext.Server.Logging.Type.Contains($Name)) { + # Retrieve the method associated with the logging type + $method = $PodeContext.Server.Logging.Type[$Name].Method + # If it's not a legacy method remove the runspace + if (! $method.NoRunspace) { + # Remove the logger name from the method's logger collection + if ($method.Logger.Count -eq 1) { + $method.Logger = @() + } + else { + $method.Logger = $method.Logger | Where-Object { $_ -ne $Name } + } + + # Check if there are no more loggers associated with the method + if ($method.Logger.Count -eq 0) { + # If the method's runspace is still active, stop and dispose of it + if ($PodeContext.Server.Logging.Method.ContainsKey($method.Id)) { + $PodeContext.Server.Logging.Method[$method.Id].Runspace.Pipeline.Stop() + $PodeContext.Server.Logging.Method[$method.Id].Runspace.Pipeline.Dispose() + + # Decrease the maximum runspaces for the 'logs' pool if applicable + $maxRunspaces = $PodeContext.RunspacePools['logs'].Pool.GetMaxRunspaces + if ($maxRunspaces -gt 1) { + $PodeContext.RunspacePools['logs'].Pool.SetMaxRunspaces($maxRunspaces - 1) + } + # Remove the method's script block if it exists + $PodeContext.Server.Logging.Method.Remove($method.Id) + } + } + } - $null = $PodeContext.Server.Logging.Types.Remove($Name) + # Finally, remove the logging type from the Types collection + $null = $PodeContext.Server.Logging.Type.Remove($Name) + } + else { + throw $PodeLocale.loggerDoesNotExistExceptionMessage + } + } } <# @@ -498,165 +761,362 @@ Clears all Logging methods that have been configured. Clears all Logging methods that have been configured. .EXAMPLE -Clear-PodeLoggers +Clear-PodeLogger #> -function Clear-PodeLoggers { - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] +function Clear-PodeLogger { [CmdletBinding()] param() - $PodeContext.Server.Logging.Types.Clear() + $PodeContext.Server.Logging.Type.Clear() +} + +# Create the alias for back compatibility +if (!(Test-Path Alias:Clear-PodeLoggers)) { + New-Alias Clear-PodeLoggers -Value Clear-PodeLogger } <# .SYNOPSIS -Writes and Exception or ErrorRecord using the inbuilt error logging. + Logs an Exception, ErrorRecord, or a custom error message using Pode's built-in logging mechanism. .DESCRIPTION -Writes and Exception or ErrorRecord using the inbuilt error logging. + This function logs exceptions, error records, or custom error messages with optional error categories and levels. It can also log inner exceptions and associate the error with a specific thread ID. Error levels can be set, and inner exceptions can be checked for more detailed logging. .PARAMETER Exception -An Exception to write. + The exception object to log. This is used when logging caught exceptions. .PARAMETER ErrorRecord -An ErrorRecord to write. + The error record to log. This is used when handling errors through PowerShell's error handling mechanism. .PARAMETER Level -The Level of the error being logged. + The logging level for the error. Supported levels are: Error, Warning, Informational, Verbose, Debug (Default: Error). .PARAMETER CheckInnerException -If supplied, any exceptions are check for inner exceptions. If one is present, this is also logged. + If specified, logs any inner exceptions associated with the provided exception. + +.PARAMETER ThreadId + The ID of the thread where the error occurred. If not specified, the current thread's ID is used. + +.PARAMETER Tag + A string that identifies the source application, service, or process generating the log message. + The tag helps distinguish log messages from different sources, making it easier to filter and analyze logs. Default is '-'. + +.PARAMETER SuppressDefaultLog + A switch to suppress writing the error to the default log. .EXAMPLE -try { /* logic */ } catch { $_ | Write-PodeErrorLog } + try { + # Some operation + } catch { + $_ | Write-PodeErrorLog + } .EXAMPLE -[System.Exception]::new('error message') | Write-PodeErrorLog + [System.Exception]::new('Custom error message') | Write-PodeErrorLog -CheckInnerException + #> + function Write-PodeErrorLog { [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Exception')] - [System.Exception] - $Exception, + [System.Exception] $Exception, - [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Error')] - [System.Management.Automation.ErrorRecord] - $ErrorRecord, + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ErrorRecord')] + [System.Management.Automation.ErrorRecord] $ErrorRecord, [Parameter()] [ValidateNotNullOrEmpty()] - [ValidateSet('Error', 'Warning', 'Informational', 'Verbose', 'Debug')] - [string] - $Level = 'Error', + [ValidateSet('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Info', 'Verbose', 'Debug' )] + [string] $Level = 'Error', [Parameter(ParameterSetName = 'Exception')] - [switch] - $CheckInnerException - ) + [switch] $CheckInnerException, - # do nothing if logging is disabled, or error logging isn't setup - $name = Get-PodeErrorLoggingName - if (!(Test-PodeLoggerEnabled -Name $name)) { - return - } + [Parameter()] + [int] $ThreadId, - # do nothing if the error level isn't present - $levels = @(Get-PodeErrorLoggingLevel) - if ($levels -inotcontains $Level) { - return - } + [string] + $Tag = '-', - # build error object for what we need - switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { - 'exception' { - $item = @{ - Category = $Exception.Source - Message = $Exception.Message - StackTrace = $Exception.StackTrace - } - } + [Parameter()] + [switch] + $SuppressDefaultLog + ) - 'error' { - $item = @{ - Category = $ErrorRecord.CategoryInfo.ToString() - Message = $ErrorRecord.Exception.Message - StackTrace = $ErrorRecord.ScriptStackTrace + Process { + $name = [Pode.PodeLogger]::ErrorLogName + if (Test-PodeLoggerEnabled -Name $name) { + Write-PodeLog @PSBoundParameters -name $name -SuppressErrorLog + if ($PodeContext.Server.Logging.Type[$name].DuplicateToDefaultLog -and ! $SuppressDefaultLog) { + Write-PodeLog @PSBoundParameters -name ([Pode.PodeLogger]::DefaultLogName) -SuppressErrorLog } } } - - # add general info - $item['Server'] = $PodeContext.Server.ComputerName - $item['Level'] = $Level - $item['Date'] = [datetime]::Now - $item['ThreadId'] = [int]$ThreadId - - # add the item to be processed - $null = $PodeContext.LogsToProcess.Add(@{ - Name = $name - Item = $item - }) - - # for exceptions, check the inner exception - if ($CheckInnerException -and ($null -ne $Exception.InnerException) -and ![string]::IsNullOrWhiteSpace($Exception.InnerException.Message)) { - $Exception.InnerException | Write-PodeErrorLog - } } <# .SYNOPSIS -Write an object to a configured custom Logging method. + Writes an object, exception, or custom message to a configured custom or built-in logging method. .DESCRIPTION -Write an object to a configured custom Logging method. + This function writes an object, custom log message, or exception to a logging method in Pode. + It supports both custom and built-in logging methods, allowing structured logging with different log levels, messages, tags, and additional details like thread ID. + The logging method can be used to write errors, warnings, and informational logs in a structured manner, depending on the log level and source of the log. + Optionally, it can suppress reporting of errors to the error log if the same error is logged. .PARAMETER Name -The Name of the Logging method. + The name of the logging method (e.g., 'Console', 'File', 'Syslog'). .PARAMETER InputObject -The Object to write. + The object to write to the logging method. This is the default parameter set. + +.PARAMETER Level + The log level for the custom logging method (Default: 'Informational'). Log levels include 'Informational', 'Warning', 'Error', etc. + +.PARAMETER Message + The log message for the custom logging method. Required for custom logging. + +.PARAMETER Tag + A string that identifies the source application, service, or process generating the log message. + The tag helps distinguish log messages from different sources, making it easier to filter and analyze logs. Default is '-'. + +.PARAMETER ThreadId + The ID of the thread where the log entry is generated. If not specified, the current thread ID will be used. + +.PARAMETER Exception + An exception object to log. Required for the 'Exception' parameter set. + +.PARAMETER ErrorRecord + The error record to log. This is used when handling errors through PowerShell's error handling mechanism. + +.PARAMETER Category + The category of the custom error message (Default: NotSpecified). + +.PARAMETER CheckInnerException + If specified, any inner exceptions of the provided exception are also logged. + +.PARAMETER SuppressErrorLog + A switch to suppress writing the error to the error log if it has already been logged by this function. Useful to prevent duplicate error logging. + +.EXAMPLE + $object | Write-PodeLog -Name 'LogName' + +.EXAMPLE + Write-PodeLog -Name 'CustomLog' -Level 'Error' -Message 'An error occurred.' .EXAMPLE -$object | Write-PodeLog -Name 'LogName' + try { + # Some code that throws an exception + } catch { + Write-PodeLog -Name 'Syslog' -Exception $_ -SuppressErrorLog + } #> function Write-PodeLog { - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = 'Message')] param( - [Parameter(Mandatory = $true)] + [Parameter()] [string] $Name, - [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [object] - $InputObject + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'InputObject')] + [psobject] + $InputObject, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Exception')] + [System.Exception] + $Exception, + + [Parameter(ParameterSetName = 'Exception')] + [switch] + $CheckInnerException, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ErrorRecord')] + [System.Management.Automation.ErrorRecord] $ErrorRecord, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('Error', 'Emergency', 'Alert', 'Critical', 'Warning', 'Notice', 'Informational', 'Info', 'Verbose', 'Debug')] + [string] + $Level, + + [Parameter( Mandatory = $true, ParameterSetName = 'Message')] + [string] + $Message, + + [Parameter(ParameterSetName = 'ErrorRecord')] + [Parameter(ParameterSetName = 'Message')] + [Parameter(ParameterSetName = 'Exception')] + [Parameter()] + [string] + $Tag, + + [Parameter(ParameterSetName = 'InputObject')] + [Parameter(ParameterSetName = 'Message')] + [System.Management.Automation.ErrorCategory] $Category = [System.Management.Automation.ErrorCategory]::NotSpecified, + + [Parameter()] + [int] + $ThreadId, + + [Parameter()] + [switch] + $SuppressErrorLog + ) + begin { + if ($null -eq $PodeContext.Server.Logging) { + Write-Debug 'Pode not yet initialised' + return + } + + if (!$Name) { + $Name = [Pode.PodeLogger]::DefaultLogName + } + + if (!$PodeContext.Server.Logging.Type.ContainsKey($Name)) { + throw $PodeLocale.loggerDoesNotExistExceptionMessage + } + # Get the configured log method. + $log = $PodeContext.Server.Logging.Type[$Name] - # do nothing if logging is disabled, or logger isn't setup - if (!(Test-PodeLoggerEnabled -Name $Name)) { - return } + Process { + # Define the log item based on the selected parameter set. + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'inputobject' { + if (!$Level) { $Level = 'Informational' } # Default to Informational. + if ( @(Get-PodeLoggingLevel -Name $Name) -inotcontains $Level) { return } # If the level is not configured, use the + + $logItem = @{ + Name = $Name + Item = $InputObject + Level = $Level + } + break + } + 'message' { + if (!$Level) { $Level = 'Informational' } # Default to Informational. + if ( @(Get-PodeLoggingLevel -Name $Name) -inotcontains $Level) { return } # If the log level is not configured, return. + + $logItem = @{ + Name = $Name + Item = @{ + Category = $Category.ToString() + Level = $Level + Message = $Message + Tag = $Tag + } + } + break + } + 'exception' { + if (!$Level) { $Level = 'Error' } # Default to Error. + if ( @(Get-PodeLoggingLevel -Name $Name) -inotcontains $Level) { return } # If the level is not supported, return. + + $logItem = @{ + Name = $Name + Item = @{ + Level = $Level + Message = $Exception.Message + Tag = $Tag + } + } + break + } + 'errorrecord' { + if (!$Level) { $Level = 'Error' } # Default to Error. + if ( @(Get-PodeLoggingLevel -Name $Name) -inotcontains $Level) { return } # If the level is not supported, return. + + $logItem = @{ + Name = $Name + Item = @{ + Level = $Level + Message = $ErrorRecord.Exception.Message + Tag = $Tag + } + } + break + } + } + if ($log.Standard) { + # Add server details to the log item. + $logItem.Item.Server = $PodeContext.Server.ComputerName + + # Add the current date and time (UTC or local) to the log item. + $logItem.Item.Date = if ($log.Method.Arguments.AsUTC) { [datetime]::UtcNow } else { [datetime]::Now } + + # Set the thread ID if provided, otherwise use the current thread ID. + $logItem.Item.ThreadId = if ($ThreadId) { $ThreadId } else { [System.Threading.Thread]::CurrentThread.ManagedThreadId } + + # If error logging is not suppressed, log errors or exceptions. + if ((! $SuppressErrorLog.IsPresent) -and (Test-PodeErrorLoggingEnabled)) { + if ($PSCmdlet.ParameterSetName.ToLowerInvariant() -eq 'exception') { + [Pode.PodeLogger]::Enqueue( @{ + Name = [Pode.PodeLogger]::ErrorLogName + Item = @{ + Server = $logItem.Item.Server + Level = $Level + Date = $(if ($PodeContext.Server.Logging.Type[$Name].Method.Arguments.AsUTC) { $logItem.Item.Date.ToUniversalTime() }else { $logItem.Item.Date.ToLocaltime() }) + Category = $Exception.Source + Message = $Exception.Message + StackTrace = $Exception.StackTrace + Tag = $Tag + ThreadId = $logItem.Item.ThreadId + } + }) - # add the item to be processed - $null = $PodeContext.LogsToProcess.Add(@{ - Name = $Name - Item = $InputObject - }) + } + elseif ($PSCmdlet.ParameterSetName.ToLowerInvariant() -eq 'errorrecord') { + [Pode.PodeLogger]::Enqueue( @{ + Name = [Pode.PodeLogger]::ErrorLogName + Item = @{ + Server = $logItem.Item.Server + Level = $Level + Date = $(if ($PodeContext.Server.Logging.Type[$Name].Method.Arguments.AsUTC) { $logItem.Item.Date.ToUniversalTime() }else { $logItem.Item.Date.ToLocaltime() }) + Category = $ErrorRecord.CategoryInfo.ToString() + Message = $ErrorRecord.Exception.Message + StackTrace = $ErrorRecord.ScriptStackTrace + Tag = $Tag + ThreadId = $logItem.Item.ThreadId + } + }) + } + elseif ($Level -eq 'Error') { + [Pode.PodeLogger]::Enqueue( @{ + Name = [Pode.PodeLogger]::ErrorLogName + Item = @{ + Server = $logItem.Item.Server + Level = $Level + Date = $(if ($PodeContext.Server.Logging.Type[$Name].Method.Arguments.AsUTC) { $logItem.Item.Date.ToUniversalTime() }else { $logItem.Item.Date.ToLocaltime() }) + Category = $Category.ToString() + Message = $Message + Tag = $Tag + ThreadId = $logItem.Item.ThreadId + } + }) + } + } + } + + # Enqueue the log item for processing. + [Pode.PodeLogger]::Enqueue($logItem) + } } <# .SYNOPSIS -Masks values within a log item to protect sensitive information. + Masks values within a log item to protect sensitive information. .DESCRIPTION -Masks values within a log item, or any string, to protect sensitive information. -Patterns, and the Mask, can be configured via the server.psd1 configuration file. + Masks values within a log item, or any string, to protect sensitive information. + Patterns, and the Mask, can be configured via the server.psd1 configuration file. .PARAMETER Item -The string Item to mask values. + The string Item to mask values. .EXAMPLE -$value = Protect-PodeLogItem -Item 'Username=Morty, Password=Hunter2' + $value = Protect-PodeLogItem -Item 'Username=Morty, Password=Hunter2' #> function Protect-PodeLogItem { [CmdletBinding()] @@ -667,54 +1127,27 @@ function Protect-PodeLogItem { $Item ) - # do nothing if there are no masks - if (Test-PodeIsEmpty $PodeContext.Server.Logging.Masking.Patterns) { - return $item - } - - # attempt to apply each mask - foreach ($mask in $PodeContext.Server.Logging.Masking.Patterns) { - if ($Item -imatch $mask) { - # has both keep before/after - if ($Matches.ContainsKey('keep_before') -and $Matches.ContainsKey('keep_after')) { - $Item = ($Item -ireplace $mask, "`${keep_before}$($PodeContext.Server.Logging.Masking.Mask)`${keep_after}") - } - - # has just keep before - elseif ($Matches.ContainsKey('keep_before')) { - $Item = ($Item -ireplace $mask, "`${keep_before}$($PodeContext.Server.Logging.Masking.Mask)") - } + Process { - # has just keep after - elseif ($Matches.ContainsKey('keep_after')) { - $Item = ($Item -ireplace $mask, "$($PodeContext.Server.Logging.Masking.Mask)`${keep_after}") - } - - # normal mask - else { - $Item = ($Item -ireplace $mask, $PodeContext.Server.Logging.Masking.Mask) - } - } + return ([pode.PodeLogger]::ProtectLogItem($Item, $PodeContext.Server.Logging.Masking)) } - - return $Item } <# .SYNOPSIS -Automatically loads logging ps1 files + Automatically loads logging ps1 files .DESCRIPTION -Automatically loads logging ps1 files from either a /logging folder, or a custom folder. Saves space dot-sourcing them all one-by-one. + Automatically loads logging ps1 files from either a /logging folder, or a custom folder. Saves space dot-sourcing them all one-by-one. .PARAMETER Path -Optional Path to a folder containing ps1 files, can be relative or literal. + Optional Path to a folder containing ps1 files, can be relative or literal. .EXAMPLE -Use-PodeLogging + Use-PodeLogging .EXAMPLE -Use-PodeLogging -Path './my-logging' + Use-PodeLogging -Path './my-logging' #> function Use-PodeLogging { [CmdletBinding()] @@ -725,4 +1158,121 @@ function Use-PodeLogging { ) Use-PodeFolder -Path $Path -DefaultPath 'logging' +} + +<# +.SYNOPSIS + Enables logging in Pode. + +.DESCRIPTION + This function enables logging in Pode by setting the appropriate flags in the Pode context. + +.PARAMETER Terminal + A switch parameter that, if specified, enables terminal logging for the Pode C# listener. + +.EXAMPLE + Enable-PodeLog + This example enables all logging except terminal logging. + +.EXAMPLE + Enable-PodeLog -Terminal + This example enables all logging including terminal logging for the Pode C# listener. +#> +function Enable-PodeLog { + param( + [switch] + $Terminal + ) + + # Enable Pode logging + [pode.PodeLogger]::Enabled = $true + $PodeContext.Server.Logging.Enabled = $true + + # Enable terminal logging for the Pode C# listener if the Terminal switch is specified + [pode.PodeLogger]::Terminal = $Terminal.IsPresent +} + + +<# +.SYNOPSIS + Disables logging in Pode. + +.DESCRIPTION + This function disables logging in Pode by setting the appropriate flags in the Pode context. + It allows you to optionally keep terminal logging enabled. + +.PARAMETER KeepTerminal + A switch parameter that, if specified, keeps terminal logging enabled for the Pode C# listener even when other logging is disabled. + +.EXAMPLE + Disable-PodeLog + This example disables all logging including terminal logging. + +.EXAMPLE + Disable-PodeLog -KeepTerminal + This example disables all logging except terminal logging. +#> +function Disable-PodeLog { + param( + [switch] + $KeepTerminal + ) + + # Disable Pode logging + [pode.PodeLogger]::Enabled = $false + $PodeContext.Server.Logging.Enabled = $false + + # Optionally disable terminal logging if the KeepTerminal switch is not specified + if (! $KeepTerminal.IsPresent) { + [pode.PodeLogger]::Terminal = $false + } +} + + + +<# +.SYNOPSIS + Clears the Pode logging. + +.DESCRIPTION + This function clears all the logs in Pode by calling the Clear method on the PodeLogger class. + +.EXAMPLE + Clear-PodeLogging +#> +function Clear-PodeLogging { + $PodeContext.Server.Logging.Type.Clear() + $PodeContext.Server.Logging.Method.Clear() + [pode.PodeLogger]::Clear() +} + +<# +.SYNOPSIS + Retrieves the logging levels for a specified Pode logger. + +.DESCRIPTION + The `Get-PodeLoggingLevel` function takes the name of a logger and returns its associated logging levels. This function verifies whether the logger exists before attempting to retrieve its levels. + +.PARAMETER Name + The name of the logger for which to retrieve the logging levels. This parameter is mandatory. + +.OUTPUTS + An array of strings representing the logging levels of the specified Pode logger. If the logger does not exist, an empty array is returned. + +.EXAMPLE + Get-PodeLoggingLevel -Name 'FileLogger' + + This command retrieves the logging levels for the logger named 'FileLogger'. +#> +function Get-PodeLoggingLevel { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + if (Test-PodeLoggerEnabled -Name $Name) { + return (Get-PodeLogger -Name $Name).Arguments.Levels + } + return @() } \ No newline at end of file diff --git a/src/Public/LoggingMethod.ps1 b/src/Public/LoggingMethod.ps1 new file mode 100644 index 000000000..367954fae --- /dev/null +++ b/src/Public/LoggingMethod.ps1 @@ -0,0 +1,2188 @@ + +<# +.SYNOPSIS + Creates a new terminal logging method in Pode. + +.DESCRIPTION + This function sets up a logging method that outputs log messages to the terminal using Pode's internal terminal logging logic. It allows specifying a custom date format, or uses the ISO 8601 format if requested. Additionally, it supports logging time in UTC. + +.PARAMETER DataFormat + The custom date format to use for log entries. If not provided, a default format of 'dd/MMM/yyyy:HH:mm:ss zzz' is used. + This parameter is mutually exclusive with the ISO8601 parameter. + +.PARAMETER ISO8601 + If set, the date format will follow ISO 8601 (equivalent to -DataFormat 'yyyy-MM-ddTHH:mm:ssK'). + This parameter is mutually exclusive with the DataFormat parameter. + +.PARAMETER AsUTC + If set, the time will be logged in UTC instead of local time. + +.PARAMETER DefaultTag + The tag to use if none is specified on the log entry. Defaults to '-'. + +.OUTPUTS + Hashtable: Returns a hashtable containing the logging method configuration. + +.EXAMPLE + $logMethod = New-PodeTerminalLoggingMethod -DataFormat 'yyyy/MM/dd HH:mm:ss' + + Creates a terminal logging method using the specified custom date format. + +.EXAMPLE + $logMethod = New-PodeTerminalLoggingMethod -ISO8601 -AsUTC + + Creates a terminal logging method that logs messages using the ISO 8601 date format and logs the time in UTC. +#> +function New-PodeTerminalLoggingMethod { + [CmdletBinding(DefaultParameterSetName = 'DataFormat')] + [OutputType([hashtable])] + param( + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ + Test-PodeDateFormat $_ + })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-' + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + # Use ISO8601 format if specified + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' + } + default { + # Use default format if no DataFormat is provided + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Terminal logging logic + $methodId = New-PodeGuid + $PodeContext.Server.Logging.Method[$methodId] = @{ + ScriptBlock = (Get-PodeLoggingTerminalMethod) + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + } + + # Return the logging method configuration + return @{ + Type = 'Terminal' + Id = $methodId + Batch = New-PodeLogBatchInfo + Logger = @() + Arguments = @{ + DataFormat = $DataFormat + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + } + } +} + +<# +.SYNOPSIS + Creates a new file-based logging method in Pode. + +.DESCRIPTION + This function sets up a logging method that outputs log messages to a file. It supports configuring log file paths, names, formats, sizes, and retention policies, along with various log formatting options such as custom date formats or ISO 8601. + +.PARAMETER Path + The file path where the logs will be stored. Defaults to './logs'. + +.PARAMETER Name + The base name for the log files. This parameter is mandatory. + +.PARAMETER Format + The format of the log entries. Supported options are: RFC3164, RFC5424, Simple, and Default (Default: Default). + +.PARAMETER Separator + The character(s) used to separate log fields in each entry. Defaults to a space (' '). + +.PARAMETER MaxLength + The maximum length of log entries. Defaults to -1 (no limit). + +.PARAMETER MaxDays + The maximum number of days to keep log files. Logs older than this will be removed automatically. Defaults to 0 (no automatic removal). + +.PARAMETER MaxSize + The maximum size of a log file in bytes. Once this size is exceeded, a new log file will be created. Defaults to 0 (no size limit). + +.PARAMETER FailureAction + Specifies the action to take if logging fails. Options are: Ignore, Report, Halt (Default: Ignore). + +.PARAMETER DataFormat + The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER Encoding + The encoding to use for Syslog messages. Supported values are ASCII, BigEndianUnicode, Default, Unicode, UTF32, UTF7, and UTF8. Defaults to UTF8. + +.PARAMETER ISO8601 + If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.PARAMETER AsUTC + If set, logs the time in UTC instead of the local time. + +.PARAMETER DefaultTag + The tag to use if none is specified on the log entry. Defaults to '-'. + +.OUTPUTS + Hashtable: Returns a hashtable containing the logging method configuration. + + +.EXAMPLE + $logMethod = New-PodeFileLoggingMethod -Path './logs' -Name 'requests' + + Creates a new file logging method that stores logs in the './logs' directory with the base name 'requests'. + +.EXAMPLE + $logMethod = New-PodeFileLoggingMethod -Name 'requests' -MaxDays 7 -MaxSize 100MB + + Creates a file logging method that keeps logs for 7 days and creates new files once the log file reaches 100MB in size. +#> +function New-PodeFileLoggingMethod { + [CmdletBinding(DefaultParameterSetName = 'DataFormat')] + [OutputType([hashtable])] + param( + [string] + $Path = './logs', + + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [ValidateSet('RFC3164', 'RFC5424', 'Simple', 'Default')] + [string] + $Format = 'Default', + + [Parameter()] + [string] + $Separator = ' ', + + [Parameter()] + [int] + $MaxLength = -1, + + [Parameter()] + [ValidateRange(0, [int]::MaxValue)] + [int] + $MaxDays = 0, + + [Parameter()] + [ValidateRange(0, [int]::MaxValue)] + [int] + $MaxSize = 0, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ + Test-PodeDateFormat $_ + })] + [string] + $DataFormat, + + [Parameter()] + [ValidateSet('ASCII', 'BigEndianUnicode', 'Default', 'Unicode', 'UTF32', 'UTF7', 'UTF8')] + [string] + $Encoding = 'UTF8', + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-' + ) + + # Determine the date format based on the parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Resolve the log file path + $Path = (Protect-PodeValue -Value $Path -Default './logs') + $Path = (Get-PodeRelativePath -Path $Path -JoinRoot -Resolve) + if (! (Test-Path -Path $Path -PathType Leaf)) { + $null = New-Item -Path $Path -ItemType Directory -Force + } + # Create a unique ID for this logging method + $methodId = New-PodeGuid + + # Register the logging method in Pode's context + $PodeContext.Server.Logging.Method[$methodId] = @{ + ScriptBlock = (Get-PodeLoggingFileMethod) + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + } + + # Return the logging method configuration + return @{ + Type = 'File' + Id = $methodId + Batch = New-PodeLogBatchInfo + Logger = @() + Arguments = @{ + Name = $Name + Path = $Path + MaxDays = $MaxDays + MaxSize = $MaxSize + FileId = 0 + Date = $null + NextClearDown = [datetime]::Now.Date + FailureAction = $FailureAction + DataFormat = $DataFormat + AsUTC = $AsUTC.IsPresent + Encoding = $Encoding + Format = $Format + MaxLength = $MaxLength + Separator = $Separator + DefaultTag = $DefaultTag + } + } +} + +<# +.SYNOPSIS + Creates a new Event Viewer logging method in Pode. + +.DESCRIPTION + This function sets up a logging method that outputs log messages to the Windows Event Viewer. It allows configuring the log name, source, and event ID, along with date formatting options like custom formats or ISO 8601. + +.PARAMETER EventLogName + The name of the event log to write to. Defaults to 'Application'. + +.PARAMETER Source + The source of the log entries. Defaults to 'Pode'. + +.PARAMETER EventID + The ID of the event to log. Defaults to 0. + +.PARAMETER FailureAction + Specifies the action to take if logging fails. Options are: Ignore, Report, Halt (Default: Ignore). + +.PARAMETER DataFormat + The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 + If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.PARAMETER AsUTC + If set, logs the time in UTC instead of local time. + +.OUTPUTS + Hashtable: Returns a hashtable containing the logging method configuration. + +.EXAMPLE + $logMethod = New-PodeEventViewerLoggingMethod -EventLogName 'Application' -Source 'PodeApp' + + Creates a new Event Viewer logging method that writes to the 'Application' log with the source 'PodeApp'. + +.EXAMPLE + $logMethod = New-PodeEventViewerLoggingMethod -Source 'MyApp' -EventID 1001 -ISO8601 + + Creates a new Event Viewer logging method with ISO 8601 date format, writing to the 'MyApp' source and using event ID 1001. + +#> +function New-PodeEventViewerLoggingMethod { + [CmdletBinding(DefaultParameterSetName = 'DataFormat')] + [OutputType([hashtable])] + param( + [string] + $EventLogName = 'Application', + + [string] + $Source = 'Pode', + + [int] + $EventID = 0, + + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601, + + [Parameter()] + [switch] + $AsUTC + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Check if the platform is Windows + if (!(Test-PodeIsWindows)) { + # Event Viewer logging is only supported on Windows + throw ($PodeLocale.eventViewerLoggingSupportedOnWindowsOnlyExceptionMessage) + } + + # Ensure the event source exists in the Event Log + if (![System.Diagnostics.EventLog]::SourceExists($Source)) { + [System.Diagnostics.EventLog]::CreateEventSource($Source, $EventLogName) + } + + # Create the method ID and configure the logging method + $methodId = New-PodeGuid + $PodeContext.Server.Logging.Method[$methodId] = @{ + ScriptBlock = (Get-PodeLoggingEventViewerMethod) + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + } + + # Return the logging method configuration + return @{ + Type = 'EventViewer' + Id = $methodId + Batch = New-PodeLogBatchInfo + Logger = @() + Arguments = @{ + LogName = $EventLogName + Source = $Source + ID = $EventID + FailureAction = $FailureAction + DataFormat = $DataFormat + AsUTC = $AsUTC.IsPresent + Tag = $Source + } + } +} + + +<# +.SYNOPSIS + Creates a new custom logging method in Pode. + +.DESCRIPTION + This function sets up a custom logging method that uses a script block to define the logging logic. It supports the option to run the logging method in a separate runspace and allows for custom options, date formatting, and failure handling. + +.PARAMETER ScriptBlock + A non-empty script block that defines the custom logging logic. This parameter is mandatory. + +.PARAMETER ArgumentList + An array of arguments to pass to the custom script block. + +.PARAMETER CustomOptions + A hashtable of custom options that will be passed to the script block when used inside a runspace. + +.PARAMETER FailureAction + Specifies the action to take if logging fails. Options are: Ignore, Report, Halt (Default: Ignore). + +.PARAMETER DataFormat + The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 + If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.PARAMETER AsUTC + If set, logs the time in UTC instead of local time. + +.EXAMPLE + $logMethod = New-PodeCustomLoggingMethod -ScriptBlock { param($logItem) Write-Output $logItem } -UseRunspace + + Creates a custom logging method using a script block that writes log items to the output. The method runs in a separate runspace. + +.EXAMPLE + $logMethod = New-PodeCustomLoggingMethod -ScriptBlock { param($logItem) Write-Output $logItem } -DataFormat 'yyyy/MM/dd HH:mm:ss' + + Creates a custom logging method with a custom date format. + +.OUTPUTS + Hashtable: Returns a hashtable containing the custom logging method configuration. +#> +function New-PodeCustomLoggingMethod { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUSeDeclaredVarsMoreThanAssignments', '')] + [CmdletBinding(DefaultParameterSetName = 'DataFormat')] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [ValidateScript({ + if (Test-PodeIsEmpty $_) { + # A non-empty ScriptBlock is required for the Custom logging output method + throw ($PodeLocale.nonEmptyScriptBlockRequiredForCustomLoggingExceptionMessage) + } + return $true + }) + ] + [scriptblock] + $ScriptBlock, + + [Parameter()] + [object[]] + $ArgumentList, + + [Parameter()] + [hashtable] + $CustomOptions = @{}, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601, + + [Parameter()] + [switch] + $AsUTC + ) + + # Determine the date format based on the parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Create the script block for the custom logging method running in a separate runspace + $enanchedScriptBlock = { + param($MethodId) + + $log = @{} + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + Start-Sleep -Milliseconds 100 + + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + if ($null -ne $log) { + $Item = $log.item + $Options = $log.options + $RawItem = $log.rawItem + try { + # Original ScriptBlock Start + <# ScriptBlock #> + # Original ScriptBlock End + } + catch { + Invoke-PodeHandleFailure -Message "Custom Logging $MethodId Error. message: $_" -FailureAction $options.FailureAction + } + } + } + } + } + + $methodId = New-PodeGuid + + # Register the enhanced script block in Pode's logging method + $PodeContext.Server.Logging.Method[$methodId] = @{ + ScriptBlock = [ScriptBlock]::Create($enanchedScriptBlock.ToString().Replace('<# ScriptBlock #>', $ScriptBlock.ToString())) + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + } + + return @{ + Type = 'Custom' + Id = $methodId + Batch = New-PodeLogBatchInfo + Logger = @() + Arguments = @{ + FailureAction = $FailureAction + DataFormat = $DataFormat + AsUTC = $AsUTC + } + $CustomOptions + } +} + +<# +.SYNOPSIS + Creates a new Syslog logging method in Pode. + +.DESCRIPTION + This function sets up a logging method that sends log messages to a remote Syslog server. It supports various Syslog protocols (RFC3164, RFC5424), transports (UDP, TCP, TLS), and encoding formats. The function also allows for custom date formatting or ISO 8601 compliance and can skip certificate checks for TLS connections. + +.PARAMETER Server + The Syslog server to send logs to. This parameter is mandatory. + +.PARAMETER Port + The port on the Syslog server to send logs to. Defaults to 514. + +.PARAMETER Transport + The transport protocol to use. Supported values are UDP, TCP, and TLS. Defaults to UDP. + +.PARAMETER TlsProtocol + The TLS protocol version to use if TLS transport is selected. Defaults to TLS 1.3. + +.PARAMETER SyslogProtocol + The Syslog protocol to use for message formatting. Supported values are RFC3164 and RFC5424. Defaults to RFC5424. + +.PARAMETER Encoding + The encoding to use for Syslog messages. Supported values are ASCII, BigEndianUnicode, Default, Unicode, UTF32, UTF7, and UTF8. Defaults to UTF8. + +.PARAMETER SkipCertificateCheck + If set, skips certificate validation for TLS connections. + +.PARAMETER FailureAction + Specifies the action to take if logging fails. Options are: Ignore, Report, Halt (Default: Ignore). + +.PARAMETER DataFormat + The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 + If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.PARAMETER AsUTC + If set, logs the time in UTC instead of local time. + +.PARAMETER DefaultTag + The tag to use if none is specified on the log entry. Defaults to '-'. + +.EXAMPLE + $logMethod = New-PodeSyslogLoggingMethod -Server '192.168.1.100' -Transport 'TCP' -SyslogProtocol 'RFC3164' + + Creates a new Syslog logging method that sends logs to the Syslog server at 192.168.1.100 using TCP and RFC3164 format. + +.EXAMPLE + $logMethod = New-PodeSyslogLoggingMethod -Server '192.168.1.100' -SyslogProtocol 'RFC5424' -ISO8601 -AsUTC + + Creates a Syslog logging method that uses RFC5424 format with ISO 8601 date formatting and logs time in UTC. + +.OUTPUTS + Hashtable: Returns a hashtable containing the Syslog logging method configuration. +#> +function New-PodeSyslogLoggingMethod { + [CmdletBinding(DefaultParameterSetName = 'DataFormat')] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $Server, + + [Parameter()] + [Int16] + $Port = 514, + + [Parameter()] + [ValidateSet('UDP', 'TCP', 'TLS')] + [string] + $Transport = 'UDP', + + [Parameter()] + [System.Security.Authentication.SslProtocols] + $TlsProtocol = [System.Security.Authentication.SslProtocols]::Tls13, + + [Parameter()] + [ValidateSet('RFC3164', 'RFC5424')] + [string] + $SyslogProtocol = 'RFC5424', + + [Parameter()] + [ValidateSet('ASCII', 'BigEndianUnicode', 'Default', 'Unicode', 'UTF32', 'UTF7', 'UTF8')] + [string] + $Encoding = 'UTF8', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-' + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Select encoding based on the provided value + $selectedEncoding = [System.Text.Encoding]::$Encoding + if ($null -eq $selectedEncoding) { + throw ($PodeLocale.invalidEncodingExceptionMessage -f $Encoding) + } + + # Create the method ID and configure the logging method + $methodId = New-PodeGuid + $PodeContext.Server.Logging.Method[$methodId] = @{ + ScriptBlock = (Get-PodeLoggingSysLogMethod) + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + } + + # Return the logging method configuration + return @{ + Type = 'Syslog' + Id = $methodId + Batch = New-PodeLogBatchInfo + Logger = @() + Arguments = @{ + Server = $Server + Port = $Port + Transport = $Transport + Hostname = $Hostname + TlsProtocols = $TlsProtocol + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + Format = $SyslogProtocol + Encoding = $selectedEncoding + FailureAction = $FailureAction + DataFormat = $DataFormat + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + } + } +} + +<# +.SYNOPSIS +Configures logging to AWS CloudWatch Logs. + +.DESCRIPTION +The `New-PodeAwsLoggingMethod` function configures a logging method for AWS CloudWatch Logs. It initializes a logging queue and sends log events to AWS CloudWatch using the specified log group and stream names. + +.PARAMETER BaseUrl +The base URL for the AWS CloudWatch Logs API, typically `https://logs..amazonaws.com`. + +.PARAMETER Region +The AWS region where the CloudWatch Log Group resides, such as `us-east-1`. + +.PARAMETER LogGroupName +The name of the AWS CloudWatch Log Group to send logs to. + +.PARAMETER LogStreamName +The name of the AWS CloudWatch Log Stream within the log group. + +.PARAMETER AuthorizationHeader +The AWS authorization header, generated using AWS Signature Version 4. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeAwsLoggingMethod -BaseUrl 'https://logs.us-east-1.amazonaws.com' -Region 'us-east-1' -LogGroupName 'MyLogGroup' -LogStreamName 'MyLogStream' -AuthorizationHeader 'AWS4-HMAC-SHA256 ...' + +Configures AWS CloudWatch logging with specified log group, log stream, and AWS authorization details. + +.NOTES +This function sends logs to AWS CloudWatch in batches, using a `ConcurrentQueue` to manage queued logs. +#> +function New-PodeAwsLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $BaseUrl, + + [Parameter(Mandatory = $true)] + [string] + $Region, + + [Parameter(Mandatory = $true)] + [string] + $LogGroupName, + + [Parameter(Mandatory = $true)] + [string] + $LogStreamName, + + [Parameter(Mandatory = $true)] + [string] + $AuthorizationHeader, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique ID for this logging method instance. + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for tracking and execution. + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to AWS CloudWatch Logs. + ScriptBlock = { + param($MethodId) # Pass the unique method ID to identify this logging configuration. + + # Temporary hashtable to hold a dequeued log entry. + $log = @{ } + + # Loop continuously until a cancellation is requested (graceful shutdown). + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + # Brief pause to reduce CPU usage in the loop. + Start-Sleep -Milliseconds 100 + + # Attempt to dequeue a log entry from the queue. + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + # Only process if a valid log entry was dequeued. + if ($null -ne $log) { + # Retrieve log data and configuration options. + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are treated as arrays to handle multiple log entries. + $Item = @($Item) + $RawItem = @($RawItem) + + # Define the AWS CloudWatch Logs endpoint URL. + $url = "https://logs.$($Options.Region).amazonaws.com" + + # Set up headers with the AWS authorization header and content type. + $headers = @{ + 'X-Amz-Date' = (Get-Date -Format 'yyyyMMddTHHmmssZ') # Current timestamp in AWS-required format + 'Content-Type' = 'application/x-amz-json-1.1' + 'X-Amz-Target' = 'Logs_20140328.PutLogEvents' # AWS target for CloudWatch log ingestion + 'Authorization' = $Options.AuthorizationHeader # AWS Signature v4 for authentication + } + + # Format each log entry for CloudWatch Logs. + $events = $Item | ForEach-Object { + @{ + message = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize log message content + timestamp = [math]::Round(($RawItem.Date).ToUniversalTime().Subtract(([datetime]::UnixEpoch)).TotalMilliseconds) # Timestamp in milliseconds since epoch + } + } + + # Create the payload body for AWS CloudWatch Logs. + $body = @{ + logGroupName = $Options.LogGroupName # Target log group + logStreamName = $Options.LogStreamName # Target log stream within the group + logEvents = $events + } | ConvertTo-Json -Compress + + # Send the log data to CloudWatch Logs via HTTP POST. + try { + Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction (e.g., Ignore, Report, Halt). + Invoke-PodeHandleFailure -Message "Failed to send log to AWS CloudWatch Logs: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + + } + + # Return the logging method configuration as a hashtable. + return @{ + Type = 'AWS' # Specifies the type of logging platform. + Id = $methodId # Unique identifier for this logging method. + Batch = New-PodeLogBatchInfo # Contains batch information for Pode logging. + Logger = @() # Initialize an empty logger array if needed for Pode processing. + Arguments = @{ + BaseUrl = $BaseUrl + Region = $Region + LogGroupName = $LogGroupName + LogStreamName = $LogStreamName + AuthorizationHeader = $AuthorizationHeader + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} + +<# +.SYNOPSIS +Configures logging to Azure Monitor Logs. + +.DESCRIPTION +The `New-PodeAzureLoggingMethod` function sets up logging for Azure Monitor Logs, allowing log data to be sent to a specified Azure Log Analytics workspace. It uses the shared key authorization method to authenticate with Azure. + +.PARAMETER WorkspaceId +The Azure Log Analytics Workspace ID. + +.PARAMETER AuthorizationHeader +The authorization header for Azure, generated using the Workspace ID and shared key. + +.PARAMETER LogType +The custom log type name in Azure Monitor. Defaults to `CustomLog`. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeAzureLoggingMethod -WorkspaceId '12345' -AuthorizationHeader 'SharedKey 12345:abcdef...' -LogType 'ApplicationLogs' + +Sets up Azure Monitor logging with the specified workspace ID and authorization details. + +.NOTES +This function sends logs to Azure Monitor Logs using the Azure REST API, formatted for ingestion by Azure Log Analytics. +#> +function New-PodeAzureLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $WorkspaceId, + + [Parameter(Mandatory = $true)] + [string] + $AuthorizationHeader, # Azure Shared Key authorization + + [Parameter()] + [string] + $LogType = 'CustomLog', + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique ID for this logging method instance. + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for tracking and execution. + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to Azure Monitor. + ScriptBlock = { + param($MethodId) # Pass the unique method ID to identify this logging configuration. + + # Temporary hashtable to hold a dequeued log entry. + $log = @{ } + + # Loop continuously until a cancellation is requested (graceful shutdown). + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + # Brief pause to reduce CPU usage in the loop. + Start-Sleep -Milliseconds 100 + + # Attempt to dequeue a log entry from the queue. + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + # Only process if a valid log entry was dequeued. + if ($null -ne $log) { + # Retrieve log data and configuration options. + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are treated as arrays to handle multiple log entries. + $Item = @($Item) + $RawItem = @($RawItem) + + # Define the Azure Monitor HTTP Data Collector API endpoint URL for the specified workspace. + $url = "https://$($Options.WorkspaceId).ods.opinsights.azure.com/api/logs?api-version=2016-04-01" + + # Set up headers, including the authorization header, log type, and time-generated field. + $headers = @{ + 'Authorization' = $Options.AuthorizationHeader # Azure Shared Key + 'Log-Type' = $Options.LogType # Specifies the Log Type name + 'x-ms-date' = (Get-Date -Format 'R') # RFC1123 date format for request header + 'time-generated-field' = 'timestamp' + } + + # Format each log entry for Azure Monitor. + $records = $Item | ForEach-Object { + @{ + message = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize log message content + severity = $RawItem.Level.ToUpperInvariant() # Set log severity level + timestamp = $RawItem.Date.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') # Format timestamp in ISO 8601 + tag = $RawItem.Tag # Include tag if provided + } + } + + # Convert the list of records to JSON format for Azure Monitor ingestion. + $body = $records | ConvertTo-Json -Compress + + # Send the log data to Azure Monitor via HTTP POST. + try { + Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction (e.g., Ignore, Report, Halt). + Invoke-PodeHandleFailure -Message "Failed to send log to Azure Monitor: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + + # Return the logging method configuration as a hashtable. + return @{ + Type = 'Azure' # Specifies the type of logging platform. + Id = $methodId # Unique identifier for this logging method. + Batch = New-PodeLogBatchInfo # Contains batch information for Pode logging. + Logger = @() # Initialize an empty logger array if needed for Pode processing. + Arguments = @{ + WorkspaceId = $WorkspaceId + AuthorizationHeader = $AuthorizationHeader + LogType = $LogType + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} + +<# +.SYNOPSIS +Configures logging to Google Cloud Logging. + +.DESCRIPTION +The `New-PodeGoogleLoggingMethod` function sets up logging for Google Cloud Logging, allowing log entries to be sent to Google Cloud using the project ID and access token for authentication. + +.PARAMETER ProjectId +The Google Cloud Project ID. + +.PARAMETER AccessToken +OAuth 2.0 access token for authenticating with Google Cloud. + +.PARAMETER LogName +The name of the log in Google Cloud Logging. Defaults to `default_log`. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeGoogleLoggingMethod -ProjectId 'my-project-id' -AccessToken 'ya29.a0AfH6SM...' -LogName 'ApplicationLogs' + +Sets up Google Cloud Logging with the specified project ID and access token. + +.NOTES +This function sends log entries to Google Cloud Logging using the Google Cloud Logging REST API, allowing for structured logging within a specific project. +#> +function New-PodeGoogleLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $ProjectId, + + [Parameter(Mandatory = $true)] + [string] + $AccessToken, # OAuth 2.0 token + + [Parameter()] + [string] + $LogName = 'default_log', + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique ID for this logging method instance. + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for tracking and execution. + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to Google Cloud Logging. + ScriptBlock = { + param($MethodId) # Pass the unique method ID to identify this logging configuration. + + # Temporary hashtable to hold a dequeued log entry. + $log = @{ } + + # Loop continuously until a cancellation is requested (graceful shutdown). + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + # Brief pause to reduce CPU usage in the loop. + Start-Sleep -Milliseconds 100 + + # Attempt to dequeue a log entry from the queue. + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + # Only process if a valid log entry was dequeued. + if ($null -ne $log) { + # Retrieve log data and configuration options. + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are treated as arrays to handle multiple log entries. + $Item = @($Item) + $RawItem = @($RawItem) + + # Define the Google Cloud Logging API endpoint URL. + $url = 'https://logging.googleapis.com/v2/entries:write' + + # Set up headers with the authorization token and JSON content type. + $headers = @{ + 'Authorization' = "Bearer $($Options.AccessToken)" # OAuth 2.0 Bearer token + 'Content-Type' = 'application/json' + } + + # Format each log entry for Google Cloud Logging. + $entries = $Item | ForEach-Object { + @{ + textPayload = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize log message content + severity = $RawItem.Level.ToUpperInvariant() # Set log severity level + timestamp = $RawItem.Date.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') # Format timestamp in ISO 8601 + labels = @{ + tag = $RawItem.Tag # Include tag if provided + } + resource = @{ + type = 'global' # Set resource type to global + labels = @{ + project_id = $Options.ProjectId # Add the project ID + } + } + } + } + + # Create the payload body for Google Cloud Logging. + $body = @{ + entries = $entries + logName = "projects/$($Options.ProjectId)/logs/$($Options.LogName)" # Define log name path + } | ConvertTo-Json -Compress + + # Send the log data to Google Cloud Logging via HTTP POST. + try { + Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction (e.g., Ignore, Report, Halt). + Invoke-PodeHandleFailure -Message "Failed to send log to Google Cloud Logging: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + + # Return the logging method configuration as a hashtable. + return @{ + Type = 'Google' # Specifies the type of logging platform. + Id = $methodId # Unique identifier for this logging method. + Batch = New-PodeLogBatchInfo # Contains batch information for Pode logging. + Logger = @() # Initialize an empty logger array if needed for Pode processing. + Arguments = @{ + ProjectId = $ProjectId + AccessToken = $AccessToken + LogName = $LogName + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} + +<# +.SYNOPSIS +Configures logging to Datadog Logs. + +.DESCRIPTION +The `New-PodeDatadogLoggingMethod` function sets up logging for Datadog, allowing log entries to be sent to Datadog’s log intake endpoint using the provided API key. + +.PARAMETER ApiKey +The Datadog API key used to authenticate requests. + +.PARAMETER BaseUrl +The Datadog intake URL, typically `https://http-intake.logs.datadoghq.com/v1/input`. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeDatadogLoggingMethod -ApiKey 'my-datadog-api-key' -BaseUrl 'https://http-intake.logs.datadoghq.com/v1/input' + +Configures Datadog logging using the provided API key and URL. + +.NOTES +This function sends logs to Datadog Logs using a REST API call with the API key as authorization. +#> +function New-PodeDatadogLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $ApiKey, + + [Parameter(Mandatory = $true)] + [string] + $BaseUrl, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique ID for this logging method instance. + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for tracking and execution. + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to Datadog. + ScriptBlock = { + param($MethodId) # Pass the unique method ID to identify this logging configuration. + + # Temporary hashtable to hold a dequeued log entry. + $log = @{ } + + # Loop continuously until a cancellation is requested (graceful shutdown). + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + # Brief pause to reduce CPU usage in the loop. + Start-Sleep -Milliseconds 100 + + # Attempt to dequeue a log entry from the queue. + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + # Only process if a valid log entry was dequeued. + if ($null -ne $log) { + # Retrieve log data and configuration options. + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are treated as arrays to handle multiple log entries. + $Item = @($Item) + $RawItem = @($RawItem) + + # Construct the Datadog intake URL for log ingestion. + $url = $Options.BaseUrl + + # Set up headers with the Datadog API key and JSON content type. + $headers = @{ + 'DD-API-KEY' = $Options.ApiKey # API key for Datadog + 'Content-Type' = 'application/json' + } + + # Format each log entry for Datadog. + $events = $Item | ForEach-Object { + @{ + message = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize log message content + host = $PodeContext.Server.ComputerName # Add hostname + service = $RawItem.Tag # Use tag as the service if provided + date_happened = [math]::Round(($RawItem.Date).ToUniversalTime().Subtract(([datetime]::UnixEpoch)).TotalSeconds) # Convert timestamp to seconds since epoch + status = $RawItem.Level.ToUpperInvariant() # Set log severity level + } + } + + # Convert the list of events to JSON format for Datadog ingestion. + $body = $events | ConvertTo-Json -Compress + + # Send the log data to Datadog via HTTP POST. + try { + Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction (e.g., Ignore, Report, Halt). + Invoke-PodeHandleFailure -Message "Failed to send log to Datadog: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + + # Return the logging method configuration as a hashtable. + return @{ + Type = 'Datadog' # Specifies the type of logging platform. + Id = $methodId # Unique identifier for this logging method. + Batch = New-PodeLogBatchInfo # Contains batch information for Pode logging. + Logger = @() # Initialize an empty logger array if needed for Pode processing. + Arguments = @{ + ApiKey = $ApiKey + BaseUrl = $BaseUrl + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} + + +<# +.SYNOPSIS +Configures logging to Elasticsearch. + +.DESCRIPTION +The `New-PodeElasticsearchLoggingMethod` function configures logging for Elasticsearch, allowing log entries to be sent as documents to a specified Elasticsearch index. + +.PARAMETER BaseUrl +The base URL for the Elasticsearch API, typically `http://:9200`. + +.PARAMETER IndexName +The name of the Elasticsearch index where log entries will be stored. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeElasticsearchLoggingMethod -BaseUrl 'http://localhost:9200' -IndexName 'application-logs' + +Sets up Elasticsearch logging with the specified base URL and index name. + +.NOTES +This function sends log entries to Elasticsearch by creating documents in the specified index. +#> +function New-PodeElasticsearchLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $BaseUrl, + + [Parameter(Mandatory = $true)] + [string] + $IndexName, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique ID for this logging method instance. + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for tracking and execution. + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to Elasticsearch. + ScriptBlock = { + param($MethodId) # Pass the unique method ID to identify this logging configuration. + + # Temporary hashtable to hold a dequeued log entry. + $log = @{ } + + # Loop continuously until a cancellation is requested (graceful shutdown). + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + # Brief pause to reduce CPU usage in the loop. + Start-Sleep -Milliseconds 100 + + # Attempt to dequeue a log entry from the queue. + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + # Only process if a valid log entry was dequeued. + if ($null -ne $log) { + # Retrieve log data and configuration options. + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are treated as arrays to handle multiple log entries. + $Item = @($Item) + $RawItem = @($RawItem) + + # Construct the Elasticsearch URL for document ingestion using the specified index. + $url = "$($Options.BaseUrl)/$($Options.IndexName)/_doc/" + + # Set up headers for JSON content type required by Elasticsearch. + $headers = @{ + 'Content-Type' = 'application/json' + } + + # Format each log entry for Elasticsearch. + $documents = $Item | ForEach-Object { + @{ + message = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize log message content + timestamp = $RawItem.Date.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') # Format timestamp in ISO 8601 + severity = $RawItem.Level.ToUpperInvariant() # Set log severity level + host = $PodeContext.Server.ComputerName # Add hostname + tag = $RawItem.Tag # Include tag if provided + } + } + + # Convert the list of documents to JSON format for Elasticsearch ingestion. + $body = $documents | ConvertTo-Json -Compress + + # Send the log data to Elasticsearch via HTTP POST. + try { + Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction (e.g., Ignore, Report, Halt). + Invoke-PodeHandleFailure -Message "Failed to send log to Elasticsearch: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + + # Return the logging method configuration as a hashtable. + return @{ + Type = 'Elasticsearch' # Specifies the type of logging platform. + Id = $methodId # Unique identifier for this logging method. + Batch = New-PodeLogBatchInfo # Contains batch information for Pode logging. + Logger = @() # Initialize an empty logger array if needed for Pode processing. + Arguments = @{ + BaseUrl = $BaseUrl + IndexName = $IndexName + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} + +<# +.SYNOPSIS +Configures logging to Graylog. + +.DESCRIPTION +The `New-PodeGraylogLoggingMethod` function sets up logging for Graylog, sending log entries to the Graylog server using GELF (Graylog Extended Log Format) over HTTP. + +.PARAMETER BaseUrl +The base URL for the Graylog API, typically `http://:12201/gelf`. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeGraylogLoggingMethod -BaseUrl 'http://graylog-server:12201/gelf' + +Configures Graylog logging using the specified base URL. + +.NOTES +This function sends logs to Graylog using GELF, which allows for structured logging. +#> +function New-PodeGraylogLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $BaseUrl, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique ID for this logging method instance. + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for tracking and execution. + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to Graylog. + ScriptBlock = { + param($MethodId) # Pass the unique method ID to identify this logging configuration. + + # Temporary hashtable to hold a dequeued log entry. + $log = @{ } + + # Loop continuously until a cancellation is requested (graceful shutdown). + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + # Brief pause to reduce CPU usage in the loop. + Start-Sleep -Milliseconds 100 + + # Attempt to dequeue a log entry from the queue. + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + # Only process if a valid log entry was dequeued. + if ($null -ne $log) { + # Retrieve log data and configuration options. + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are treated as arrays to handle multiple log entries. + $Item = @($Item) + $RawItem = @($RawItem) + + # Construct the Graylog HTTP GELF URL. + $url = $Options.BaseUrl + + # Set up headers for JSON content type required by Graylog. + $headers = @{ + 'Content-Type' = 'application/json' + } + + # Format each log entry for Graylog. + $messages = $Item | ForEach-Object { + @{ + version = '1.1' # GELF version + host = $PodeContext.Server.ComputerName # Add hostname + short_message = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize log message content + timestamp = [math]::Round(($RawItem.Date).ToUniversalTime().Subtract(([datetime]::UnixEpoch)).TotalSeconds) # Convert timestamp to seconds since epoch + level = $RawItem.Level.ToUpperInvariant() # Set log severity level + _tag = $RawItem.Tag # Include tag if provided + } + } + + # Convert the list of messages to JSON format for Graylog ingestion. + $body = $messages | ConvertTo-Json -Compress + + # Send the log data to Graylog via HTTP POST. + try { + Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction (e.g., Ignore, Report, Halt). + Invoke-PodeHandleFailure -Message "Failed to send log to Graylog: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + + # Return the logging method configuration as a hashtable. + return @{ + Type = 'Graylog' # Specifies the type of logging platform. + Id = $methodId # Unique identifier for this logging method. + Batch = New-PodeLogBatchInfo # Contains batch information for Pode logging. + Logger = @() # Initialize an empty logger array if needed for Pode processing. + Arguments = @{ + BaseUrl = $BaseUrl + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} + +<# +.SYNOPSIS +Configures logging to Splunk. + +.DESCRIPTION +The `New-PodeSplunkLoggingMethod` function sets up logging for Splunk, sending log entries to a specified Splunk HTTP Event Collector (HEC) endpoint using a specified token. + +.PARAMETER BaseUrl +The base URL for the Splunk HTTP Event Collector, typically `https://:8088/services/collector`. + +.PARAMETER Token +The Splunk HEC token for authentication. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeSplunkLoggingMethod -BaseUrl 'https://splunk-server:8088/services/collector' -Token 'my-splunk-token' + +Configures Splunk logging with the provided URL and token. + +.NOTES +This function sends logs to Splunk through its HTTP Event Collector (HEC). +#> +function New-PodeSplunkLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $BaseUrl, + + [Parameter(Mandatory = $true)] + [string] + $Token, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique ID for this logging method instance. + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for tracking and execution. + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to Splunk. + ScriptBlock = { + param($MethodId) # Pass the unique method ID to identify this logging configuration. + + # Temporary hashtable to hold a dequeued log entry. + $log = @{ } + + # Loop continuously until a cancellation is requested (graceful shutdown). + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + # Brief pause to reduce CPU usage in the loop. + Start-Sleep -Milliseconds 100 + + # Attempt to dequeue a log entry from the queue. + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + # Only process if a valid log entry was dequeued. + if ($null -ne $log) { + # Retrieve log data and configuration options. + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are treated as arrays to handle multiple log entries. + $Item = @($Item) + $RawItem = @($RawItem) + + # Construct the Splunk HEC URL. + $url = $Options.BaseUrl + + # Set up headers for Splunk HEC authentication and content type. + $headers = @{ + 'Authorization' = "Splunk $($Options.Token)" # HEC token for Splunk + 'Content-Type' = 'application/json' + } + + # Format each log entry for Splunk. + $events = $Item | ForEach-Object { + @{ + event = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize log message content + host = $PodeContext.Server.ComputerName # Add hostname + source = $RawItem.Tag # Use tag as the source if provided + time = [math]::Round(($RawItem.Date).ToUniversalTime().Subtract(([datetime]::UnixEpoch)).TotalSeconds) # Convert timestamp to seconds since epoch + fields = @{ + severity = $RawItem.Level.ToUpperInvariant() # Set log severity level + } + } + } + + # Convert the list of events to JSON format for Splunk ingestion. + $body = $events | ConvertTo-Json -Compress + + # Send the log data to Splunk via HTTP POST. + try { + Invoke-RestMethod -Uri $url -Method Post -Headers $headers -Body $body -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction (e.g., Ignore, Report, Halt). + Invoke-PodeHandleFailure -Message "Failed to send log to Splunk: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + + # Return the logging method configuration as a hashtable. + return @{ + Type = 'Splunk' # Specifies the type of logging platform. + Id = $methodId # Unique identifier for this logging method. + Batch = New-PodeLogBatchInfo # Contains batch information for Pode logging. + Logger = @() # Initialize an empty logger array if needed for Pode processing. + Arguments = @{ + BaseUrl = $BaseUrl + Token = $Token + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} + +<# +.SYNOPSIS +Configures logging to VMware Log Insight. + +.DESCRIPTION +The `New-PodeLogInsightLoggingMethod` function sets up logging for VMware Log Insight, allowing log entries to be sent to the Log Insight API endpoint. + +.PARAMETER BaseUrl +The base URL for the VMware Log Insight ingestion API, typically `https:///api/v1/messages/ingest/`. + +.PARAMETER Id +The ingestion ID for VMware Log Insight, used to target a specific log stream. + +.PARAMETER FailureAction +Specifies the action to take if the logging request fails. Valid values are `Ignore`, `Report`, and `Halt`. The default is `Ignore`. + +.PARAMETER SkipCertificateCheck +If present, skips SSL certificate validation when sending logs. + +.PARAMETER AsUTC +If present, converts timestamps to UTC. + +.PARAMETER DefaultTag +Sets a default tag for log entries. Defaults to `-`. + +.PARAMETER DataFormat +The custom date format for log entries. Mutually exclusive with ISO8601. + +.PARAMETER ISO8601 +If set, uses the ISO 8601 date format for log entries. Mutually exclusive with DataFormat. + +.EXAMPLE +PS> New-PodeLogInsightLoggingMethod -BaseUrl 'https://loginsight-server/api/v1/messages/ingest/' -Id 'my-log-id' + +Configures Log Insight logging using the provided URL and ID. + +.NOTES +This function sends logs to VMware Log Insight through its ingestion API. +#> +function New-PodeLogInsightLoggingMethod { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $BaseUrl, + + [Parameter(Mandatory = $true)] + [string] + $Id, + + [Parameter()] + [ValidateSet('Ignore', 'Report', 'Halt')] + [string] + $FailureAction = 'Ignore', + + [Parameter()] + [switch] + $SkipCertificateCheck, + + [Parameter()] + [switch] + $AsUTC, + + [Parameter()] + [string] + $DefaultTag = '-', + + [Parameter(ParameterSetName = 'DataFormat')] + [ValidateScript({ Test-PodeDateFormat $_ })] + [string] + $DataFormat, + + [Parameter(ParameterSetName = 'ISO8601')] + [switch] + $ISO8601 + ) + + # Determine the date format based on parameter set + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'iso8601' { + $DataFormat = 'yyyy-MM-ddTHH:mm:ssK' # ISO8601 format + } + default { + if ([string]::IsNullOrEmpty($DataFormat)) { + $DataFormat = 'dd/MMM/yyyy:HH:mm:ss zzz' # Default format + } + } + } + + # Generate a unique method ID for this logging method instance + $methodId = New-PodeGuid + + # Add the logging method configuration to the PodeContext for use in logging + $PodeContext.Server.Logging.Method[$methodId] = @{ + # Queue to hold log entries until they can be processed. + # Using a concurrent queue ensures thread-safe interactions in the runspace. + Queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + + # ScriptBlock responsible for processing log entries in a separate runspace. + # This block continuously dequeues and sends log entries to Splunk. + ScriptBlock = { + param($MethodId) + + # Temporary hashtable to store dequeued log information + $log = @{ } + + # Run while cancellation has not been requested + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + Start-Sleep -Milliseconds 100 # Sleep briefly to avoid constant polling + + # Try to dequeue a log entry from the method's queue + if ($PodeContext.Server.Logging.Method[$MethodId].Queue.TryDequeue([ref]$log)) { + if ($null -ne $log) { + # Extract log data and configuration options + $Item = $log.Item + $Options = $log.Options + $RawItem = $log.RawItem + + # Ensure both $Item and $RawItem are arrays to handle multiple log entries + $Item = @($Item) + $RawItem = @($RawItem) + + # Build the target URL for the Log Insight API endpoint + $url = "$($Options.BaseUrl)/$($Options.Id)" + $headers = @{ + 'Content-Type' = 'application/json' + } + + # Process each log entry and format as required by Log Insight + $messages = $Item | ForEach-Object { + @{ + text = ([pode.PodeLogger]::ProtectLogItem($_, $PodeContext.Server.Logging.Masking)) #($_ | Protect-PodeLogItem) # Sanitize log message content # Sanitize the log message + timestamp = [math]::Round(($RawItem.Date).ToUniversalTime().Subtract(([datetime]::UnixEpoch)).TotalMilliseconds) # Convert date to milliseconds since epoch + fields = @{ + severity = $RawItem.Level.ToUpperInvariant() # Add severity level + tag = $RawItem.Tag # Add a tag if provided + } + } + } + + # Prepare the payload with the formatted messages + $payload = @{ + messages = $messages + } + + # Convert the payload to JSON format + $body = $payload | ConvertTo-Json -Compress + + try { + # Send the log data to VMware Log Insight via HTTP POST + Invoke-RestMethod -Uri $url -Method Post -Body $body -Headers $headers -SkipCertificateCheck:$Options.SkipCertificateCheck + } + catch { + # Handle any failures based on the configured FailureAction + Invoke-PodeHandleFailure -Message "Failed to send log to Log Insight: $_" -FailureAction $Options.FailureAction + } + } + } + } + } + } + + # Return the logging method configuration to the caller + return @{ + Type = 'LogInsight' + Id = $methodId + Batch = New-PodeLogBatchInfo # Contains batch information if needed + Logger = @() + Arguments = @{ + BaseUrl = $BaseUrl + Id = $Id + FailureAction = $FailureAction + SkipCertificateCheck = $SkipCertificateCheck.IsPresent + AsUTC = $AsUTC.IsPresent + DefaultTag = $DefaultTag + DataFormat = $DataFormat + } + } +} diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index a655a69b3..00a35d262 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -234,84 +234,6 @@ function Get-PodeConfig { return $PodeContext.Server.Configuration } -<# -.SYNOPSIS -Adds a ScriptBlock as Endware to run at the end of each web Request. - -.DESCRIPTION -Adds a ScriptBlock as Endware to run at the end of each web Request. - -.PARAMETER ScriptBlock -The ScriptBlock to add. It will be supplied the current web event. - -.PARAMETER ArgumentList -An array of arguments to supply to the Endware's ScriptBlock. - -.EXAMPLE -Add-PodeEndware -ScriptBlock { /* logic */ } -#> -function Add-PodeEndware { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] - [scriptblock] - $ScriptBlock, - - [Parameter()] - [object[]] - $ArgumentList - ) - begin { - $pipelineItemCount = 0 - } - - process { - $pipelineItemCount++ - } - - end { - if ($pipelineItemCount -gt 1) { - throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) - } - # check for scoped vars - $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState - - # add the scriptblock to array of endware that needs to be run - $PodeContext.Server.Endware += @{ - Logic = $ScriptBlock - UsingVariables = $usingVars - Arguments = $ArgumentList - } - } -} - -<# -.SYNOPSIS -Automatically loads endware ps1 files - -.DESCRIPTION -Automatically loads endware ps1 files from either a /endware folder, or a custom folder. Saves space dot-sourcing them all one-by-one. - -.PARAMETER Path -Optional Path to a folder containing ps1 files, can be relative or literal. - -.EXAMPLE -Use-PodeEndware - -.EXAMPLE -Use-PodeEndware -Path './endware' -#> -function Use-PodeEndware { - [CmdletBinding()] - param( - [Parameter()] - [string] - $Path - ) - - Use-PodeFolder -Path $Path -DefaultPath 'endware' -} - <# .SYNOPSIS Imports a Module into the current, and all runspaces that Pode uses. diff --git a/tests/integration/OpenApi.Tests.ps1 b/tests/integration/OpenApi.Tests.ps1 index cc398cdd4..449390156 100644 --- a/tests/integration/OpenApi.Tests.ps1 +++ b/tests/integration/OpenApi.Tests.ps1 @@ -5,6 +5,10 @@ param() Describe 'OpenAPI integration tests' { BeforeAll { + + $helperPath = (Split-Path -Parent -Path $PSCommandPath) -ireplace 'integration', 'shared' + . "$helperPath/TestHelper.ps1" + $mindyCommonHeaders = @{ 'accept' = 'application/json' 'X-API-KEY' = 'test2-api-key' @@ -19,137 +23,9 @@ Describe 'OpenAPI integration tests' { $PortV3 = 8080 $PortV3_1 = 8081 $scriptPath = "$($PSScriptRoot)\..\..\examples\OpenApi-TuttiFrutti.ps1" - Start-Process (Get-Process -Id $PID).Path -ArgumentList "-NoProfile -File `"$scriptPath`" -PortV3 $PortV3 -PortV3_1 $PortV3_1 -Daemon -IgnoreServerConfig" -NoNewWindow - - function Compare-StringRnLn { - param ( - [string]$InputString1, - [string]$InputString2 - ) - return ($InputString1.Trim() -replace "`r`n|`n|`r", "`n") -eq ($InputString2.Trim() -replace "`r`n|`n|`r", "`n") - } - - function Convert-PsCustomObjectToOrderedHashtable { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [PSCustomObject]$InputObject - ) - begin { - # Define a recursive function within the process block - function Convert-ObjectRecursively { - param ( - [Parameter(Mandatory = $true)] - [System.Object] - $InputObject - ) - - # Initialize an ordered dictionary - $orderedHashtable = [ordered]@{} - - # Loop through each property of the PSCustomObject - foreach ($property in $InputObject.PSObject.Properties) { - # Check if the property value is a PSCustomObject - if ($property.Value -is [PSCustomObject]) { - # Recursively convert the nested PSCustomObject - $orderedHashtable[$property.Name] = Convert-ObjectRecursively -InputObject $property.Value - } - elseif ($property.Value -is [System.Collections.IEnumerable] -and -not ($property.Value -is [string])) { - # If the value is a collection, check each element - $convertedCollection = @() - foreach ($item in $property.Value) { - if ($item -is [PSCustomObject]) { - $convertedCollection += Convert-ObjectRecursively -InputObject $item - } - else { - $convertedCollection += $item - } - } - $orderedHashtable[$property.Name] = $convertedCollection - } - else { - # Add the property name and value to the ordered hashtable - $orderedHashtable[$property.Name] = $property.Value - } - } - - # Return the resulting ordered hashtable - return $orderedHashtable - } - } - process { - # Call the recursive helper function for each input object - Convert-ObjectRecursively -InputObject $InputObject - } - } - - function Compare-Hashtable { - param ( - [object]$Hashtable1, - [object]$Hashtable2 - ) - - # Function to compare two hashtable values - function Compare-Value($value1, $value2) { - # Check if both values are hashtables - if ((($value1 -is [hashtable] -or $value1 -is [System.Collections.Specialized.OrderedDictionary]) -and - ($value2 -is [hashtable] -or $value2 -is [System.Collections.Specialized.OrderedDictionary]))) { - return Compare-Hashtable -Hashtable1 $value1 -Hashtable2 $value2 - } - # Check if both values are arrays - elseif (($value1 -is [Object[]]) -and ($value2 -is [Object[]])) { - if ($value1.Count -ne $value2.Count) { - return $false - } - for ($i = 0; $i -lt $value1.Count; $i++) { - $found = $false - for ($j = 0; $j -lt $value2.Count; $j++) { - if ( Compare-Value $value1[$i] $value2[$j]) { - $found = $true - } - } - if ($found -eq $false) { - return $false - } - } - return $true - } - else { - if ($value1 -is [string] -and $value2 -is [string]) { - return Compare-StringRnLn $value1 $value2 - } - # Check if the values are equal - return $value1 -eq $value2 - } - } + Start-Process (Get-Process -Id $PID).Path -ArgumentList "-NoProfile -File `"$scriptPath`" -PortV3 $PortV3 -PortV3_1 $PortV3_1 -Daemon -IgnoreServerConfig" -NoNewWindow - $keys1 = $Hashtable1.Keys - $keys2 = $Hashtable2.Keys - - # Check if both hashtables have the same keys - if ($keys1.Count -ne $keys2.Count) { - return $false - } - - foreach ($key in $keys1) { - if (! ($Hashtable2.Keys -contains $key)) { - return $false - } - - if ($Hashtable2[$key] -is [hashtable] -or $Hashtable2[$key] -is [System.Collections.Specialized.OrderedDictionary]) { - if (! (Compare-Hashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])) { - return $false - } - } - elseif (!(Compare-Value $Hashtable1[$key] $Hashtable2[$key])) { - return $false - } - } - - return $true - } - - Start-Sleep -Seconds 5 + Wait-ForWebServer -Port $PortV3 } AfterAll { @@ -161,7 +37,6 @@ Describe 'OpenAPI integration tests' { Describe 'OpenAPI' { it 'Open API v3.0.3' { - Start-Sleep -Seconds 10 $fileContent = Get-Content -Path "$PSScriptRoot/specs/OpenApi-TuttiFrutti_3.0.3.json" $webResponse = Invoke-WebRequest -Uri "http://localhost:$($PortV3)/docs/openapi/v3.0" -Method Get diff --git a/tests/integration/Sessions.Tests.ps1 b/tests/integration/Sessions.Tests.ps1 index b4d42f191..2e5aa69b9 100644 --- a/tests/integration/Sessions.Tests.ps1 +++ b/tests/integration/Sessions.Tests.ps1 @@ -5,13 +5,16 @@ param() Describe 'Session Requests' { BeforeAll { + $helperPath = (Split-Path -Parent -Path $PSCommandPath) -ireplace 'integration', 'shared' + . "$helperPath/TestHelper.ps1" + $Port = 8080 $Endpoint = "http://127.0.0.1:$($Port)" Start-Job -Name 'Pode' -ErrorAction Stop -ScriptBlock { Import-Module -Name "$($using:PSScriptRoot)\..\..\src\Pode.psm1" - Start-PodeServer -Quiet -ScriptBlock { + Start-PodeServer -Daemon -ScriptBlock { Add-PodeEndpoint -Address localhost -Port $using:Port -Protocol Http Add-PodeRoute -Method Get -Path '/close' -ScriptBlock { Close-PodeServer @@ -41,7 +44,7 @@ Describe 'Session Requests' { } } - Start-Sleep -Seconds 10 + Wait-ForWebServer -Port $Port } AfterAll { diff --git a/tests/shared/TestHelper.ps1 b/tests/shared/TestHelper.ps1 index 7c72f4b3c..46707409f 100644 --- a/tests/shared/TestHelper.ps1 +++ b/tests/shared/TestHelper.ps1 @@ -1,3 +1,5 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] +param() <# .SYNOPSIS Ensures the Pode assembly is loaded into the current session. @@ -49,6 +51,314 @@ function Import-PodeAssembly { } +<# +.SYNOPSIS + Compares two strings while normalizing line endings. + +.DESCRIPTION + This function trims both input strings and replaces all variations of line endings (`CRLF`, `LF`, `CR`) with a normalized `LF` (`\n`). + It then compares the normalized strings for equality. + +.PARAMETER InputString1 + The first string to compare. + +.PARAMETER InputString2 + The second string to compare. + +.OUTPUTS + [bool] + Returns `$true` if both strings are equal after normalization; otherwise, returns `$false`. + +.EXAMPLE + Compare-StringRnLn -InputString1 "Hello`r`nWorld" -InputString2 "Hello`nWorld" + # Returns: $true + +.EXAMPLE + Compare-StringRnLn -InputString1 "Line1`r`nLine2" -InputString2 "Line1`rLine2" + # Returns: $true + +.NOTES + This function ensures that strings with different line-ending formats are treated as equal if their content is otherwise identical. +#> +function Compare-StringRnLn { + param ( + [string]$InputString1, + [string]$InputString2 + ) + return ($InputString1.Trim() -replace "`r`n|`n|`r", "`n") -eq ($InputString2.Trim() -replace "`r`n|`n|`r", "`n") +} + +<# +.SYNOPSIS + Converts a PSCustomObject into an ordered hashtable. + +.DESCRIPTION + This function recursively converts a PSCustomObject, including nested objects and collections, into an ordered hashtable. + It ensures that all properties are retained while maintaining their original structure. + +.PARAMETER InputObject + The PSCustomObject to be converted into an ordered hashtable. + +.OUTPUTS + [System.Collections.Specialized.OrderedDictionary] + Returns an ordered hashtable representation of the input PSCustomObject. + +.EXAMPLE + $object = [PSCustomObject]@{ Name = "Pode"; Version = "2.0"; Config = [PSCustomObject]@{ Debug = $true } } + Convert-PsCustomObjectToOrderedHashtable -InputObject $object + # Returns: An ordered hashtable representation of $object. + +.EXAMPLE + $object = [PSCustomObject]@{ Users = @([PSCustomObject]@{ Name = "Alice" }, [PSCustomObject]@{ Name = "Bob" }) } + Convert-PsCustomObjectToOrderedHashtable -InputObject $object + # Returns: An ordered hashtable where 'Users' is an array of ordered hashtables. + +.NOTES + This function preserves key order and supports recursive conversion of nested objects and collections. +#> +function Convert-PsCustomObjectToOrderedHashtable { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [PSCustomObject]$InputObject + ) + begin { + # Define a recursive function within the process block + function Convert-ObjectRecursively { + param ( + [Parameter(Mandatory = $true)] + [System.Object] + $InputObject + ) + + # Initialize an ordered dictionary + $orderedHashtable = [ordered]@{} + + # Loop through each property of the PSCustomObject + foreach ($property in $InputObject.PSObject.Properties) { + # Check if the property value is a PSCustomObject + if ($property.Value -is [PSCustomObject]) { + # Recursively convert the nested PSCustomObject + $orderedHashtable[$property.Name] = Convert-ObjectRecursively -InputObject $property.Value + } + elseif ($property.Value -is [System.Collections.IEnumerable] -and -not ($property.Value -is [string])) { + # If the value is a collection, check each element + $convertedCollection = @() + foreach ($item in $property.Value) { + if ($item -is [PSCustomObject]) { + $convertedCollection += Convert-ObjectRecursively -InputObject $item + } + else { + $convertedCollection += $item + } + } + $orderedHashtable[$property.Name] = $convertedCollection + } + else { + # Add the property name and value to the ordered hashtable + $orderedHashtable[$property.Name] = $property.Value + } + } + + # Return the resulting ordered hashtable + return $orderedHashtable + } + } + process { + # Call the recursive helper function for each input object + Convert-ObjectRecursively -InputObject $InputObject + } +} + +<# +.SYNOPSIS + Compares two hashtables to determine if they are equal. + +.DESCRIPTION + This function recursively compares two hashtables, checking whether they contain the same keys and values. + It also handles nested hashtables and arrays, ensuring deep comparison of all elements. + +.PARAMETER Hashtable1 + The first hashtable to compare. + +.PARAMETER Hashtable2 + The second hashtable to compare. + +.OUTPUTS + [bool] + Returns `$true` if both hashtables are equal, otherwise returns `$false`. + +.EXAMPLE + $hash1 = @{ Name = "Pode"; Version = "2.0"; Config = @{ Debug = $true } } + $hash2 = @{ Name = "Pode"; Version = "2.0"; Config = @{ Debug = $true } } + Compare-Hashtable -Hashtable1 $hash1 -Hashtable2 $hash2 + # Returns: $true + +.EXAMPLE + $hash1 = @{ Name = "Pode"; Version = "2.0" } + $hash2 = @{ Name = "Pode"; Version = "2.1" } + Compare-Hashtable -Hashtable1 $hash1 -Hashtable2 $hash2 + # Returns: $false + +#> +function Compare-Hashtable { + param ( + [object]$Hashtable1, + [object]$Hashtable2 + ) + + # Function to compare two hashtable values + function Compare-Value($value1, $value2) { + # Check if both values are hashtables + if ((($value1 -is [hashtable] -or $value1 -is [System.Collections.Specialized.OrderedDictionary]) -and + ($value2 -is [hashtable] -or $value2 -is [System.Collections.Specialized.OrderedDictionary]))) { + return Compare-Hashtable -Hashtable1 $value1 -Hashtable2 $value2 + } + # Check if both values are arrays + elseif (($value1 -is [Object[]]) -and ($value2 -is [Object[]])) { + if ($value1.Count -ne $value2.Count) { + return $false + } + for ($i = 0; $i -lt $value1.Count; $i++) { + $found = $false + for ($j = 0; $j -lt $value2.Count; $j++) { + if ( Compare-Value $value1[$i] $value2[$j]) { + $found = $true + } + } + if ($found -eq $false) { + return $false + } + } + return $true + } + else { + if ($value1 -is [string] -and $value2 -is [string]) { + return Compare-StringRnLn $value1 $value2 + } + # Check if the values are equal + return $value1 -eq $value2 + } + } + + $keys1 = $Hashtable1.Keys + $keys2 = $Hashtable2.Keys + + # Check if both hashtables have the same keys + if ($keys1.Count -ne $keys2.Count) { + return $false + } + + foreach ($key in $keys1) { + if (! ($Hashtable2.Keys -contains $key)) { + return $false + } + + if ($Hashtable2[$key] -is [hashtable] -or $Hashtable2[$key] -is [System.Collections.Specialized.OrderedDictionary]) { + if (! (Compare-Hashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])) { + return $false + } + } + elseif (!(Compare-Value $Hashtable1[$key] $Hashtable2[$key])) { + return $false + } + } + + return $true +} + + +<# +.SYNOPSIS + Waits for a web server to become available at a specified URI or port. + +.DESCRIPTION + This function continuously checks if a web server is online by sending an HTTP request. + It retries until the server responds with a 200 status code or a timeout is reached. + +.PARAMETER Uri + The full URI to check (e.g., "http://127.0.0.1:5000"). If not provided, defaults to "http://localhost:$Port". + +.PARAMETER Port + The port on which the web server is expected to be available. If no URI is provided, the function constructs a default URI using "http://localhost:$Port". + +.PARAMETER Timeout + The maximum number of seconds to wait before timing out. Default is 60 seconds. + +.PARAMETER Interval + The number of seconds to wait between retries. Default is 2 seconds. + +.OUTPUTS + Boolean - Returns $true if the server is online, otherwise $false. + +.EXAMPLE + Wait-ForWebServer -Port 8080 -Timeout 30 -Interval 2 + + Waits up to 30 seconds for the web server on port 8080 to come online. + +.EXAMPLE + Wait-ForWebServer -Uri "http://127.0.0.1:5000" -Timeout 45 + + Waits up to 45 seconds for the web server at "http://127.0.0.1:5000" to respond. + +.NOTES + Author: ChatGPT + This function ensures that the web server is fully responding, not just that the port is open. +#> +function Wait-ForWebServer { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Position = 0)] + [string]$Uri, + + [Parameter(Position = 1)] + [int]$Port, + + [Parameter()] + [int]$Timeout = 60, + + [Parameter()] + [int]$Interval = 2 + ) + + # Determine the final URI: If no URI is provided, use "http://localhost:$Port" + if (-not $Uri) { + if ($Port -gt 0) { + $Uri = "http://localhost:$Port" + } + else { + return $false + } + } + + $MaxRetries = [math]::Ceiling($Timeout / $Interval) + $RetryCount = 0 + + while ($RetryCount -lt $MaxRetries) { + try { + # Send a request but ignore status codes (any response means the server is online) + $null = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 3 + Write-Host "Webserver is online at $Uri" + return $true + } + catch { + if ($_.Exception.Response -and $_.Exception.Response.StatusCode -eq 404) { + return $true + } + else { + Write-Host "Waiting for webserver to come online at $Uri... (Attempt $($RetryCount+1)/$MaxRetries)" + } + } + + Start-Sleep -Seconds $Interval + $RetryCount++ + } + + return $false +} + + function Compare-Hashtable { diff --git a/tests/unit/Authentication.Tests.ps1 b/tests/unit/Authentication.Tests.ps1 index 38190f523..985f6b9fb 100644 --- a/tests/unit/Authentication.Tests.ps1 +++ b/tests/unit/Authentication.Tests.ps1 @@ -190,4 +190,4 @@ Describe 'Expand-PodeAuthMerge Tests' { { Expand-PodeAuthMerge -Names @('NonExistentAuth') } | Should -Throw } -} +} \ No newline at end of file diff --git a/tests/unit/Helpers.Tests.ps1 b/tests/unit/Helpers.Tests.ps1 index e58087a02..0fdd346b4 100644 --- a/tests/unit/Helpers.Tests.ps1 +++ b/tests/unit/Helpers.Tests.ps1 @@ -1079,6 +1079,7 @@ Describe 'Close-PodeServerInternal' { Mock Remove-PodePSDrive {} Mock Write-PodeHost {} Mock Close-PodeCancellationTokenRequest {} + Mock Disable-PodeLog { } } @@ -1744,4 +1745,4 @@ Describe 'ConvertTo-PodeYamlInternal Tests' { $result | Should -Be '' } } -} +} \ No newline at end of file diff --git a/tests/unit/Logging.Tests.ps1 b/tests/unit/Logging.Tests.ps1 index 1353ea076..4e6e62302 100644 --- a/tests/unit/Logging.Tests.ps1 +++ b/tests/unit/Logging.Tests.ps1 @@ -5,59 +5,111 @@ BeforeAll { $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' + if (!([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Pode' })) { + + # fetch the .net version and the libs path + $version = [System.Environment]::Version.Major + $libsPath = Join-Path -Path $src -ChildPath 'Libs' + + # filter .net dll folders based on version above, and get path for latest version found + if (![string]::IsNullOrWhiteSpace($version)) { + $netFolder = Get-ChildItem -Path $libsPath -Directory -Force | + Where-Object { $_.Name -imatch "net[1-$($version)]" } | + Sort-Object -Property Name -Descending | + Select-Object -First 1 -ExpandProperty FullName + } + + # use netstandard if no folder found + if ([string]::IsNullOrWhiteSpace($netFolder)) { + $netFolder = "$($libsPath)/netstandard2.0" + } + + # append Pode.dll and mount + Add-Type -LiteralPath "$($netFolder)/Pode.dll" -ErrorAction Stop + } + [Pode.PodeLogger]::Enabled = $true + } Describe 'Get-PodeLogger' { It 'Returns null as the logger does not exist' { - $PodeContext = @{ 'Server' = @{ 'Logging' = @{ 'Types' = @{}; } }; } - Get-PodeLogger -Name 'test' | Should -Be $null + $PodeContext = @{ 'Server' = @{ 'Logging' = @{ 'Type' = @{}; } }; } + { Get-PodeLogger -Name 'test' } | Should -Throw $PodeLocale.loggerDoesNotExistExceptionMessage } It 'Returns terminal logger for name' { - $PodeContext = @{ 'Server' = @{ 'Logging' = @{ 'Types' = @{ 'test' = $null }; } }; } + $PodeContext = @{ 'Server' = @{ 'Logging' = @{ 'Type' = @{ 'test' = $null }; } }; } $result = (Get-PodeLogger -Name 'test') $result | Should -Be $null } It 'Returns custom logger for name' { - $PodeContext = @{ 'Server' = @{ 'Logging' = @{ 'Types' = @{ 'test' = { Write-Host 'hello' } }; } }; } + $PodeContext = @{ 'Server' = @{ 'Logging' = @{ 'Type' = @{ 'test' = { Write-PodeHost 'hello' } }; } }; } $result = (Get-PodeLogger -Name 'test') $result | Should -Not -Be $null - $result.ToString() | Should -Be ({ Write-Host 'hello' }).ToString() + $result.ToString() | Should -Be ({ Write-PodeHost 'hello' }).ToString() } } Describe 'Write-PodeLog' { + BeforeEach { + $PodeContext = @{ + Server = @{ + Logging = @{ + LogsToProcess = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + Type = @{ + test = @{ + Standard = $false + } + } + } + } + } + } It 'Does nothing when logging disabled' { Mock Test-PodeLoggerEnabled { return $false } - $PodeContext = @{ LogsToProcess = [System.Collections.ArrayList]::new() } Write-PodeLog -Name 'test' -InputObject 'test' - $PodeContext.LogsToProcess.Count | Should -Be 0 + [Pode.PodeLogger]::Count | Should -Be 0 } It 'Adds a log item' { Mock Test-PodeLoggerEnabled { return $true } - $PodeContext = @{ LogsToProcess = [System.Collections.ArrayList]::new() } - + Mock Get-PodeLoggingLevel { return @('Informational') } Write-PodeLog -Name 'test' -InputObject 'test' - $PodeContext.LogsToProcess.Count | Should -Be 1 - $PodeContext.LogsToProcess[0].Name | Should -Be 'test' - $PodeContext.LogsToProcess[0].Item | Should -Be 'test' + [Pode.PodeLogger]::Count | Should -Be 1 + $item = [Pode.PodeLogger]::Dequeue() + $item.Name | Should -Be 'test' + $item.Item | Should -Be 'test' } } Describe 'Write-PodeErrorLog' { + BeforeEach { + $PodeContext = @{ + Server = @{ + Logging = @{ + Type = @{ + ([Pode.PodeLogger]::ErrorLogName) = @{ + Standard = $false + } + test = @{ + Standard = $false + } + } + } + } + } + } It 'Does nothing when logging disabled' { Mock Test-PodeLoggerEnabled { return $false } - $PodeContext = @{ LogsToProcess = [System.Collections.ArrayList]::new() } Write-PodeLog -Name 'test' -InputObject 'test' - $PodeContext.LogsToProcess.Count | Should -Be 0 + [Pode.PodeLogger]::Count | Should -Be 0 } It 'Adds an error log item' { @@ -65,17 +117,18 @@ Describe 'Write-PodeErrorLog' { Mock Get-PodeLogger { return @{ Arguments = @{ Levels = @('Error') } - } } + } + } - $PodeContext = @{ LogsToProcess = [System.Collections.ArrayList]::new() } try { throw 'some error' } catch { Write-PodeErrorLog -ErrorRecord $Error[0] } - $PodeContext.LogsToProcess.Count | Should -Be 1 - $PodeContext.LogsToProcess[0].Item.Message | Should -Be 'some error' + [Pode.PodeLogger]::Count | Should -Be 1 + $item = [Pode.PodeLogger]::Dequeue() + $item.Item.Message | Should -Be 'some error' } It 'Adds an exception log item' { @@ -85,13 +138,12 @@ Describe 'Write-PodeErrorLog' { } } } - $PodeContext = @{ LogsToProcess = [System.Collections.ArrayList]::new() } - $exp = [exception]::new('some error') Write-PodeErrorLog -Exception $exp - $PodeContext.LogsToProcess.Count | Should -Be 1 - $PodeContext.LogsToProcess[0].Item.Message | Should -Be 'some error' + [Pode.PodeLogger]::Count | Should -Be 1 + $item = [Pode.PodeLogger]::Dequeue() + $item.Item.Message | Should -Be 'some error' } It 'Does not log as Verbose not allowed' { @@ -101,24 +153,22 @@ Describe 'Write-PodeErrorLog' { } } } - $PodeContext = @{ LogsToProcess = [System.Collections.ArrayList]::new() } - $exp = [exception]::new('some error') Write-PodeErrorLog -Exception $exp -Level Verbose - - $PodeContext.LogsToProcess.Count | Should -Be 0 + $item = [Pode.PodeLogger]::Dequeue() + [Pode.PodeLogger]::Count | Should -Be 0 } } -Describe 'Get-PodeRequestLoggingName' { +Describe '[Pode.PodeLogger]::RequestLogName' { It 'Returns logger name' { - Get-PodeRequestLoggingName | Should -Be '__pode_log_requests__' + [Pode.PodeLogger]::RequestLogName | Should -Be '__pode_log_requests__' } } -Describe 'Get-PodeErrorLoggingName' { +Describe '[Pode.PodeLogger]::ErrorLogName' { It 'Returns logger name' { - Get-PodeErrorLoggingName | Should -Be '__pode_log_errors__' + [Pode.PodeLogger]::ErrorLogName | Should -Be '__pode_log_errors__' } } diff --git a/tests/unit/Schedules.Tests.ps1 b/tests/unit/Schedules.Tests.ps1 index 4f854672a..ce426bbe0 100644 --- a/tests/unit/Schedules.Tests.ps1 +++ b/tests/unit/Schedules.Tests.ps1 @@ -129,7 +129,7 @@ Describe 'Add-PodeSchedule' { $start = ([DateTime]::Now.AddHours(3)) $end = ([DateTime]::Now.AddHours(5)) - Add-PodeSchedule -Name 'test' -Cron @('@minutely', '@hourly') -ScriptBlock { Write-Host 'hello' } -StartTime $start -EndTime $end + Add-PodeSchedule -Name 'test' -Cron @('@minutely', '@hourly') -ScriptBlock { Write-Host 'hello' } -StartTime $start -EndTime $end $schedule = $PodeContext.Schedules.Items['test'] $schedule | Should -Not -Be $null diff --git a/tests/unit/Server.Tests.ps1 b/tests/unit/Server.Tests.ps1 index 8234f02d3..f5ceb62cc 100644 --- a/tests/unit/Server.Tests.ps1 +++ b/tests/unit/Server.Tests.ps1 @@ -26,7 +26,7 @@ Describe 'Start-PodeInternalServer' { Mock Invoke-PodeScriptBlock {} Mock New-PodeRunspaceState {} Mock New-PodeRunspacePool {} - Mock Start-PodeLoggingRunspace {} + Mock Start-PodeLoggerDispatcher {} Mock Start-PodeTimerRunspace {} Mock Start-PodeScheduleRunspace {} Mock Start-PodeGuiRunspace {} @@ -45,6 +45,8 @@ Describe 'Start-PodeInternalServer' { Mock Add-PodeScopedVariablesInbuilt {} Mock Write-PodeHost {} Mock Show-PodeConsoleInfo {} + Mock Write-PodeErrorLog { } + Mock Write-PodeLog { } } It 'Calls one-off script logic' { @@ -114,10 +116,10 @@ Describe 'Restart-PodeInternalServer' { Mock Close-PodeRunspace {} Mock Remove-PodePSDrive {} Mock Open-PodeConfiguration { return $null } - Mock Start-PodeInternalServer {} - Mock Write-PodeErrorLog {} - Mock Close-PodeDisposable {} - Mock Invoke-PodeEvent {} + Mock Start-PodeInternalServer { } + Mock Write-PodeErrorLog { } + Mock Close-PodeDisposable { } + Mock Invoke-PodeEvent { } } It 'Resetting the server values' { @@ -135,7 +137,9 @@ Describe 'Restart-PodeInternalServer' { key = @{} } Logging = @{ - Types = @{ 'key' = 'value' } + Type = @{ 'key' = 'value' } + LogsToProcess = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new() + Method = @{ 'key' = 'value' } } Middleware = @{ 'key' = 'value' } Endpoints = @{ 'key' = 'value' } @@ -272,7 +276,7 @@ Describe 'Restart-PodeInternalServer' { Restart-PodeInternalServer | Out-Null $PodeContext.Server.Routes['GET'].Count | Should -Be 0 - $PodeContext.Server.Logging.Types.Count | Should -Be 0 + $PodeContext.Server.Logging.Type.Count | Should -Be 0 $PodeContext.Server.Middleware.Count | Should -Be 0 $PodeContext.Server.Endware.Count | Should -Be 0 $PodeContext.Server.Sessions.Count | Should -Be 0 diff --git a/tests/unit/Serverless.Tests.ps1 b/tests/unit/Serverless.Tests.ps1 index 66f29c59c..4509889f3 100644 --- a/tests/unit/Serverless.Tests.ps1 +++ b/tests/unit/Serverless.Tests.ps1 @@ -6,6 +6,7 @@ BeforeAll { $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' + Mock Test-PodeLoggerEnabled { return $false } } Describe 'Start-PodeAzFuncServer' { BeforeAll { @@ -25,6 +26,8 @@ Describe 'Start-PodeAzFuncServer' { Mock Set-PodeServerHeader { } Mock Set-PodeResponseStatus { } Mock Update-PodeServerRequestMetric { } + Mock Write-PodeLog {} + Mock Write-PodeErrorLog {} } It 'Throws error for null data' { { Start-PodeAzFuncServer -Data $null } | Should -Throw -ErrorId 'ParameterArgumentValidationErrorNullNotAllowed,Start-PodeAzFuncServer' @@ -155,7 +158,11 @@ Describe 'Start-PodeAwsLambdaServer' { Mock Invoke-PodeEndware { } Mock Set-PodeServerHeader { } Mock Set-PodeResponseStatus { } - Mock Update-PodeServerRequestMetric { } } + Mock Update-PodeServerRequestMetric { } + Mock Write-PodeLog {} + Mock Write-PodeErrorLog {} + + } It 'Throws error for null data' { { Start-PodeAwsLambdaServer -Data $null } | Should -Throw -ErrorId 'ParameterArgumentValidationErrorNullNotAllowed,Start-PodeAwsLambdaServer' diff --git a/tests/unit/Timers.Tests.ps1 b/tests/unit/Timers.Tests.ps1 index f8607c73f..d25f7e4d0 100644 --- a/tests/unit/Timers.Tests.ps1 +++ b/tests/unit/Timers.Tests.ps1 @@ -8,6 +8,7 @@ BeforeAll { Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' + $PodeContext = @{ 'Server' = $null; } } @@ -71,7 +72,7 @@ Describe 'Add-PodeTimer' { It 'Adds new timer to session with no limit' { $PodeContext = @{ 'Timers' = @{ Items = @{} }; } - Add-PodeTimer -Name 'test' -Interval 1 -ScriptBlock { Write-Host 'hello' } -Limit 0 -Skip 1 + Add-PodeTimer -Name 'test' -Interval 1 -ScriptBlock { Write-Host 'hello' } -Limit 0 -Skip 1 $timer = $PodeContext.Timers.Items['test'] $timer | Should -Not -Be $null From 00e1890222380eb49b37f74de08e367e8bb42cc2 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 08:49:03 -0800 Subject: [PATCH 11/12] modified: src/Locales/ar/Pode.psd1 modified: src/Locales/de/Pode.psd1 modified: src/Locales/en-us/Pode.psd1 modified: src/Locales/en/Pode.psd1 modified: src/Locales/es/Pode.psd1 modified: src/Locales/fr/Pode.psd1 modified: src/Locales/it/Pode.psd1 modified: src/Locales/ja/Pode.psd1 modified: src/Locales/ko/Pode.psd1 modified: src/Locales/nl/Pode.psd1 modified: src/Locales/pl/Pode.psd1 modified: src/Locales/pt/Pode.psd1 modified: src/Locales/zh/Pode.psd1 modified: tests/integration/OpenApi.Tests.ps1 modified: tests/integration/Sessions.Tests.ps1 modified: tests/shared/TestHelper.ps1 modified: tests/unit/Convert.Tests.ps1 modified: tests/unit/Utility.Tests.ps1 modified: tests/unit/_.Tests.ps1 --- src/Locales/ar/Pode.psd1 | 1 - src/Locales/de/Pode.psd1 | 1 - src/Locales/en-us/Pode.psd1 | 1 - src/Locales/en/Pode.psd1 | 1 - src/Locales/es/Pode.psd1 | 1 - src/Locales/fr/Pode.psd1 | 1 - src/Locales/it/Pode.psd1 | 1 - src/Locales/ja/Pode.psd1 | 1 - src/Locales/ko/Pode.psd1 | 1 - src/Locales/nl/Pode.psd1 | 1 - src/Locales/pl/Pode.psd1 | 1 - src/Locales/pt/Pode.psd1 | 3 +- src/Locales/zh/Pode.psd1 | 1 - tests/integration/OpenApi.Tests.ps1 | 14 ++-- tests/integration/Sessions.Tests.ps1 | 2 +- tests/shared/TestHelper.ps1 | 104 ++++++--------------------- tests/unit/Convert.Tests.ps1 | 26 +++---- tests/unit/Utility.Tests.ps1 | 6 +- tests/unit/_.Tests.ps1 | 6 +- 19 files changed, 49 insertions(+), 124 deletions(-) diff --git a/src/Locales/ar/Pode.psd1 b/src/Locales/ar/Pode.psd1 index c072b0147..dfe3e578d 100644 --- a/src/Locales/ar/Pode.psd1 +++ b/src/Locales/ar/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "الدالة '{0}' لا تقبل مصفوفة كمدخل لأنبوب البيانات." unsupportedStreamCompressionEncodingExceptionMessage = 'تشفير الضغط غير مدعوم للتشفير {0}' localEndpointConflictExceptionMessage = "تم تعريف كل من '{0}' و '{1}' كنقاط نهاية محلية لـ OpenAPI، لكن يُسمح فقط بنقطة نهاية محلية واحدة لكل تعريف API." - deprecatedFunctionWarningMessage = "تحذير: الدالة '{0}' مهملة وستتم إزالتها في الإصدارات المستقبلية. يُرجى استخدام الدالة '{1}' بدلاً منها." serviceAlreadyRegisteredException = "الخدمة '{0}' مسجلة بالفعل." serviceIsNotRegisteredException = "الخدمة '{0}' غير مسجلة." serviceCommandFailedException = "فشل الأمر '{0}' في الخدمة '{1}'." diff --git a/src/Locales/de/Pode.psd1 b/src/Locales/de/Pode.psd1 index 1dc4a5c11..6867a59a1 100644 --- a/src/Locales/de/Pode.psd1 +++ b/src/Locales/de/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Die Funktion '{0}' akzeptiert kein Array als Pipeline-Eingabe." unsupportedStreamCompressionEncodingExceptionMessage = 'Die Stream-Komprimierungskodierung wird nicht unterstützt: {0}' localEndpointConflictExceptionMessage = "Sowohl '{0}' als auch '{1}' sind als lokale OpenAPI-Endpunkte definiert, aber es ist nur ein lokaler Endpunkt pro API-Definition erlaubt." - deprecatedFunctionWarningMessage = "WARNUNG: Die Funktion '{0}' ist veraltet und wird in zukünftigen Versionen entfernt. Bitte verwenden Sie stattdessen die Funktion '{1}'." serviceAlreadyRegisteredException = "Der Dienst '{0}' ist bereits registriert." serviceIsNotRegisteredException = "Der Dienst '{0}' ist nicht registriert." serviceCommandFailedException = "Der Dienstbefehl '{0}' ist bei dem Dienst '{1}' fehlgeschlagen." diff --git a/src/Locales/en-us/Pode.psd1 b/src/Locales/en-us/Pode.psd1 index 31e0c8d0e..c2f78bf88 100644 --- a/src/Locales/en-us/Pode.psd1 +++ b/src/Locales/en-us/Pode.psd1 @@ -334,7 +334,6 @@ rateLimitRuleDoesNotExistExceptionMessage = "A rate limit rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An access limit rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An access limit rule with the name '{0}' does not exist." - deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." loggerDoesNotExistExceptionMessage = "Logger '{0}' does not exist." missingKeyForAlgorithmExceptionMessage = 'A {0} key is required for {1} algorithms ({2}).' jwtIssuedInFutureExceptionMessage = "The JWT's 'iat' (Issued At) timestamp is set in the future. The token is not valid yet." diff --git a/src/Locales/en/Pode.psd1 b/src/Locales/en/Pode.psd1 index a6b71d0db..3c34152ab 100644 --- a/src/Locales/en/Pode.psd1 +++ b/src/Locales/en/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "The function '{0}' does not accept an array as pipeline input." unsupportedStreamCompressionEncodingExceptionMessage = 'Unsupported stream compression encoding: {0}' localEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition." - deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." serviceAlreadyRegisteredException = "Service '{0}' is already registered." serviceIsNotRegisteredException = "Service '{0}' is not registered." serviceCommandFailedException = "Service command '{0}' failed on service '{1}'." diff --git a/src/Locales/es/Pode.psd1 b/src/Locales/es/Pode.psd1 index 8a8b37f43..98da8b5dc 100644 --- a/src/Locales/es/Pode.psd1 +++ b/src/Locales/es/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La función '{0}' no acepta una matriz como entrada de canalización." unsupportedStreamCompressionEncodingExceptionMessage = 'La codificación de compresión de transmisión no es compatible: {0}' localEndpointConflictExceptionMessage = "Tanto '{0}' como '{1}' están definidos como puntos finales locales de OpenAPI, pero solo se permite un punto final local por definición de API." - deprecatedFunctionWarningMessage = "ADVERTENCIA: La función '{0}' está obsoleta y será eliminada en futuras versiones. Por favor, use la función '{1}' en su lugar." serviceAlreadyRegisteredException = "El servicio '{0}' ya está registrado." serviceIsNotRegisteredException = "El servicio '{0}' no está registrado." serviceCommandFailedException = "El comando del servicio '{0}' falló en el servicio '{1}'." diff --git a/src/Locales/fr/Pode.psd1 b/src/Locales/fr/Pode.psd1 index 156d694e3..7703c07f0 100644 --- a/src/Locales/fr/Pode.psd1 +++ b/src/Locales/fr/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La fonction '{0}' n'accepte pas un tableau en tant qu'entrée de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = "La compression de flux {0} n'est pas prise en charge." localEndpointConflictExceptionMessage = "Les deux '{0}' et '{1}' sont définis comme des points de terminaison locaux pour OpenAPI, mais un seul point de terminaison local est autorisé par définition d'API." - deprecatedFunctionWarningMessage = "AVERTISSEMENT : La fonction '{0}' est obsolète et sera supprimée dans les versions futures. Veuillez utiliser la fonction '{1}' à la place." serviceAlreadyRegisteredException = "Le service '{0}' est déjà enregistré." serviceIsNotRegisteredException = "Le service '{0}' n'est pas enregistré." serviceCommandFailedException = "La commande de service '{0}' a échoué sur le service '{1}'." diff --git a/src/Locales/it/Pode.psd1 b/src/Locales/it/Pode.psd1 index ccb3bc189..33801aea5 100644 --- a/src/Locales/it/Pode.psd1 +++ b/src/Locales/it/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La funzione '{0}' non accetta una matrice come input della pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'La compressione dello stream non è supportata per la codifica {0}' localEndpointConflictExceptionMessage = "Sia '{0}' che '{1}' sono definiti come endpoint locali OpenAPI, ma è consentito solo un endpoint locale per definizione API." - deprecatedFunctionWarningMessage = "AVVISO: La funzione '{0}' è obsoleta e verrà rimossa nelle versioni future. Si prega di utilizzare la funzione '{1}' al suo posto." serviceAlreadyRegisteredException = "Il servizio '{0}' è già registrato." serviceIsNotRegisteredException = "Il servizio '{0}' non è registrato." serviceCommandFailedException = "Il comando '{0}' è fallito sul servizio '{1}'." diff --git a/src/Locales/ja/Pode.psd1 b/src/Locales/ja/Pode.psd1 index 9fa67d807..b05504621 100644 --- a/src/Locales/ja/Pode.psd1 +++ b/src/Locales/ja/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "関数 '{0}' は配列をパイプライン入力として受け付けません。" unsupportedStreamCompressionEncodingExceptionMessage = 'サポートされていないストリーム圧縮エンコーディングが提供されました: {0}' localEndpointConflictExceptionMessage = "'{0}' と '{1}' は OpenAPI のローカルエンドポイントとして定義されていますが、API 定義ごとに 1 つのローカルエンドポイントのみ許可されます。" - deprecatedFunctionWarningMessage = "警告: 関数 '{0}' は廃止され、将来のリリースで削除されます。代わりに '{1}' 関数を使用してください。" serviceAlreadyRegisteredException = "サービス '{0}' はすでに登録されています。" serviceIsNotRegisteredException = "サービス '{0}' は登録されていません。" serviceCommandFailedException = "サービスコマンド '{0}' はサービス '{1}' で失敗しました。" diff --git a/src/Locales/ko/Pode.psd1 b/src/Locales/ko/Pode.psd1 index 0b06c579a..1e9fdedbb 100644 --- a/src/Locales/ko/Pode.psd1 +++ b/src/Locales/ko/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "함수 '{0}'은(는) 배열을 파이프라인 입력으로 받지 않습니다." unsupportedStreamCompressionEncodingExceptionMessage = '지원되지 않는 스트림 압축 인코딩: {0}' localEndpointConflictExceptionMessage = "'{0}' 와 '{1}' 는 OpenAPI 로컬 엔드포인트로 정의되었지만, API 정의당 하나의 로컬 엔드포인트만 허용됩니다." - deprecatedFunctionWarningMessage = "경고: 함수 '{0}'는 더 이상 지원되지 않으며, 향후 릴리스에서 제거될 예정입니다. 대신 '{1}' 함수를 사용하세요." serviceAlreadyRegisteredException = "서비스 '{0}'가 이미 등록되었습니다." serviceIsNotRegisteredException = "서비스 '{0}'가 등록되지 않았습니다." serviceCommandFailedException = "서비스 명령 '{0}' 이(가) 서비스 '{1}' 에서 실패했습니다." diff --git a/src/Locales/nl/Pode.psd1 b/src/Locales/nl/Pode.psd1 index 3eb02b8bb..7997d9a10 100644 --- a/src/Locales/nl/Pode.psd1 +++ b/src/Locales/nl/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "De functie '{0}' accepteert geen array als pipeline-invoer." unsupportedStreamCompressionEncodingExceptionMessage = 'Niet-ondersteunde streamcompressie-encodering: {0}' localEndpointConflictExceptionMessage = "Zowel '{0}' als '{1}' zijn gedefinieerd als lokale OpenAPI-eindpunten, maar er is slechts één lokaal eindpunt per API-definitie toegestaan." - deprecatedFunctionWarningMessage = "WAARSCHUWING: De functie '{0}' is verouderd en zal in toekomstige versies worden verwijderd. Gebruik in plaats daarvan de functie '{1}'." serviceAlreadyRegisteredException = "De service '{0}' is al geregistreerd." serviceIsNotRegisteredException = "De service '{0}' is niet geregistreerd." serviceCommandFailedException = "De serviceopdracht '{0}' is mislukt op de service '{1}'." diff --git a/src/Locales/pl/Pode.psd1 b/src/Locales/pl/Pode.psd1 index 4614d151f..361d44d5b 100644 --- a/src/Locales/pl/Pode.psd1 +++ b/src/Locales/pl/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Funkcja '{0}' nie akceptuje tablicy jako wejścia potoku." unsupportedStreamCompressionEncodingExceptionMessage = 'Kodowanie kompresji strumienia nie jest obsługiwane: {0}' localEndpointConflictExceptionMessage = "Zarówno '{0}', jak i '{1}' są zdefiniowane jako lokalne punkty końcowe OpenAPI, ale na jedną definicję API dozwolony jest tylko jeden lokalny punkt końcowy." - deprecatedFunctionWarningMessage = "OSTRZEŻENIE: Funkcja '{0}' jest przestarzała i zostanie usunięta w przyszłych wersjach. Użyj funkcji '{1}' zamiast niej." serviceAlreadyRegisteredException = "Usługa '{0}' jest już zarejestrowana." serviceIsNotRegisteredException = "Usługa '{0}' nie jest zarejestrowana." serviceCommandFailedException = "Polecenie serwisu '{0}' nie powiodło się w serwisie '{1}'." diff --git a/src/Locales/pt/Pode.psd1 b/src/Locales/pt/Pode.psd1 index 752d5dda3..774cd0401 100644 --- a/src/Locales/pt/Pode.psd1 +++ b/src/Locales/pt/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "A função '{0}' não aceita uma matriz como entrada de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'A codificação de compressão de fluxo não é suportada.' localEndpointConflictExceptionMessage = "Tanto '{0}' quanto '{1}' estão definidos como endpoints locais do OpenAPI, mas apenas um endpoint local é permitido por definição de API." - deprecatedFunctionWarningMessage = "AVISO: A função '{0}' está obsoleta e será removida em versões futuras. Por favor, use a função '{1}' em seu lugar." serviceAlreadyRegisteredException = "O serviço '{0}' já está registrado." serviceIsNotRegisteredException = "O serviço '{0}' não está registrado." serviceCommandFailedException = "O comando do serviço '{0}' falhou no serviço '{1}'." @@ -346,7 +345,7 @@ jwtInvalidJtiExceptionMessage = "A reivindicação 'jti' (JWT ID) do JWT é inválida ou está ausente. Um identificador único válido é necessário." jwtAlgorithmMismatchExceptionMessage = 'Incompatibilidade de algoritmo JWT: Esperado {0}, encontrado {1}.' jwtMissingJtiExceptionMessage = "O JWT está sem a reivindicação obrigatória 'jti' (JWT ID)." - deprecatedFunctionWarningMessage = "警告: 函数 '{0}' 已被弃用,并将在未来版本中移除。请改用函数 '{1}'。" + deprecatedFunctionWarningMessage = "AVISO: A função '{0}' está obsoleta e será removida em versões futuras. Por favor, use a função '{1}' em seu lugar." unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algoritmo desconhecido ou formato PEM inválido.' unknownAlgorithmWithKeySizeExceptionMessage = 'Algoritmo {0} desconhecido (Tamanho da chave: {1} bits).' jwtCertificateAuthNotSupportedExceptionMessage = 'A autenticação de certificado JWT é suportada apenas no PowerShell 7.0 ou superior.' diff --git a/src/Locales/zh/Pode.psd1 b/src/Locales/zh/Pode.psd1 index 90305fe01..6cc637ccc 100644 --- a/src/Locales/zh/Pode.psd1 +++ b/src/Locales/zh/Pode.psd1 @@ -292,7 +292,6 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "函数 '{0}' 不接受数组作为管道输入。" unsupportedStreamCompressionEncodingExceptionMessage = '不支持的流压缩编码: {0}' localEndpointConflictExceptionMessage = "'{0}' 和 '{1}' 都被定义为 OpenAPI 的本地端点,但每个 API 定义仅允许一个本地端点。" - deprecatedFunctionWarningMessage = "警告:函数 '{0}' 已被弃用,并将在未来版本中移除。请改用 '{1}' 函数。" serviceAlreadyRegisteredException = "服务 '{0}' 已经注册。" serviceIsNotRegisteredException = "服务 '{0}' 未注册。" serviceCommandFailedException = "服务命令 '{0}' 在服务 '{1}' 上失败。" diff --git a/tests/integration/OpenApi.Tests.ps1 b/tests/integration/OpenApi.Tests.ps1 index 449390156..a200313bf 100644 --- a/tests/integration/OpenApi.Tests.ps1 +++ b/tests/integration/OpenApi.Tests.ps1 @@ -25,7 +25,7 @@ Describe 'OpenAPI integration tests' { $scriptPath = "$($PSScriptRoot)\..\..\examples\OpenApi-TuttiFrutti.ps1" Start-Process (Get-Process -Id $PID).Path -ArgumentList "-NoProfile -File `"$scriptPath`" -PortV3 $PortV3 -PortV3_1 $PortV3_1 -Daemon -IgnoreServerConfig" -NoNewWindow - Wait-ForWebServer -Port $PortV3 + Wait-PodeForWebServer -Port $PortV3 } AfterAll { @@ -42,15 +42,15 @@ Describe 'OpenAPI integration tests' { $webResponse = Invoke-WebRequest -Uri "http://localhost:$($PortV3)/docs/openapi/v3.0" -Method Get $json = $webResponse.Content if ( $PSVersionTable.PSEdition -eq 'Desktop') { - $expected = $fileContent | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable - $response = $json | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $expected = $fileContent | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable + $response = $json | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable } else { $expected = $fileContent | ConvertFrom-Json -AsHashtable $response = $json | ConvertFrom-Json -AsHashtable } - Compare-Hashtable $response $expected | Should -BeTrue + Compare-PodeHashtable $response $expected | Should -BeTrue } @@ -60,14 +60,14 @@ Describe 'OpenAPI integration tests' { $webResponse = Invoke-WebRequest -Uri "http://localhost:$($PortV3_1)/docs/openapi/v3.1" -Method Get $json = $webResponse.Content if ( $PSVersionTable.PSEdition -eq 'Desktop') { - $expected = $fileContent | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable - $response = $json | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $expected = $fileContent | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable + $response = $json | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable } else { $expected = $fileContent | ConvertFrom-Json -AsHashtable $response = $json | ConvertFrom-Json -AsHashtable } - Compare-Hashtable $response $expected | Should -BeTrue + Compare-PodeHashtable $response $expected | Should -BeTrue } } diff --git a/tests/integration/Sessions.Tests.ps1 b/tests/integration/Sessions.Tests.ps1 index 2e5aa69b9..c71600472 100644 --- a/tests/integration/Sessions.Tests.ps1 +++ b/tests/integration/Sessions.Tests.ps1 @@ -44,7 +44,7 @@ Describe 'Session Requests' { } } - Wait-ForWebServer -Port $Port + Wait-PodeForWebServer -Port $Port } AfterAll { diff --git a/tests/shared/TestHelper.ps1 b/tests/shared/TestHelper.ps1 index 46707409f..fb6a4ea80 100644 --- a/tests/shared/TestHelper.ps1 +++ b/tests/shared/TestHelper.ps1 @@ -70,17 +70,17 @@ function Import-PodeAssembly { Returns `$true` if both strings are equal after normalization; otherwise, returns `$false`. .EXAMPLE - Compare-StringRnLn -InputString1 "Hello`r`nWorld" -InputString2 "Hello`nWorld" + Compare-PodeStringRnLn -InputString1 "Hello`r`nWorld" -InputString2 "Hello`nWorld" # Returns: $true .EXAMPLE - Compare-StringRnLn -InputString1 "Line1`r`nLine2" -InputString2 "Line1`rLine2" + Compare-PodeStringRnLn -InputString1 "Line1`r`nLine2" -InputString2 "Line1`rLine2" # Returns: $true .NOTES This function ensures that strings with different line-ending formats are treated as equal if their content is otherwise identical. #> -function Compare-StringRnLn { +function Compare-PodeStringRnLn { param ( [string]$InputString1, [string]$InputString2 @@ -105,18 +105,18 @@ function Compare-StringRnLn { .EXAMPLE $object = [PSCustomObject]@{ Name = "Pode"; Version = "2.0"; Config = [PSCustomObject]@{ Debug = $true } } - Convert-PsCustomObjectToOrderedHashtable -InputObject $object + Convert-PodePsCustomObjectToOrderedHashtable -InputObject $object # Returns: An ordered hashtable representation of $object. .EXAMPLE $object = [PSCustomObject]@{ Users = @([PSCustomObject]@{ Name = "Alice" }, [PSCustomObject]@{ Name = "Bob" }) } - Convert-PsCustomObjectToOrderedHashtable -InputObject $object + Convert-PodePsCustomObjectToOrderedHashtable -InputObject $object # Returns: An ordered hashtable where 'Users' is an array of ordered hashtables. .NOTES This function preserves key order and supports recursive conversion of nested objects and collections. #> -function Convert-PsCustomObjectToOrderedHashtable { +function Convert-PodePsCustomObjectToOrderedHashtable { [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] @@ -191,17 +191,17 @@ function Convert-PsCustomObjectToOrderedHashtable { .EXAMPLE $hash1 = @{ Name = "Pode"; Version = "2.0"; Config = @{ Debug = $true } } $hash2 = @{ Name = "Pode"; Version = "2.0"; Config = @{ Debug = $true } } - Compare-Hashtable -Hashtable1 $hash1 -Hashtable2 $hash2 + Compare-PodeHashtable -Hashtable1 $hash1 -Hashtable2 $hash2 # Returns: $true .EXAMPLE $hash1 = @{ Name = "Pode"; Version = "2.0" } $hash2 = @{ Name = "Pode"; Version = "2.1" } - Compare-Hashtable -Hashtable1 $hash1 -Hashtable2 $hash2 + Compare-PodeHashtable -Hashtable1 $hash1 -Hashtable2 $hash2 # Returns: $false #> -function Compare-Hashtable { +function Compare-PodeHashtable { param ( [object]$Hashtable1, [object]$Hashtable2 @@ -212,7 +212,7 @@ function Compare-Hashtable { # Check if both values are hashtables if ((($value1 -is [hashtable] -or $value1 -is [System.Collections.Specialized.OrderedDictionary]) -and ($value2 -is [hashtable] -or $value2 -is [System.Collections.Specialized.OrderedDictionary]))) { - return Compare-Hashtable -Hashtable1 $value1 -Hashtable2 $value2 + return Compare-PodeHashtable -Hashtable1 $value1 -Hashtable2 $value2 } # Check if both values are arrays elseif (($value1 -is [Object[]]) -and ($value2 -is [Object[]])) { @@ -234,7 +234,7 @@ function Compare-Hashtable { } else { if ($value1 -is [string] -and $value2 -is [string]) { - return Compare-StringRnLn $value1 $value2 + return Compare-PodeStringRnLn $value1 $value2 } # Check if the values are equal return $value1 -eq $value2 @@ -255,7 +255,7 @@ function Compare-Hashtable { } if ($Hashtable2[$key] -is [hashtable] -or $Hashtable2[$key] -is [System.Collections.Specialized.OrderedDictionary]) { - if (! (Compare-Hashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])) { + if (! (Compare-PodeHashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])) { return $false } } @@ -292,12 +292,12 @@ function Compare-Hashtable { Boolean - Returns $true if the server is online, otherwise $false. .EXAMPLE - Wait-ForWebServer -Port 8080 -Timeout 30 -Interval 2 + Wait-PodeForWebServer -Port 8080 -Timeout 30 -Interval 2 Waits up to 30 seconds for the web server on port 8080 to come online. .EXAMPLE - Wait-ForWebServer -Uri "http://127.0.0.1:5000" -Timeout 45 + Wait-PodeForWebServer -Uri "http://127.0.0.1:5000" -Timeout 45 Waits up to 45 seconds for the web server at "http://127.0.0.1:5000" to respond. @@ -305,7 +305,7 @@ function Compare-Hashtable { Author: ChatGPT This function ensures that the web server is fully responding, not just that the port is open. #> -function Wait-ForWebServer { +function Wait-PodeForWebServer { [CmdletBinding()] [OutputType([bool])] param ( @@ -339,7 +339,7 @@ function Wait-ForWebServer { try { # Send a request but ignore status codes (any response means the server is online) $null = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 3 - Write-Host "Webserver is online at $Uri" + Write-Host "Webserver is online at $Uri" return $true } catch { @@ -361,7 +361,7 @@ function Wait-ForWebServer { -function Compare-Hashtable { +function Compare-PodeHashtable { param ( [object]$Hashtable1, [object]$Hashtable2 @@ -372,7 +372,7 @@ function Compare-Hashtable { # Check if both values are hashtables if ((($value1 -is [hashtable] -or $value1 -is [System.Collections.Specialized.OrderedDictionary]) -and ($value2 -is [hashtable] -or $value2 -is [System.Collections.Specialized.OrderedDictionary]))) { - return Compare-Hashtable -Hashtable1 $value1 -Hashtable2 $value2 + return Compare-PodeHashtable -Hashtable1 $value1 -Hashtable2 $value2 } # Check if both values are arrays elseif (($value1 -is [Object[]]) -and ($value2 -is [Object[]])) { @@ -394,7 +394,7 @@ function Compare-Hashtable { } else { if ($value1 -is [string] -and $value2 -is [string]) { - return Compare-StringRnLn $value1 $value2 + return Compare-PodeStringRnLn $value1 $value2 } # Check if the values are equal return $value1 -eq $value2 @@ -415,7 +415,7 @@ function Compare-Hashtable { } if ($Hashtable2[$key] -is [hashtable] -or $Hashtable2[$key] -is [System.Collections.Specialized.OrderedDictionary]) { - if (! (Compare-Hashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])) { + if (! (Compare-PodeHashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])) { return $false } } @@ -426,69 +426,7 @@ function Compare-Hashtable { return $true } - - -function Compare-StringRnLn { - param ( - [string]$InputString1, - [string]$InputString2 - ) - return ($InputString1.Trim() -replace "`r`n|`n|`r", "`n") -eq ($InputString2.Trim() -replace "`r`n|`n|`r", "`n") -} - -function Convert-PsCustomObjectToOrderedHashtable { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [PSCustomObject]$InputObject - ) - begin { - # Define a recursive function within the process block - function Convert-ObjectRecursively { - param ( - [Parameter(Mandatory = $true)] - [System.Object] - $InputObject - ) - - # Initialize an ordered dictionary - $orderedHashtable = [ordered]@{} - - # Loop through each property of the PSCustomObject - foreach ($property in $InputObject.PSObject.Properties) { - # Check if the property value is a PSCustomObject - if ($property.Value -is [PSCustomObject]) { - # Recursively convert the nested PSCustomObject - $orderedHashtable[$property.Name] = Convert-ObjectRecursively -InputObject $property.Value - } - elseif ($property.Value -is [System.Collections.IEnumerable] -and -not ($property.Value -is [string])) { - # If the value is a collection, check each element - $convertedCollection = @() - foreach ($item in $property.Value) { - if ($item -is [PSCustomObject]) { - $convertedCollection += Convert-ObjectRecursively -InputObject $item - } - else { - $convertedCollection += $item - } - } - $orderedHashtable[$property.Name] = $convertedCollection - } - else { - # Add the property name and value to the ordered hashtable - $orderedHashtable[$property.Name] = $property.Value - } - } - - # Return the resulting ordered hashtable - return $orderedHashtable - } - } - process { - # Call the recursive helper function for each input object - Convert-ObjectRecursively -InputObject $InputObject - } -} + function Get-PodeModuleManifest { param( diff --git a/tests/unit/Convert.Tests.ps1 b/tests/unit/Convert.Tests.ps1 index f51c60162..76abbb36d 100644 --- a/tests/unit/Convert.Tests.ps1 +++ b/tests/unit/Convert.Tests.ps1 @@ -7,7 +7,7 @@ BeforeAll { $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' - + $helperPath = (Split-Path -Parent -Path $path) -ireplace 'unit', 'shared' . "$helperPath/TestHelper.ps1" @@ -84,28 +84,28 @@ Describe 'ConvertTo-PodeCustomDictionaryJson' { } It 'Should correctly serialize a recursively nested dictionary' { $dictionary = @{ 'Level1' = @{ 'Level2' = @{ 'Final' = 'Reached' } } } - $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"Level1","Value":{"Type":"Hashtable","Items":[{"Key":"Level2","Value":{"Type":"Hashtable","Items":[{"Key":"Final","Value":"Reached"}]}}]}}]}}' | - ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable - Compare-Hashtable $json $expected | Should -BeTrue + ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable + Compare-PodeHashtable $json $expected | Should -BeTrue } It 'Should correctly serialize a dictionary with multiple types' { $dictionary = @{ 'String' = 'Test'; 'Number' = 123; 'Boolean' = $true; 'Array' = @(1, 2, 3) } - $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"Array","Value":[1,2,3]},{"Key":"Boolean","Value":true},{"Key":"Number","Value":123},{"Key":"String","Value":"Test"}]}}' | - ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable - Compare-Hashtable $json $expected | Should -BeTrue + Compare-PodeHashtable $json $expected | Should -BeTrue } It 'Should correctly serialize nested dictionaries and collections' { $dictionary = @{ 'Dict' = @{ 'SubDict' = @{ 'Key' = 'Value' } }; 'List' = @(1, 2, @{ 'Nested' = 'Yes' }) } - $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"List","Value":[1,2,{"Type":"Hashtable","Items":[{"Key":"Nested","Value":"Yes"}]}]},{"Key":"Dict","Value":{"Type":"Hashtable","Items":[{"Key":"SubDict","Value":{"Type":"Hashtable","Items":[{"Key":"Key","Value":"Value"}]}}]}}]}}' | - ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable - Compare-Hashtable $json $expected | Should -BeTrue + Compare-PodeHashtable $json $expected | Should -BeTrue } It 'Should correctly serialize thread-safe collections' { @@ -118,11 +118,11 @@ Describe 'ConvertTo-PodeCustomDictionaryJson' { $concurrentBag.Add('Item2') $dictionary = @{ 'ConcurrentDict' = $concurrentDictionary; 'ConcurrentBag' = $concurrentBag } - $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + $json = ConvertTo-PodeCustomDictionaryJson -Dictionary $dictionary | ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable $expected = '{"Metadata":{"Product":"Pode","Version":"[dev]","Timestamp":"2025-02-04T01:54:30.6400033Z","Application":"Pester"},"Data":{"Type":"Hashtable","Items":[{"Key":"ConcurrentBag","Value":{"Type":"ConcurrentBag","Items":["Item2","Item1"]}},{"Key":"ConcurrentDict","Value":{"Type":"ConcurrentDictionary","Items":[{"Key":"Key1","Value":"Value1"},{"Key":"Key2","Value":42}]}}]}}' | - ConvertFrom-Json | Convert-PsCustomObjectToOrderedHashtable + ConvertFrom-Json | Convert-PodePsCustomObjectToOrderedHashtable - Compare-Hashtable $json $expected | Should -BeTrue + Compare-PodeHashtable $json $expected | Should -BeTrue } } diff --git a/tests/unit/Utility.Tests.ps1 b/tests/unit/Utility.Tests.ps1 index 2c9f5d0db..0dd1a7c0b 100644 --- a/tests/unit/Utility.Tests.ps1 +++ b/tests/unit/Utility.Tests.ps1 @@ -492,19 +492,19 @@ Describe 'ConvertFrom-PodeSerializedString' { } } # Recursive comparison function - function Compare-Hashtable($expected, $actual) { + function Compare-PodeHashtable($expected, $actual) { $expected.Keys.Count | Should -Be $actual.Keys.Count foreach ($key in $expected.Keys) { $actual.ContainsKey($key) | Should -BeTrue -Because "Key '$key' is missing." if ($expected[$key] -is [hashtable]) { - Compare-Hashtable $expected[$key] $actual[$key] + Compare-PodeHashtable $expected[$key] $actual[$key] } else { $actual[$key] | Should -Be $expected[$key] } } } - Compare-Hashtable $expected $result + Compare-PodeHashtable $expected $result } diff --git a/tests/unit/_.Tests.ps1 b/tests/unit/_.Tests.ps1 index 917b4fe00..0e59f4cd7 100644 --- a/tests/unit/_.Tests.ps1 +++ b/tests/unit/_.Tests.ps1 @@ -24,7 +24,7 @@ BeforeDiscovery { BeforeAll { $path = $PSCommandPath $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' - + # Import Pode Assembly $helperPath = (Split-Path -Parent -Path $path) -ireplace 'unit', 'shared' . "$helperPath/TestHelper.ps1" @@ -127,7 +127,7 @@ Describe 'All Aliases' { It 'Have Pode Tag' { $found = @() - foreach ($alias in ($publicAliases + $privateAliases)) { + foreach ($alias in ($publicAliases)) { if ($alias.Name -cnotlike '*-Pode*') { $found += $alias.Name } @@ -140,7 +140,7 @@ Describe 'All Aliases' { $found = @() $verbs = (Get-Verb).Verb - foreach ($alias in ($publicAliases + $privateAliases)) { + foreach ($alias in ($publicAliases)) { if (($alias.Name -split '-')[0] -cnotin $verbs) { $found += $alias.Name } From 84939500e2a6562afa78a4b895a3700725a0080e Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 3 Mar 2025 08:58:42 -0800 Subject: [PATCH 12/12] Create steps.txt --- steps.txt | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 steps.txt diff --git a/steps.txt b/steps.txt new file mode 100644 index 000000000..214a811ad --- /dev/null +++ b/steps.txt @@ -0,0 +1,40 @@ +git checkout develop +git branch -D alpha.3 + +git checkout -b alpha.3 origin/develop + + +#Fix 2 issues of New-PodeLoggingMethod -File +git fetch upstream pull/1507/head:pr-1507 +git merge --squash pr-1507 +git commit -m "Squashed changes from PR #1507" + +#Refactored Module Manifest Handling and Centralized Aliases +git fetch upstream pull/1504/head:pr-1504 +git merge --squash pr-1504 +git commit -m "Squashed changes from PR #1504" + +#Support for Custom and default Favicons in Pode Endpoints +git fetch upstream pull/1509/head:pr-1509 +git merge --squash pr-1509 +git commit -m "Squashed changes from PR #1509" + +#Add Functions for Handling Route WebEvent Data and Serialization in Pode +git fetch upstream pull/1386/head:pr-1386 +git merge --squash pr-1386 +git commit -m "Squashed changes from PR #1386" + +#Enhance Pode Shared State with Thread-Safe Structures and Improved Concurrency Handling +git fetch upstream pull/1473/head:pr-1473 +git merge --squash pr-1473 +git commit -m "Squashed changes from PR #1473" + +#Enhanced Digest Authentication, JWT Authentication, Certificate Management & RFC Compliance +git fetch upstream pull/1470/head:pr-1470 +git merge --squash pr-1470 +git commit -m "Squashed changes from PR #1470" + +#Using Pode as a Service in Windows, MacOSX and Linux (Systemd) +git fetch upstream pull/1421/head:pr-1421 +git merge --squash pr-1421 +git commit -m "Squashed changes from PR #1421" \ No newline at end of file