diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..36bd853 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [StartAutomating] diff --git a/.github/workflows/BuildServers101.yml b/.github/workflows/BuildServers101.yml new file mode 100644 index 0000000..eefbf4c --- /dev/null +++ b/.github/workflows/BuildServers101.yml @@ -0,0 +1,491 @@ + +name: Build Module +on: + push: + pull_request: + workflow_dispatch: +jobs: + TestPowerShellOnLinux: + runs-on: ubuntu-latest + steps: + - name: InstallPester + id: InstallPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Installs Pester + .Description + Installs Pester + #> + param( + # The maximum pester version. Defaults to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99' + ) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber + Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters + - name: Check out repository + uses: actions/checkout@v2 + - name: RunPester + id: RunPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + $Parameters.NoCoverage = ${env:NoCoverage} + $Parameters.NoCoverage = $parameters.NoCoverage -match 'true'; + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Runs Pester + .Description + Runs Pester tests after importing a PowerShell module + #> + param( + # The module path. If not provided, will default to the second half of the repository ID. + [string] + $ModulePath, + # The Pester max version. By default, this is pinned to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99', + + # If set, will not collect code coverage. + [switch] + $NoCoverage + ) + + $global:ErrorActionPreference = 'continue' + $global:ProgressPreference = 'silentlycontinue' + + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + if (-not $ModulePath) { $ModulePath = ".\$moduleName.psd1" } + $importedPester = Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion + $importedModule = Import-Module $ModulePath -Force -PassThru + $importedPester, $importedModule | Out-Host + + $codeCoverageParameters = @{ + CodeCoverage = "$($importedModule | Split-Path)\*-*.ps1" + CodeCoverageOutputFile = ".\$moduleName.Coverage.xml" + } + + if ($NoCoverage) { + $codeCoverageParameters = @{} + } + + + $result = + Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml @codeCoverageParameters + + "::set-output name=TotalCount::$($result.TotalCount)", + "::set-output name=PassedCount::$($result.PassedCount)", + "::set-output name=FailedCount::$($result.FailedCount)" | Out-Host + if ($result.FailedCount -gt 0) { + "::debug:: $($result.FailedCount) tests failed" + foreach ($r in $result.TestResult) { + if (-not $r.Passed) { + "::error::$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" + } + } + throw "::error:: $($result.FailedCount) tests failed" + } + } @Parameters + - name: PublishTestResults + uses: actions/upload-artifact@main + with: + name: PesterResults + path: '**.TestResults.xml' + if: ${{always()}} + TagReleaseAndPublish: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: TagModuleVersion + id: TagModuleVersion + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.TagAnnotationFormat = ${env:TagAnnotationFormat} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: TagModuleVersion $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The tag version format (default value: '$($imported.Name) $(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagAnnotationFormat = '$($imported.Name) $($imported.Version)' + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Tagging" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $existingTags = git tag --list + + @" + Target Version: $targetVersion + + Existing Tags: + $($existingTags -join [Environment]::NewLine) + "@ | Out-Host + + $versionTagExists = $existingTags | Where-Object { $_ -match $targetVersion } + + if ($versionTagExists) { + "::warning::Version $($versionTagExists)" + return + } + + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $UserEmail) { $UserEmail = "$UserName@github.com" } + git config --global user.email $UserEmail + git config --global user.name $UserName + + git tag -a $targetVersion -m $ExecutionContext.InvokeCommand.ExpandString($TagAnnotationFormat) + git push origin --tags + + if ($env:GITHUB_ACTOR) { + exit 0 + }} @Parameters + - name: ReleaseModule + id: ReleaseModule + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.ReleaseNameFormat = ${env:ReleaseNameFormat} + $Parameters.ReleaseAsset = ${env:ReleaseAsset} + $Parameters.ReleaseAsset = $parameters.ReleaseAsset -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: ReleaseModule $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The release name format (default value: '$($imported.Name) $($imported.Version)') + [string] + $ReleaseNameFormat = '$($imported.Name) $($imported.Version)', + + # Any assets to attach to the release. Can be a wildcard or file name. + [string[]] + $ReleaseAsset + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping GitHub release" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $targetReleaseName = $targetVersion + $releasesURL = 'https://api.github.com/repos/${{github.repository}}/releases' + "Release URL: $releasesURL" | Out-Host + $listOfReleases = Invoke-RestMethod -Uri $releasesURL -Method Get -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + + $releaseExists = $listOfReleases | Where-Object tag_name -eq $targetVersion + + if ($releaseExists) { + "::warning::Release '$($releaseExists.Name )' Already Exists" | Out-Host + $releasedIt = $releaseExists + } else { + $releasedIt = Invoke-RestMethod -Uri $releasesURL -Method Post -Body ( + [Ordered]@{ + owner = '${{github.owner}}' + repo = '${{github.repository}}' + tag_name = $targetVersion + name = $ExecutionContext.InvokeCommand.ExpandString($ReleaseNameFormat) + body = + if ($env:RELEASENOTES) { + $env:RELEASENOTES + } elseif ($imported.PrivateData.PSData.ReleaseNotes) { + $imported.PrivateData.PSData.ReleaseNotes + } else { + "$($imported.Name) $targetVersion" + } + draft = if ($env:RELEASEISDRAFT) { [bool]::Parse($env:RELEASEISDRAFT) } else { $false } + prerelease = if ($env:PRERELEASE) { [bool]::Parse($env:PRERELEASE) } else { $false } + } | ConvertTo-Json + ) -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Content-type" = "application/json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + } + + + + + + if (-not $releasedIt) { + throw "Release failed" + } else { + $releasedIt | Out-Host + } + + $releaseUploadUrl = $releasedIt.upload_url -replace '\{.+$' + + if ($ReleaseAsset) { + $fileList = Get-ChildItem -Recurse + $filesToRelease = + @(:nextFile foreach ($file in $fileList) { + foreach ($relAsset in $ReleaseAsset) { + if ($relAsset -match '[\*\?]') { + if ($file.Name -like $relAsset) { + $file; continue nextFile + } + } elseif ($file.Name -eq $relAsset -or $file.FullName -eq $relAsset) { + $file; continue nextFile + } + } + }) + + $releasedFiles = @{} + foreach ($file in $filesToRelease) { + if ($releasedFiles[$file.Name]) { + Write-Warning "Already attached file $($file.Name)" + continue + } else { + $fileBytes = [IO.File]::ReadAllBytes($file.FullName) + $releasedFiles[$file.Name] = + Invoke-RestMethod -Uri "${releaseUploadUrl}?name=$($file.Name)" -Headers @{ + "Accept" = "application/vnd.github+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } -Body $fileBytes -ContentType Application/octet-stream + $releasedFiles[$file.Name] + } + } + + "Attached $($releasedFiles.Count) file(s) to release" | Out-Host + } + + + + } @Parameters + - name: PublishPowerShellGallery + id: PublishPowerShellGallery + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.Exclude = ${env:Exclude} + $Parameters.Exclude = $parameters.Exclude -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: PublishPowerShellGallery $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + [string[]] + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif', 'docs[/\]*') + ) + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + if (-not $Exclude) { + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif','docs[/\]*') + } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + @" + ::group::PSBoundParameters + $($PSBoundParameters | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Gallery Publish" | Out-Host + return + } + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $foundModule = try { Find-Module -Name $imported.Name -ErrorAction SilentlyContinue} catch {} + + if ($foundModule -and (([Version]$foundModule.Version) -ge ([Version]$imported.Version))) { + "::warning::Gallery Version of $moduleName is more recent ($($foundModule.Version) >= $($imported.Version))" | Out-Host + } else { + + $gk = '${{secrets.GALLERYKEY}}' + + $rn = Get-Random + $moduleTempFolder = Join-Path $pwd "$rn" + $moduleTempPath = Join-Path $moduleTempFolder $moduleName + New-Item -ItemType Directory -Path $moduleTempPath -Force | Out-Host + + Write-Host "Staging Directory: $ModuleTempPath" + + $imported | Split-Path | + Get-ChildItem -Force | + Where-Object Name -NE $rn | + Copy-Item -Destination $moduleTempPath -Recurse + + $moduleGitPath = Join-Path $moduleTempPath '.git' + Write-Host "Removing .git directory" + if (Test-Path $moduleGitPath) { + Remove-Item -Recurse -Force $moduleGitPath + } + + if ($Exclude) { + "::notice::Attempting to Exlcude $exclude" | Out-Host + Get-ChildItem $moduleTempPath -Recurse | + Where-Object { + foreach ($ex in $exclude) { + if ($_.FullName -like $ex) { + "::notice::Excluding $($_.FullName)" | Out-Host + return $true + } + } + } | + Remove-Item + } + + Write-Host "Module Files:" + Get-ChildItem $moduleTempPath -Recurse + Write-Host "Publishing $moduleName [$($imported.Version)] to Gallery" + Publish-Module -Path $moduleTempPath -NuGetApiKey $gk + if ($?) { + Write-Host "Published to Gallery" + } else { + Write-Host "Gallery Publish Failed" + exit 1 + } + } + } @Parameters + diff --git a/Build/GitHub/Steps/PublishTestResults.psd1 b/Build/GitHub/Steps/PublishTestResults.psd1 new file mode 100644 index 0000000..e8111e8 --- /dev/null +++ b/Build/GitHub/Steps/PublishTestResults.psd1 @@ -0,0 +1,10 @@ +@{ + name = 'PublishTestResults' + uses = 'actions/upload-artifact@main' + with = @{ + name = 'PesterResults' + path = '**.TestResults.xml' + } + if = '${{always()}}' +} + diff --git a/Build/Servers101.GitHubWorkflow.PSDevOps.ps1 b/Build/Servers101.GitHubWorkflow.PSDevOps.ps1 new file mode 100644 index 0000000..f551c53 --- /dev/null +++ b/Build/Servers101.GitHubWorkflow.PSDevOps.ps1 @@ -0,0 +1,12 @@ +#requires -Module PSDevOps +Import-BuildStep -SourcePath ( + Join-Path $PSScriptRoot 'GitHub' +) -BuildSystem GitHubWorkflow + +Push-Location ($PSScriptRoot | Split-Path) + +New-GitHubWorkflow -Name "Build Module" -On Push, + PullRequest, + Demand -Job TestPowerShellOnLinux, TagReleaseAndPublish -OutputPath ./.github/workflows/BuildServers101.yml + +Pop-Location \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c25ce90 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +## Servers101 0.1: + +* Initial Release of Servers101 +* An educational module with small servers in PowerShell (#1) +* Built with a basic build (#2) +* Including a single command: `Get-Servers101` (#3) +* Initial demo servers: + * `Server101` (#4) + * `DebugServer` (#5) + * `EventServer` (#6) + * `DualEventServer` (#7) + diff --git a/Commands/Get-Servers101.ps1 b/Commands/Get-Servers101.ps1 new file mode 100644 index 0000000..9b7541f --- /dev/null +++ b/Commands/Get-Servers101.ps1 @@ -0,0 +1,48 @@ +function Get-Servers101 +{ + <# + .SYNOPSIS + Servers101 + .DESCRIPTION + Gets the list of example servers included in Servers101. + + Each server is a self-contained PowerShell script. + .EXAMPLE + Get-Servers101 + .EXAMPLE + Servers101 + #> + [Alias('Servers101')] + param( + # The name of the server. If no name is provided, all servers will be returned. + [SupportsWildcards()] + [string] + $Name + ) + + begin { + $myModuleRoot = $PSScriptRoot | Split-Path + Update-TypeData -TypeName Servers101 -DefaultDisplayPropertySet Name, + Synopsis, Description -Force + } + process { + Get-ChildItem -File -Path $myModuleRoot -Recurse | + Where-Object { + $_.Name -match 'server?[^\.]{0,}\.ps1$' -and + $_.Name -notmatch '-Server' -and ( + (-not $Name) -or ($_.Name -like "$name*") + ) + } | + ForEach-Object { + $file = $_ + $help = Get-Help -Name $file.FullName -ErrorAction Ignore + $file.pstypenames.clear() + $file.pstypenames.insert(0,'Servers101') + $file | + Add-Member NoteProperty Synopsis $help.Synopsis -Force -PassThru | + Add-Member NoteProperty Description ( + $help.Description.text -join [Environment]::NewLine + ) -Force -PassThru + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index e215d7e..75998a4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# Servers101 -Simple Servers in PowerShell +# Servers 101 + +Servers are pretty simple. + +You listen and then you reply. + +That's Servers 101. + +## Simple Servers + +Frameworks often abstract this away. + +This can make the basics of servers harder to learn. + +To avoid reliance on the framework flavor of the week, it's good to learn how to build a simple server. + +This is a collection of simple servers in PowerShell. + +Feel free to [contribute](contributing.md) and add your own. + + +## Server Samples + +* [DebugServer.ps1](/Servers/DebugServer.ps1) +* [DualEventServer.ps1](/Servers/DualEventServer.ps1) +* [EventServer.ps1](/Servers/EventServer.ps1) +* [Server101.ps1](/Servers/Server101.ps1) +* [SwitchServer.ps1](/Servers/SwitchServer.ps1) + +## Using this module + +This module has only one command, Get-Servers101. + +It will return all of the sample servers in the module. + +Each server will be self-contained in a single script. + +To start the server, simply run the script. + +To learn about how each server works, read thru each script. diff --git a/README.md.ps1 b/README.md.ps1 new file mode 100644 index 0000000..b537fa4 --- /dev/null +++ b/README.md.ps1 @@ -0,0 +1,47 @@ +@" +# Servers 101 + +Servers are pretty simple. + +You listen and then you reply. + +That's Servers 101. + +## Simple Servers + +Frameworks often abstract this away. + +This can make the basics of servers harder to learn. + +To avoid reliance on the framework flavor of the week, it's good to learn how to build a simple server. + +This is a collection of simple servers in PowerShell. + +Feel free to [contribute](contributing.md) and add your own. + + +## Server Samples + +"@ + + +foreach ($serverScript in Get-Servers101) { + "* [$($serverScript.Name)]($($serverScript.FullName.Substring("$pwd".Length)))" +} + + + +@" + +## Using this module + +This module has only one command, `Get-Servers101`. + +It will return all of the sample servers in the module. + +Each server will be self-contained in a single script. + +To start the server, simply run the script. + +To learn about how each server works, read thru each script. +"@ \ No newline at end of file diff --git a/Servers/DebugServer.ps1 b/Servers/DebugServer.ps1 new file mode 100644 index 0000000..fe7f787 --- /dev/null +++ b/Servers/DebugServer.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS + A debug server. +.DESCRIPTION + A server that runs on the current thread, so you can debug it. + + You can run this with -AsJob, but then you cannot debug in PowerShell. +.NOTES + A few notes: + + 1. This will effectively lock the current thread (CTRL+C works). + 2. Because of the way requests are processed, you may need to refresh to hit the breakpoint. + 3. Be aware that browsers will request a `favicon.ico` first. +#> +param( +# The rootUrl of the server. By default, a random loopback address. +[string]$RootUrl= + "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", + +# If set, will run in a background job. +[switch] +$AsJob +) + +$httpListener = [Net.HttpListener]::new() +$httpListener.Prefixes.add($RootUrl) +$httpListener.Start() +Write-Warning "Listening on $rootUrl" + +$listenScript = { + param([Net.HttpListener]$httpListener) + # Listen for the next request + :nextRequest while ($httpListener.IsListening) { + $getContext = $httpListener.GetContextAsync() + + while (-not $getContext.Wait(17)) { } + + $context = $getContext.Result + $requestTime = [DateTime]::Now + $request, $reply = $context.Request, $context.Response + $debugObject = $request | + Select-Object HttpMethod, Url, Is* | + Add-Member NoteProperty Headers ([Ordered]@{}) -Force -passThru | + Add-Member NoteProperty Query ([Ordered]@{}) -Force -passThru + + foreach ($headerName in $request.Headers) { + $debugObject.headers[$headerName] = $request.Headers[$headerName] + } + if ($request.Url.Query) { + + foreach ($chunk in $request.Url.Query -split '&') { + $parsedQuery = + [Web.HttpUtility]::ParseQueryString($chunk) + $key = @($parsedQuery.Keys)[0] + if ($debugObject.Query[$key]) { + $debugObject.Query[$key] = @( + $debugObject.Query[$key] + ) + $parsedQuery[$key] + } else { + $debugObject.Query[$key] = $parsedQuery[$key] + } + } + } + $reply.ContentType = 'application/json' + $reply.Close( + $OutputEncoding.GetBytes( + ($debugObject | ConvertTo-Json -Depth 5) + ), $false) + "Responded to $($Request.Url) in $([DateTime]::Now - $requestTime)" + } +} + +if ($AsJob) { + Start-ThreadJob -ScriptBlock $listenerScript -ArgumentList $httpListener +} else { + . $listenScript $httpListener +} \ No newline at end of file diff --git a/Servers/DualEventServer.ps1 b/Servers/DualEventServer.ps1 new file mode 100644 index 0000000..67f629c --- /dev/null +++ b/Servers/DualEventServer.ps1 @@ -0,0 +1,75 @@ +<# +.SYNOPSIS + Event Server +.DESCRIPTION + A simple event driven server. + + Each request will generate an event, which will be responded to by a handler. +#> +param( +# The rootUrl of the server. By default, a random loopback address. +[string]$RootUrl= + "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/" +) + +$httpListener = [Net.HttpListener]::new() +$httpListener.Prefixes.Add($RootUrl) +Write-Warning "Listening on $RootUrl $($httpListener.Start())" + +$io = [Ordered]@{ # Pack our job input into an IO dictionary + HttpListener = $httpListener ; ServerRoot = $RootDirectory + MainRunspace = [Runspace]::DefaultRunspace; SourceIdentifier = $RootUrl + TypeMap = $TypeMap +} + +# Our server is a thread job +Start-ThreadJob -ScriptBlock {param([Collections.IDictionary]$io) + $psvariable = $ExecutionContext.SessionState.PSVariable + foreach ($key in $io.Keys) { # First, let's unpack. + if ($io[$key] -is [PSVariable]) { $psvariable.set($io[$key]) } + else { $psvariable.set($key, $io[$key]) } + } + + $thisRunspace = [Runspace]::DefaultRunspace + + # Because we are handling the event locally, the main thread can keep chugging. + Register-EngineEvent -SourceIdentifier $SourceIdentifier -Action { + try { + $request = $event.MessageData.Request + $reply = $event.MessageData.Reply + + $timeToRespond = [DateTime]::Now - $event.TimeGenerated + $myReply = "$($request.HttpMethod) $($request.Url) $($timeToRespond)" + $reply.Close($OutputEncoding.GetBytes($myReply), $false) + } catch { + Write-Error $_ + } + } + + # Listen for the next request + :nextRequest while ($httpListener.IsListening) { + $getContext = $httpListener.GetContextAsync() + while (-not $getContext.Wait(17)) { } + $request, $reply = + $getContext.Result.Request, $getContext.Result.Response + + # Generate events for every request + foreach ($runspace in $thisRunspace, $mainRunspace) { + # by broadcasting to multiple runspaces, we can both reply and have a record. + $runspace.Events.GenerateEvent( + $SourceIdentifier, $httpListener, @( + $getContext.Result, $request, $reply + ), [Ordered]@{ + Method = $Request.HttpMethod; Url = $request.Url + Request = $request; Reply = $reply; Response = $reply + ServerRoot = $ServerRoot; TypeMap = $TypeMap + } + ) + } + } +} -ThrottleLimit 100 -ArgumentList $IO -Name "$RootUrl" | # Output our job, + Add-Member -NotePropertyMembers @{ # but attach a few properties first: + HttpListener=$httpListener # * The listener (so we can stop it) + IO=$IO # * The IO (so we can change it) + Url="$RootUrl" # The URL (so we can easily access it). + } -Force -PassThru # Pass all of that thru and return it to you. \ No newline at end of file diff --git a/Servers/EventServer.ps1 b/Servers/EventServer.ps1 new file mode 100644 index 0000000..d3af43c --- /dev/null +++ b/Servers/EventServer.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Event Server +.DESCRIPTION + A simple event driven server. + + Each request will generate an event, which will be responded to by a handler. +.EXAMPLE + ./EventServer.ps1 ($pwd | Split-Path) +#> +param( +# The rootDirectory. +[string]$RootDirectory = $PSScriptRoot, + +# The rootUrl of the server. By default, a random loopback address. +[string]$RootUrl= + "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", + +# The type map. This determines how each extension will be served. +[Collections.IDictionary] +$TypeMap = [Ordered]@{ + ".html" = "text/html" ; ".css" = "text/css" ; ".svg" = "image/svg+xml" ; + ".png" = "image/png" ; ".jpg" = "image/jpeg"; ".gif" = "image/gif" + ".mp3" = "audio/mpeg"; ".mp4" = "video/mp4" + ".json" = "application/json"; ".xml" = "application/xml" ; + ".js" = "text/javascript" ; ".jsm" = "text/javascript" ; +} +) + +$httpListener = [Net.HttpListener]::new() +$httpListener.Prefixes.Add($RootUrl) +Write-Warning "Listening on $RootUrl $($httpListener.Start())" + +$io = [Ordered]@{ # Pack our job input into an IO dictionary + HttpListener = $httpListener ; ServerRoot = $RootDirectory + MainRunspace = [Runspace]::DefaultRunspace; SourceIdentifier = $RootUrl + TypeMap = $TypeMap +} + +# Our server is a thread job +Start-ThreadJob -ScriptBlock {param([Collections.IDictionary]$io) + $psvariable = $ExecutionContext.SessionState.PSVariable + foreach ($key in $io.Keys) { # First, let's unpack. + if ($io[$key] -is [PSVariable]) { $psvariable.set($io[$key]) } + else { $psvariable.set($key, $io[$key]) } + } + + # Listen for the next request + :nextRequest while ($httpListener.IsListening) { + $getContext = $httpListener.GetContextAsync() + while (-not $getContext.Wait(17)) { } + $request, $reply = + $getContext.Result.Request, $getContext.Result.Response + # Generate an event for every request + $mainRunspace.Events.GenerateEvent( + $SourceIdentifier, $httpListener, @( + $getContext.Result, $request, $reply + ), [Ordered]@{ + Method = $Request.HttpMethod; Url = $request.Url + Request = $request; Reply = $reply; Response = $reply + ServerRoot = $ServerRoot; TypeMap = $TypeMap + } + ) + } +} -ThrottleLimit 100 -ArgumentList $IO -Name "$RootUrl" | # Output our job, + Add-Member -NotePropertyMembers @{ # but attach a few properties first: + HttpListener=$httpListener # * The listener (so we can stop it) + IO=$IO # * The IO (so we can change it) + Url="$RootUrl" # The URL (so we can easily access it). + } -Force -PassThru # Pass all of that thru and return it to you. + +# Now register a handler for these events. +Register-EngineEvent -SourceIdentifier $RootUrl -Action { + $request = $event.MessageData.Request + $reply = $event.MessageData.Reply + + $timeToRespond = [DateTime]::Now - $event.TimeGenerated + $myReply = "$($request.HttpMethod) $($request.Url) $($timeToRespond)" + $reply.Close($OutputEncoding.GetBytes($myReply), $false) +} + +# Because events are processed on the main runspace thread, this cannot Invoke-RestMethod itself. \ No newline at end of file diff --git a/Servers/Server101.ps1 b/Servers/Server101.ps1 new file mode 100644 index 0000000..17b2a6d --- /dev/null +++ b/Servers/Server101.ps1 @@ -0,0 +1,101 @@ +<# +.SYNOPSIS + Server 101 +.DESCRIPTION + Server 101: A file server in 101 lines of pure PowerShell. +.EXAMPLE + ./Server101.ps1 ($pwd | Split-Path) +#> +param( +<# The Root Directory. #> [string]$RootDirectory = $PSScriptRoot, + +# The rootUrl of the server. By default, a random loopback address. +[string]$RootUrl= + "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", + +# The type map. This determines how each extension will be served. +[Collections.IDictionary] +$TypeMap = [Ordered]@{ + ".html" = "text/html" ; ".css" = "text/css" ; ".svg" = "image/svg+xml" ; + ".png" = "image/png" ; ".jpg" = "image/jpeg"; ".gif" = "image/gif" + ".mp3" = "audio/mpeg"; ".mp4" = "video/mp4" + ".json" = "application/json"; ".xml" = "application/xml" ; + ".js" = "text/javascript" ; ".jsm" = "text/javascript" ; + ".ps1" = "text/x-powershell" +}) + +$httpListener = [Net.HttpListener]::new() +$httpListener.Prefixes.Add($RootUrl) +Write-Warning "Listening on $RootUrl $($httpListener.Start())" + +$io = [Ordered]@{ # Pack our job input into an IO dictionary + HttpListener = $httpListener ; ServerRoot = $RootDirectory + Files = [Ordered]@{}; ContentTypes = [Ordered]@{} +} +# Then map each file into one or more /uris +foreach ($file in Get-ChildItem -File -Path $RootDirectory -Recurse) { + $relativePath = + $file.FullName.Substring($RootDirectory.Length) -replace '[\\/]', '/' + $fileUris = @($relativePath) + @( + foreach ($indexFile in 'index.html', 'readme.html') { + $indexPattern = [Regex]::Escape($indexFile) + '$' + if ($file.Name -eq $indexFile -and -not $IO.Files[ + $relativePath -replace $indexPattern + ]) { + $relativePath -replace $indexPattern + $relativePath -replace "[\\/]$indexPattern" + } + } + ) + foreach ($fileUri in $fileUris) { + $io.ContentTypes[$fileUri] = # and map content types now + $TypeMap[$file.Extension] ? # so we don't have to later. + $TypeMap[$file.Extension] : + 'text/plain' + $io.Files[$fileUri] = $file + } +} + +# Our server is a thread job +Start-ThreadJob -ScriptBlock {param([Collections.IDictionary]$io) + $psvariable = $ExecutionContext.SessionState.PSVariable + foreach ($key in $io.Keys) { # First, let's unpack + if ($io[$key] -is [PSVariable]) { $psvariable.set($io[$key]) } + else { $psvariable.set($key, $io[$key]) } + } # and then declare a few filters to make code more readable. + filter outputError([int]$Number) { + $reply.StatusCode = $Number; $reply.Close(); continue nextRequest + } + filter outputHeader { + $reply.Length=$file.Length; $reply.Close(); continue nextRequest + } + filter outputFile { + $reply.ContentType = $contentTypes[$potentialPath] + $fileStream = $file.OpenRead() + $fileStream.CopyTo($reply.OutputStream) + $fileStream.Close(); $fileStream.Dispose(); $reply.Close() + continue nextRequest + } + # Listen for the next request + :nextRequest while ($httpListener.IsListening) { + $getContext = $httpListener.GetContextAsync() + while (-not $getContext.Wait(17)) { } + $request, $reply = # and reply to it. + $getContext.Result.Request, $getContext.Result.Response + $method, $localPath = + $request.HttpMethod, $request.Url.LocalPath + # If the method is not allowed, output error 405 + if ($method -notin 'get', 'head') { outputError 405 } + # If the file does not exist, output error 404 + if (-not ($files -and $files[$localPath])) { outputError 404 } + $file = $files[$request.Url.LocalPath] + # If they asked for header information, output it. + if ($request.httpMethod -eq 'head') { outputHeader } + outputFile # otherwise, output the file. + } +} -ThrottleLimit 100 -ArgumentList $IO -Name "$RootUrl" | # Output our job, + Add-Member -NotePropertyMembers @{ # but attach a few properties first: + HttpListener=$httpListener # * The listener (so we can stop it) + IO=$IO # * The IO (so we can change it) + Url="$RootUrl" # The URL (so we can easily access it). + } -Force -PassThru # Pass all of that thru and return it to you. \ No newline at end of file diff --git a/Servers101.psd1 b/Servers101.psd1 new file mode 100644 index 0000000..bb578c9 --- /dev/null +++ b/Servers101.psd1 @@ -0,0 +1,146 @@ +# +# Module manifest for module 'Servers101' +# +# Generated by: James Brundage +# +# Generated on: 12/7/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'Servers101.psm1' + +# Version number of this module. +ModuleVersion = '0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = 'f6170af5-7ade-4bea-ad1b-d636122177f5' + +# Author of this module +Author = 'James Brundage' + +# Company or vendor of this module +CompanyName = 'Start Automating' + +# Copyright statement for this module +Copyright = '2025 Start Automating' + +# Description of the functionality provided by this module +Description = 'Simple Servers in PowerShell' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() + +# Functions 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 functions to export. +FunctionsToExport = 'Get-Servers101' + +# Cmdlets 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 cmdlets to export. +CmdletsToExport = '*' + +# Variables to export from this module +VariablesToExport = '*' + +# 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. +AliasesToExport = 'Servers101' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# 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 = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + Tags = 'Server', 'Educational' + + # A URL to the license for this module. + LicenseUri = 'https://github.com/PowerShellWeb/Servers101/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/PowerShellWeb/Servers101' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + ReleaseNotes = @' +## Servers101 0.1: + +* Initial Release of Servers101 +* An educational module with small servers in PowerShell (#1) +* Built with a basic build (#2) +* Including a single command: `Get-Servers101` (#3) +* Initial demo servers: + * `Server101` (#4) + * `DebugServer` (#5) + * `EventServer` (#6) + * `DualEventServer` (#7) + + +'@ + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/Servers101.psm1 b/Servers101.psm1 new file mode 100644 index 0000000..b0d0bb8 --- /dev/null +++ b/Servers101.psm1 @@ -0,0 +1,6 @@ +$commandsPath = Join-Path $PSScriptRoot Commands +foreach ($file in Get-ChildItem -File -Recurse -Path $commandsPath) { + if ($file.Extension -ne '.ps1') { continue } + if ($file.Name -like '*.*.ps1') { continue } + . $file.FullName +} \ No newline at end of file diff --git a/Servers101.tests.ps1 b/Servers101.tests.ps1 new file mode 100644 index 0000000..b23ddfc --- /dev/null +++ b/Servers101.tests.ps1 @@ -0,0 +1,28 @@ +<# +.SYNOPSIS + Servers 101 Tests +.DESCRIPTION + Tests for the Server 101 module. +#> + +$scriptName = $MyInvocation.MyCommand.Name +$scriptFileContent = Get-Content -Raw $MyInvocation.MyCommand.ScriptBlock.File + +describe Servers101 { + it 'Gets a list of demo servers' { + Servers101 + } + + it 'Can get a specific demo server' { + Servers101 -Name Server101 + } + + it 'Has a working file server' { + $job = . (Servers101 -Name Server101) -RootDirectory $pwd + Write-Warning "$($job.Url)$($scriptName)" + $myResponse = Invoke-RestMethod -Uri "$($job.Url)$($scriptName)" + "$myResponse".Trim() | Should -Be "$scriptFileContent".Trim() + $job.HttpListener.Stop() + } + +} diff --git a/code_of_conduct.md b/code_of_conduct.md new file mode 100644 index 0000000..e409748 --- /dev/null +++ b/code_of_conduct.md @@ -0,0 +1 @@ +Try to be a good person, and please be polite when providing feedback. \ No newline at end of file diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..d3ca089 --- /dev/null +++ b/contributing.md @@ -0,0 +1,9 @@ +Anyone can contribute to this collection of simple servers. + +There are only a few ground rules: + +There are only a few rules here: + +* Servers must be short (~100 lines is ideal) +* Servers must be named like `*server*.ps1` +* Servers must simple and mostly harmless diff --git a/security.md b/security.md new file mode 100644 index 0000000..7b69d36 --- /dev/null +++ b/security.md @@ -0,0 +1,7 @@ +This module is designed to help anyone understand how to build their own server. + +Each of the educational servers in this module should run as a low-rights user on a randomized loopback port. + +Each of the educational servers are short and easy to inspect for malicious code. + +If you find that an educational server is being used in a malicious way or find a security issue with any of the educational servers, please file an issue.