diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b44c224 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,149 @@ +name: PSNetScanners Workflow +on: + push: + branches: + - main + + pull_request: + branches: + - main + + release: + types: + - published + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + POWERSHELL_TELEMETRY_OPTOUT: 1 + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + BUILD_CONFIGURATION: ${{ fromJSON('["Debug", "Release"]')[startsWith(github.ref, 'refs/tags/v')] }} + +jobs: + build: + name: build + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build module - Debug + shell: pwsh + run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Build + if: ${{ env.BUILD_CONFIGURATION == 'Debug' }} + + - name: Build module - Publish + shell: pwsh + run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Build + if: ${{ env.BUILD_CONFIGURATION == 'Release' }} + + - name: Capture PowerShell Module + uses: actions/upload-artifact@v4 + with: + name: PSModule + path: output/*.nupkg + + test: + name: test + needs: + - build + runs-on: ${{ matrix.info.os }} + strategy: + fail-fast: false + matrix: + info: + - name: PS-5.1 + psversion: '5.1' + os: windows-latest + - name: PS-7_Windows + psversion: '7' + os: windows-latest + - name: PS-7_Linux + psversion: '7' + os: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Restore Built PowerShell Module + uses: actions/download-artifact@v4 + with: + name: PSModule + path: output + + - name: Install Built PowerShell Module + shell: pwsh + run: | + $manifestItem = Get-Item ([IO.Path]::Combine('module', '*.psd1')) + $moduleName = $manifestItem.BaseName + $manifest = Test-ModuleManifest -Path $manifestItem.FullName -ErrorAction SilentlyContinue -WarningAction Ignore + + $destPath = [IO.Path]::Combine('output', $moduleName, $manifest.Version) + if (-not (Test-Path -LiteralPath $destPath)) { + New-Item -Path $destPath -ItemType Directory | Out-Null + } + + Get-ChildItem output/*.nupkg | Rename-Item -NewName { $_.Name -replace '.nupkg', '.zip' } + Expand-Archive -Path output/*.zip -DestinationPath $destPath -Force -ErrorAction Stop + + - name: Run Tests - PowerShell 5.1 + if: ${{ matrix.info.psversion == '5.1' }} + shell: powershell + run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Test + + - name: setcap + if: ${{ matrix.info.name == 'PS-7_Linux' }} + shell: pwsh + run: sudo setcap cap_net_raw=eip /opt/microsoft/powershell/7/pwsh + + - name: Run Tests - PowerShell 7 + if: ${{ matrix.info.psversion != '5.1' }} + shell: pwsh + run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Test + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: Unit Test Results (${{ matrix.info.name }}) + path: ./output/TestResults/Pester.xml + + - name: Upload Coverage Results + if: always() && !startsWith(github.ref, 'refs/tags/v') + uses: actions/upload-artifact@v4 + with: + name: Coverage Results (${{ matrix.info.name }}) + path: ./output/TestResults/Coverage.xml + + - name: Upload Coverage to codecov + if: always() && !startsWith(github.ref, 'refs/tags/v') + uses: codecov/codecov-action@v4 + with: + files: ./output/TestResults/Coverage.xml + flags: ${{ matrix.info.name }} + token: ${{ secrets.CODECOV_TOKEN }} + + publish: + name: publish + if: startsWith(github.ref, 'refs/tags/v') + needs: + - build + - test + runs-on: windows-latest + steps: + - name: Restore Built PowerShell Module + uses: actions/download-artifact@v4 + with: + name: PSModule + path: ./ + + - name: Publish to Gallery + if: github.event_name == 'release' + shell: pwsh + run: >- + dotnet nuget push '*.nupkg' + --api-key $env:PSGALLERY_TOKEN + --source 'https://www.powershellgallery.com/api/v2/package' + --no-symbols + env: + PSGALLERY_TOKEN: ${{ secrets.PSGALLERY_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..480bc3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,277 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +benchmarks/ +BenchmarkDotNet.Artifacts/ +tools/dotnet + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +*.zip + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +### Custom entries ### +output/ +tools/Modules +test.settings.json +tests/integration/.vagrant +tests/integration/cert_setup +tools/ProjectBuilder/output +tools/ProjectBuilder/bin +tools/ProjectBuilder/debug +tools/ProjectBuilder/obj diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..4e3f7c3 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "default": true, + "no-hard-tabs": true, + "no-duplicate-heading": false, + "line-length": false, + "no-inline-html": false, + "ul-indent": false +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4dafc5c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "formulahendry.dotnet-test-explorer", + "ms-dotnettools.csharp", + "ms-vscode.powershell", + ], +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ff1143b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,45 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "PowerShell launch", + "type": "coreclr", + "request": "launch", + "program": "pwsh", + "args": [ + "-NoExit", + "-NoProfile", + "-Command", + ". ./tools/prompt.ps1;", + "Import-Module ./output/PSNetScanners" + ], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "externalTerminal", + }, + { + "name": "PowerShell Launch Current File", + "type": "PowerShell", + "request": "launch", + "script": "${file}", + "cwd": "${workspaceFolder}" + }, + { + "name": ".NET FullCLR Attach", + "type": "clr", + "request": "attach", + "processId": "${command:pickProcess}", + "justMyCode": true, + }, + { + "name": ".NET CoreCLR Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}", + "justMyCode": true, + }, + ], +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c7f8f57 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,56 @@ +{ + "cSpell.enableFiletypes": [ + "!powershell" + ], + //-------- Files configuration -------- + // When enabled, will trim trailing whitespace when you save a file. + "files.trimTrailingWhitespace": true, + // When enabled, insert a final new line at the end of the file when saving it. + "files.insertFinalNewline": true, + "search.exclude": { + "Release": true, + "tools/ResGen": true, + "tools/dotnet": true, + }, + "json.schemas": [ + { + "fileMatch": [ + "/test.settings.json" + ], + "url": "./tests/settings.schema.json" + } + ], + "editor.rulers": [ + 120, + ], + //-------- PowerShell configuration -------- + // Binary modules cannot be unloaded so running in separate processes solves that problem + //"powershell.debugging.createTemporaryIntegratedConsole": true, + // We use Pester v5 so we don't need the legacy code lens + "powershell.pester.useLegacyCodeLens": false, + "cSpell.words": [ + "pwsh" + ], + "xml.fileAssociations": [ + { + "systemId": "https://raw.githubusercontent.com/PowerShell/PowerShell/master/src/Schemas/Format.xsd", + "pattern": "**/*.Format.ps1xml" + }, + { + "systemId": "https://raw.githubusercontent.com/PowerShell/PowerShell/master/src/Schemas/Types.xsd", + "pattern": "**/*.Types.ps1xml" + } + ], + "[powershell]": { + "files.encoding": "utf8bom", + "editor.tabSize": 4, + "editor.detectIndentation": false, + "editor.autoIndent": "full" + }, + "[csharp]": { + "editor.maxTokenizationLineLength": 2500, + "editor.tabSize": 4, + "editor.detectIndentation": false, + "editor.autoIndent": "full", + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..bbde0bc --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,49 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "pwsh", + "type": "shell", + "args": [ + "-File", + "${workspaceFolder}/build.ps1" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile" + }, + { + "label": "update docs", + "command": "pwsh", + "type": "shell", + "args": [ + "-Command", + "Import-Module ${workspaceFolder}/output/PSNetScanners; Import-Module ${workspaceFolder}/tools/Modules/platyPS; Update-MarkdownHelpModule ${workspaceFolder}/docs/en-US -AlphabeticParamsOrder -RefreshModulePage -UpdateInputOutput" + ], + "problemMatcher": [], + "dependsOn": [ + "build" + ] + }, + { + "label": "test", + "command": "pwsh", + "type": "shell", + "args": [ + "-File", + "${workspaceFolder}/build.ps1", + "-Task", + "Test" + ], + "problemMatcher": [], + "dependsOn": [ + "build" + ] + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dd35bd7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Santiago Squarzon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Network-IPScanner.ps1 b/Network-IPScanner.ps1 deleted file mode 100644 index aa6c036..0000000 --- a/Network-IPScanner.ps1 +++ /dev/null @@ -1,117 +0,0 @@ -$ErrorActionPreference = 'Stop' - -# Define List of IPs / Hosts to Ping -# $list = 'host1', 'host2', 'host3' -$list = 1..254 | ForEach-Object { - "192.168.0.$_" -} - -function Test-ICMPConnection { - [cmdletbinding(DefaultParameterSetName = 'DefaultParams')] - param( - [parameter(Mandatory, ValueFromPipeline, Position = 0)] - [string[]] $Address, - [parameter(ParameterSetName = 'DefaultParams', Position = 1)] - [int] $Count = 4, - [parameter(ParameterSetName = 'DefaultParams', Position = 2)] - [int] $TimeOut = 1000, - [parameter(ParameterSetName = 'Quiet', Position = 1)] - [switch] $Quiet, - [parameter(ParameterSetName = 'DefaultParams', Position = 3)] - [string] $Buffer = 'aaaaaaaaaa' - ) - - begin { - $ping = [System.Net.NetworkInformation.Ping]::new() - $options = [System.Net.NetworkInformation.PingOptions]::new() - $data = [System.Text.Encoding]::Unicode.GetBytes($Buffer) - $hostname = $env:COMPUTERNAME - $options.DontFragment = $true - } - process { - foreach($i in $Address) { - if($Quiet.IsPresent) { - return [bool]$ping.Send($i, $TimeOut, $data, $options).RoundtripTime - } - - $resolver = try { - [System.Net.Dns]::GetHostEntry($i).HostName - } - catch { '*' } - - 1..$Count | ForEach-Object { - $response = $ping.Send($i, $TimeOut, $data, $options) - $latency = ( - '*', [string]::Format('{0} ms', $response.RoundtripTime) - )[$response.Status -eq 'Success'] - - [pscustomobject]@{ - Ping = $_ - Source = $hostname - Address = $response.Address - Destination = $resolver - Latency = $latency - Status = $response.Status - } - } - } - } - end { - $ping.ForEach('Dispose') - } -} - -# Store the function Definition -$funcDef = ${function:Test-ICMPConnection}.ToString() -$scriptBlock = { - param([string] $ip, [string] $funcDef) - - # Load the function in this Scope - ${function:Test-ICMPConnection} = $funcDef - - # Define which arguments will be used for Pinger - # Default Values are: - # - # -Count 1 - # -TimeOut 1000 (Milliseconds) - # -Buffer 'aaaaaaaaaa' (10 bytes) - # -Quiet:$false - - Test-ICMPConnection -Address $ip -Count 1 -} - -& { - try { - # Change this value for tweaking - $Threshold = 100 - $RunspacePool = [runspacefactory]::CreateRunspacePool(1, $Threshold) - $RunspacePool.Open() - - $runspaces = foreach($ip in $list) { - $params = @{ - IP = $ip - funcDef = $funcDef - } - - $psinstance = [powershell]::Create().AddScript($scriptBlock).AddParameters($params) - $psinstance.RunspacePool = $RunspacePool - - [pscustomobject]@{ - Instance = $psinstance - Handle = $psinstance.BeginInvoke() - } - } - - foreach($r in $runspaces) { - $r.Instance.EndInvoke($r.Handle) - $r.Instance.foreach('Dispose') - } - } - catch { - Write-Warning $_.Exception.Message - } - finally { - $runspaces.foreach('Clear') - $RunspacePool.foreach('Dispose') - } -} | Format-Table -AutoSize diff --git a/Network-TCPScanner.ps1 b/Network-TCPScanner.ps1 deleted file mode 100644 index d226b3d..0000000 --- a/Network-TCPScanner.ps1 +++ /dev/null @@ -1,109 +0,0 @@ -$Threshold = 100 # => Number of threads running -[int[]] $PortsToScan = 80, 443, 125, 8080 -[string[]] $HostsToScan = 'google.com', 'cisco.com', 'amazon.com' - -function Test-TCPConnectionAsync { - [cmdletbinding()] - param( - [parameter(Mandatory, Valuefrompipeline)] - [string[]] $Target, - - [parameter(Mandatory, Position = 1)] - [ValidateRange(1, 65535)] - [int[]] $Port, - - # 1 second minimum, reasonable for TCP connection - [parameter(Position = 2)] - [ValidateRange(1000, [int]::MaxValue)] - [int] $TimeOut = 1200 - ) - - begin { - $timer = [System.Diagnostics.Stopwatch]::StartNew() - $tasks = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() - } - process { - foreach($t in $Target) { - foreach($i in $Port) { - if($tasks.Count -eq 62) { - Wait-Tasks - } - - $tcp = [System.Net.Sockets.TcpClient]::new() - $tasks.Add([ordered]@{ - Instance = $tcp - Task = $tcp.ConnectAsync($t, $i) - Output = [ordered]@{ - Source = $env:COMPUTERNAME - Destination = $t - Port = $i - } - }) - } - } - } - end { - do { - $id = [System.Threading.Tasks.Task]::WaitAny($tasks.Task, 200) - if($id -eq -1) { - continue - } - $instance, $task, $output = $tasks[$id][$tasks[$id].PSBase.Keys] - $output['Success'] = $task.Status -eq [System.Threading.Tasks.TaskStatus]::RanToCompletion - $instance.ForEach('Dispose') # Avoid any throws here - $tasks.RemoveAt($id) - [pscustomobject] $output - } while($tasks -and $timer.ElapsedMilliseconds -le $timeout) - - foreach($t in $tasks) { - $instance, $task, $output = $t[$t.PSBase.Keys] - $output['Success'] = $task.Status -eq [System.Threading.Tasks.TaskStatus]::RanToCompletion - $instance.ForEach('Dispose') # Avoid any throws here - [pscustomobject] $output - } - } -} - -& { - try { - # Store function definition - $funcDef = ${function:Test-TCPConnectionAsync}.ToString() - $RunspacePool = [runspacefactory]::CreateRunspacePool(1, $Threshold) - $RunspacePool.Open() - $scriptBlock = { - param([string] $hostname, [int[]] $ports, [string] $func) - - # Load the function in this Scope - ${function:Test-TCPConnectionAsync} = $func - Test-TCPConnectionAsync -Target $hostname -Port $ports -TimeOut 2000 - } - - $runspaces = foreach($i in $HostsToScan) { - $params = @{ - hostname = $i - ports = $PortsToScan - func = $funcDef - } - - $psinstance = [powershell]::Create().AddScript($scriptBlock).AddParameters($params) - $psinstance.RunspacePool = $RunspacePool - - [pscustomobject]@{ - Instance = $psinstance - Handle = $psinstance.BeginInvoke() - } - } - - foreach($r in $runspaces) { - $r.Instance.EndInvoke($r.Handle) - $r.Instance.foreach('Dispose') - } - } - catch { - Write-Warning $_.Exception.Message - } - finally { - $runspaces.foreach('Clear') - $RunspacePool.foreach('Dispose') - } -} diff --git a/README.md b/README.md index 96dee1c..c9f58b7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ -# PowerShell Network Scanners +

PSNetScanners

+
+PowerShell ICMP and TCP async scanners +

+ +[![build](https://github.com/santisq/PSNetScanners/actions/workflows/ci.yml/badge.svg)](https://github.com/santisq/PSNetScanners/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/santisq/PSNetScanners/branch/main/graph/badge.svg?token=b51IOhpLfQ)](https://codecov.io/gh/santisq/PSNetScanners) +[![PowerShell Gallery](https://img.shields.io/powershellgallery/v/PSNetScanners?label=gallery)](https://www.powershellgallery.com/packages/PSNetScanners) +[![LICENSE](https://img.shields.io/github/license/santisq/PSNetScanners)](https://github.com/santisq/PSNetScanners/blob/main/LICENSE) + +
## DESCRIPTION + Two PowerShell scripts designed to scan Network IP Ranges or hostname using [`Runspace`](https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.runspaces.runspace?view=powershellsdk-7.0.0) for faster execution. And two standalone functions using async techniques for ICMP and TCP scanning. | Name | Description | diff --git a/Test-ICMPConnectionAsync.ps1 b/Test-ICMPConnectionAsync.ps1 deleted file mode 100644 index 5c12878..0000000 --- a/Test-ICMPConnectionAsync.ps1 +++ /dev/null @@ -1,118 +0,0 @@ -using namespace System.Threading.Tasks -using namespace System.Collections.Generic -using namespace System.Net.NetworkInformation -using namespace System.Net -using namespace System.Diagnostics - -function Test-ICMPConnectionAsync { - [cmdletbinding()] - param( - [parameter(Mandatory, ValueFromPipeline, Position = 0)] - [string[]] $Address, - - [parameter(Position = 1)] - [ValidateRange(1, [int]::MaxValue)] - [int] $TimeOut = 10, # In seconds! - - [parameter(Position = 2)] - [ValidateRange(1, [int]::MaxValue)] - [int] $BufferSize = 32 - ) - - begin { - $tasks = [List[hashtable]]::new() - $timer = [Stopwatch]::StartNew() - $data = [byte[]] [char[]] 'A' * $BufferSize - $options = [PingOptions]::new() - $TimeOut = [timespan]::FromSeconds($TimeOut).TotalMilliseconds - $options.DontFragment = $true - - $outObject = { - [pscustomobject]@{ - Source = $env:COMPUTERNAME - Target = $target - Address = $response.Address.IPAddressToString - DnsName = $dnsresol - Latency = $latency - Status = $response.Status - } - } - } - process { - foreach($addr in $Address) { - $ping = [Ping]::new() - $tasks.Add(@{ - Target = $addr - Instance = $ping - PingTask = $ping.SendPingAsync($addr, $TimeOut, $data, $options) - DnsTask = [Dns]::GetHostEntryAsync($addr) - }) - } - } - end { - while($tasks -and $timer.ElapsedMilliseconds -le $timeout) { - $id = [Task]::WaitAny($tasks.PingTask, 200) - if($id -eq -1) { - continue - } - $target, $instance, $ping, $dns = $tasks[$id]['Target', 'Instance', 'PingTask', 'DnsTask'] - - try { - $response = $ping.GetAwaiter().GetResult() - $latency = [string]::Format('{0} ms', $response.RoundtripTime) - $ping.Dispose() - } - catch { - $latency = '*' - $response = @{ - Address = @{ IPAddressToString = '*' } - Status = $_.Exception.InnerException.InnerException.Message - } - } - - try { - $dnsresol = $dns.GetAwaiter().GetResult().HostName - $dns.Dispose() - } - catch { - $dnsresol = $_.Exception.InnerException.Message - } - - & $outObject - $instance.Dispose() - $tasks.RemoveAt($id) - } - - foreach($task in $tasks) { - $target, $instance, $ping, $dns = $task['Target', 'Instance', 'PingTask', 'DnsTask'] - - try { - $response = $ping.GetAwaiter().GetResult() - $latency = [string]::Format('{0} ms', $response.RoundtripTime) - $ping.Dispose() - } - catch { - $latency = '*' - $response = @{ - Address = '*' - Status = $_.Exception.InnerException.InnerException.Message - } - } - - try { - $dnsresol = $dns.GetAwaiter().GetResult().HostName - $dns.Dispose() - } - catch { - $dnsresol = $_.Exception.InnerException.Message - } - - & $outObject - $instance.Dispose() - } - } -} - -'amazon.com', 'google.com', 'facebook.com' | - Test-ICMPConnectionAsync -TimeOut 5 | - Format-Table -AutoSize \ No newline at end of file diff --git a/Test-TCPConnectionAsync.ps1 b/Test-TCPConnectionAsync.ps1 deleted file mode 100644 index 40744de..0000000 --- a/Test-TCPConnectionAsync.ps1 +++ /dev/null @@ -1,113 +0,0 @@ -using namespace System.Diagnostics -using namespace System.Collections.Generic -using namespace System.Net.Sockets -using namespace System.Threading.Tasks - -<# -.DESCRIPTION -PowerShell Function that leverages the ConnectAsync(...) Method from the TcpClient Class to send the async TCP connection requests. - -.EXAMPLE -'google.com', 'cisco.com', 'amazon.com' | Test-TCPConnectionAsync 80, 443, 8080, 389, 636 - -.EXAMPLE -@' -Target,Port -google.com,80 -google.com,443 -google.com,8080 -google.com,389 -google.com,636 -cisco.com,80 -cisco.com,443 -cisco.com,8080 -cisco.com,389 -cisco.com,636 -amazon.com,80 -amazon.com,443 -amazon.com,8080 -amazon.com,389 -amazon.com,636 -'@ | ConvertFrom-Csv | Test-TCPConnectionAsync -#> - -function Test-TCPConnectionAsync { - [cmdletbinding()] - param( - [parameter(Mandatory, Valuefrompipeline, ValueFromPipelineByPropertyName)] - [alias('ComputerName', 'HostName', 'Host', 'Server')] - [string[]] $Target, - - [parameter(Mandatory, ValueFromPipelineByPropertyName)] - [ValidateRange(1, 65535)] - [int[]] $Port, - - [parameter()] - [ValidateRange(5, [int]::MaxValue)] - [int] $TimeOut = 5, # In seconds! - - [parameter()] - [switch] $IPv6 - ) - - begin { - $timer = [Stopwatch]::StartNew() - $queue = [List[hashtable]]::new() - $TimeOut = [timespan]::FromSeconds($TimeOut).TotalMilliseconds - if($IPv6.IsPresent) { - $newTcp = { [TCPClient]::new([AddressFamily]::InterNetworkV6) } - return - } - $newTcp = { [TCPClient]::new() } - } - process { - foreach($item in $Target) { - foreach($i in $Port) { - $tcp = & $newTcp - $queue.Add(@{ - Instance = $tcp - Task = $tcp.ConnectAsync($item, $i) - Output = [ordered]@{ - Source = $env:COMPUTERNAME - Destination = $item - Port = $i - } - }) - } - } - } - end { - while($queue -and $timer.ElapsedMilliseconds -le $timeout) { - try { - $id = [Task]::WaitAny($queue.Task, 200) - if($id -eq -1) { - continue - } - $instance, $task, $output = $queue[$id]['Instance', 'Task', 'Output'] - if($instance) { - $instance.Dispose() - } - $output['Success'] = $task.Status -eq [TaskStatus]::RanToCompletion - $queue.RemoveAt($id) - [pscustomobject] $output - } - catch { - $PSCmdlet.WriteError($_) - } - } - - foreach($item in $queue) { - try { - $instance, $task, $output = $item['Instance', 'Task', 'Output'] - $output['Success'] = $task.Status -eq [TaskStatus]::RanToCompletion - if($instance) { - $instance.Dispose() - } - [pscustomobject] $output - } - catch { - $PSCmdlet.WriteError($_) - } - } - } -} \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..650b40b --- /dev/null +++ b/build.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Debug', + + [Parameter()] + [ValidateSet('Build', 'Test')] + [string[]] $Task = 'Build' +) + +$prev = $ErrorActionPreference +$ErrorActionPreference = 'Stop' + +if (-not ('ProjectBuilder.ProjectInfo' -as [type])) { + try { + $builderPath = [IO.Path]::Combine($PSScriptRoot, 'tools', 'ProjectBuilder') + Push-Location $builderPath + + dotnet @( + 'publish' + '--configuration', 'Release' + '-o', 'output' + '--framework', 'netstandard2.0' + '--verbosity', 'q' + '-nologo' + ) + + if ($LASTEXITCODE) { + throw "Failed to compiled 'ProjectBuilder'" + } + + $dll = [IO.Path]::Combine($builderPath, 'output', 'ProjectBuilder.dll') + Add-Type -Path $dll + } + finally { + Pop-Location + } +} + +$projectInfo = [ProjectBuilder.ProjectInfo]::Create($PSScriptRoot, $Configuration) +$projectInfo.GetRequirements() | Import-Module -DisableNameChecking -Force + +$ErrorActionPreference = $prev + +$invokeBuildSplat = @{ + Task = $Task + File = Convert-Path ([IO.Path]::Combine($PSScriptRoot, 'tools', 'InvokeBuild.ps1')) + ProjectInfo = $projectInfo +} +Invoke-Build @invokeBuildSplat diff --git a/module/PSNetScanners.Format.ps1xml b/module/PSNetScanners.Format.ps1xml new file mode 100644 index 0000000..0279642 --- /dev/null +++ b/module/PSNetScanners.Format.ps1xml @@ -0,0 +1,148 @@ + + + + + DnsViewSuccess + + PSNetScanners.DnsSuccess + + + + + + + Status + + + HostName + + + AddressList + + + Aliases + + + + + + + + DnsViewFailure + + PSNetScanners.DnsFailure + + + + + + + Status + + + Exception + + + + + + + + PingAsyncView + + PSNetScanners.PingResult + + + + + + + + + + + + + + + Right + + + + + + + + + + + + + Source + + + Destination + + + DisplayAddress + + + [PSNetScanners.Internal._Format]::GetFormattedLatency($_) + + + Status + + + DnsResult + + + + + + + + TcpAsyncView + + PSNetScanners.TcpResult + + + + + + 17 + + + + 17 + + + + 7 + + + + 15 + + + + + + + Source + + + Destination + + + Port + + + Status + + + + + + + + diff --git a/module/PSNetScanners.psd1 b/module/PSNetScanners.psd1 new file mode 100644 index 0000000..a4453af --- /dev/null +++ b/module/PSNetScanners.psd1 @@ -0,0 +1,144 @@ +# +# Module manifest for module 'PSNetScanners' +# +# Generated by: Santiago Squarzon +# +# Generated on: 10/07/2024 +# + +@{ + # Script module or binary module file associated with this manifest. + RootModule = 'bin/netstandard2.0/PSNetScanners.dll' + + # Version number of this module. + ModuleVersion = '1.0.0' + + # Supported PSEditions + # CompatiblePSEditions = @() + + # ID used to uniquely identify this module + GUID = 'eec20b34-11d9-4085-af34-519c5503beb2' + + # Author of this module + Author = 'Santiago Squarzon' + + # Company or vendor of this module + CompanyName = 'Unknown' + + # Copyright statement for this module + Copyright = '(c) Santiago Squarzon. All rights reserved.' + + # Description of the functionality provided by this module + Description = 'tcp and icmp async scanners for powershell' + + # Minimum version of the PowerShell engine required by this module + PowerShellVersion = '5.1' + + # 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 = @('PSNetScanners.Format.ps1xml') + + # 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 = @() + + # 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 = @( + 'Test-PingAsync' + 'Test-TcpAsync' + ) + + # 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 = @( + 'pingasync' + 'tcpasync' + ) + + # 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 = @( + 'parallel' + 'concurrency' + 'runspace' + 'parallel-processing' + 'powershell' + 'multithreading' + 'ping' + 'async' + 'tcp' + 'icmp' + ) + + # A URL to the license for this module. + LicenseUri = 'https://github.com/santisq/PSNetScanners/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/santisq/PSNetScanners' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # 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/src/PSNetScanners/Abstractions/AbstractWorker.cs b/src/PSNetScanners/Abstractions/AbstractWorker.cs new file mode 100644 index 0000000..310cce1 --- /dev/null +++ b/src/PSNetScanners/Abstractions/AbstractWorker.cs @@ -0,0 +1,40 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace PSNetScanners.Abstractions; + +internal abstract class WorkerBase(int throttle, Cancellation cancellation) + : IDisposable +{ + protected CancellationToken Token { get => _cancellation.Token; } + + protected abstract Task Worker { get; } + + internal string Source { get; } = Dns.GetHostName(); + + protected readonly Cancellation _cancellation = cancellation; + + protected readonly int _throttle = throttle; + + protected bool _disposed; + + protected abstract Task Start(); + + internal void Cancel() + { + _cancellation.Cancel(); + Wait(); + } + + internal void Wait() => Worker.GetAwaiter().GetResult(); + + protected abstract void Dispose(bool disposing); + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/src/PSNetScanners/Abstractions/AbstractWorker_T.cs b/src/PSNetScanners/Abstractions/AbstractWorker_T.cs new file mode 100644 index 0000000..dd4259d --- /dev/null +++ b/src/PSNetScanners/Abstractions/AbstractWorker_T.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace PSNetScanners.Abstractions; + +internal abstract class WorkerBase(int throttle, Cancellation cancellation) + : WorkerBase(throttle, cancellation) +{ + protected virtual BlockingCollection InputQueue { get; } = []; + + protected virtual BlockingCollection OutputQueue { get; } = []; + + internal void Enqueue(TInput item) => InputQueue.Add(item, Token); + + internal void CompleteAdding() => InputQueue.CompleteAdding(); + + internal virtual IEnumerable GetOutput() => OutputQueue.GetConsumingEnumerable(Token); + + internal bool TryTake(out Output result) => OutputQueue.TryTake(out result, 0, Token); + + protected async Task ProcessOneAsync( + List> tasks) + { + Task task = await Task.WhenAny(tasks); + tasks.Remove(task); + await ProcessTaskAsync(task); + } + + protected abstract Task ProcessTaskAsync(Task task); + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + _cancellation.Cancel(); + InputQueue.Dispose(); + OutputQueue.Dispose(); + } + + _disposed = true; + } +} diff --git a/src/PSNetScanners/Abstractions/PSNetScannerCommandBase.cs b/src/PSNetScanners/Abstractions/PSNetScannerCommandBase.cs new file mode 100644 index 0000000..de0d97c --- /dev/null +++ b/src/PSNetScanners/Abstractions/PSNetScannerCommandBase.cs @@ -0,0 +1,43 @@ +using System.Management.Automation; + +namespace PSNetScanners.Abstractions; + +public abstract class PSNetScannerCommandBase : PSCmdlet +{ + [Parameter( + Mandatory = true, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true, + Position = 0)] + [Alias([ + "ComputerName", + "HostName", + "Host", + "Server", + "Address"])] + public string[] Target { get; set; } = null!; + + [Parameter] + [ValidateRange(1, int.MaxValue)] + [Alias("tl")] + public int ThrottleLimit { get; set; } = 50; + + [Parameter] + [ValidateRange(1, int.MaxValue)] + [Alias(["timeout", "to", "ct"])] + public int? ConnectionTimeout { get; set; } + + internal void Process(Output output) + { + switch (output.Type) + { + case Type.Success: + WriteObject(output.Data); + break; + + case Type.Error: + WriteError((ErrorRecord)output.Data); + break; + } + } +} diff --git a/src/PSNetScanners/Cancellation.cs b/src/PSNetScanners/Cancellation.cs new file mode 100644 index 0000000..8cb41e1 --- /dev/null +++ b/src/PSNetScanners/Cancellation.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace PSNetScanners; + +internal sealed class Cancellation : IDisposable +{ + private readonly CancellationTokenSource _cts; + + internal CancellationToken Token { get => _cts.Token; } + + internal Task Task { get; } + + internal Cancellation() + { + _cts = new CancellationTokenSource(); + Task = Task.Delay(Timeout.Infinite, _cts.Token); + } + + internal Task GetTimeoutTask(int timeout) => Task.Delay(timeout, Token); + + internal void Cancel() => _cts.Cancel(); + + public void Dispose() => _cts.Dispose(); +} diff --git a/src/PSNetScanners/Commands/TestPingAsyncCommand.cs b/src/PSNetScanners/Commands/TestPingAsyncCommand.cs new file mode 100644 index 0000000..916484b --- /dev/null +++ b/src/PSNetScanners/Commands/TestPingAsyncCommand.cs @@ -0,0 +1,96 @@ +using System; +using System.Management.Automation; +using System.Net.NetworkInformation; +using System.Text; +using PSNetScanners.Abstractions; +using PSNetScanners.Dbg; + +namespace PSNetScanners.Commands; + +[Cmdlet(VerbsDiagnostic.Test, "PingAsync")] +[OutputType(typeof(PingResult))] +[Alias("pingasync")] +public sealed class TestPingAsyncCommand : PSNetScannerCommandBase, IDisposable +{ + [Parameter] + [ValidateRange(1, 65500)] + [Alias("bfs")] + public int BufferSize { get; set; } = 32; + + [Parameter] + [Alias("dns")] + public SwitchParameter ResolveDns { get; set; } + + [Parameter] + public int Ttl { get; set; } = 128; + + [Parameter] + public SwitchParameter DontFragment { get; set; } + + private PingWorker? _worker; + + protected override void BeginProcessing() + { + PingAsyncOptions options = new() + { + PingOptions = new PingOptions(Ttl, DontFragment.IsPresent), + Buffer = Encoding.ASCII.GetBytes(new string('A', BufferSize)), + TaskTimeout = ConnectionTimeout ?? 4000, + ThrottleLimit = ThrottleLimit, + ResolveDns = ResolveDns.IsPresent + }; + + _worker = new PingWorker(options); + } + + protected override void ProcessRecord() + { + Debug.Assert(_worker is not null); + + try + { + foreach (string address in Target) + { + _worker.Enqueue(address); + } + + while (_worker.TryTake(out Output data)) + { + Process(data); + } + } + catch (Exception _) when (_ is PipelineStoppedException or FlowControlException) + { + _worker.Cancel(); + throw; + } + } + + protected override void EndProcessing() + { + Debug.Assert(_worker is not null); + + try + { + _worker.CompleteAdding(); + foreach (Output data in _worker.GetOutput()) + { + Process(data); + } + _worker.Wait(); + } + catch (Exception _) when (_ is PipelineStoppedException or FlowControlException) + { + _worker.Cancel(); + throw; + } + } + + protected override void StopProcessing() => _worker?.Cancel(); + + public void Dispose() + { + _worker?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/PSNetScanners/Commands/TestTcpAsyncCommand.cs b/src/PSNetScanners/Commands/TestTcpAsyncCommand.cs new file mode 100644 index 0000000..e2511c4 --- /dev/null +++ b/src/PSNetScanners/Commands/TestTcpAsyncCommand.cs @@ -0,0 +1,87 @@ +using System; +using System.Management.Automation; +using System.Net; +using PSNetScanners.Abstractions; +using PSNetScanners.Dbg; + +namespace PSNetScanners.Commands; + +[Cmdlet(VerbsDiagnostic.Test, "TcpAsync")] +[OutputType(typeof(TcpResult))] +[Alias("tcpasync")] +public sealed class TestTcpAsyncCommand : PSNetScannerCommandBase, IDisposable +{ + [Parameter( + Mandatory = true, + ValueFromPipelineByPropertyName = true, + Position = 1)] + [ValidateRange(IPEndPoint.MinPort, IPEndPoint.MaxPort)] + [Alias("p")] + public int[] Port { get; set; } = null!; + + private TcpWorker? _worker; + + protected override void BeginProcessing() + { + _worker = new TcpWorker( + throttle: ThrottleLimit, + timeout: ConnectionTimeout ?? 4000); + } + + protected override void ProcessRecord() + { + Debug.Assert(_worker is not null); + + try + { + foreach (string address in Target) + { + foreach (int port in Port) + { + _worker.Enqueue(new TcpInput( + source: _worker.Source, + target: address, + port: port)); + } + } + + while (_worker.TryTake(out Output data)) + { + Process(data); + } + } + catch (Exception _) when (_ is PipelineStoppedException or FlowControlException) + { + _worker.Cancel(); + throw; + } + } + + protected override void EndProcessing() + { + Debug.Assert(_worker is not null); + + try + { + _worker.CompleteAdding(); + foreach (Output data in _worker.GetOutput()) + { + Process(data); + } + _worker.Wait(); + } + catch (Exception _) when (_ is PipelineStoppedException or FlowControlException) + { + _worker.Cancel(); + throw; + } + } + + protected override void StopProcessing() => _worker?.Cancel(); + + public void Dispose() + { + _worker?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/PSNetScanners/Dbg/Dbg.cs b/src/PSNetScanners/Dbg/Dbg.cs new file mode 100644 index 0000000..834d974 --- /dev/null +++ b/src/PSNetScanners/Dbg/Dbg.cs @@ -0,0 +1,11 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace PSNetScanners.Dbg; + +internal static class Debug +{ + [Conditional("DEBUG")] + public static void Assert([DoesNotReturnIf(false)] bool condition) => + System.Diagnostics.Debug.Assert(condition); +} diff --git a/src/PSNetScanners/Dbg/Nullable.cs b/src/PSNetScanners/Dbg/Nullable.cs new file mode 100644 index 0000000..ad74d3b --- /dev/null +++ b/src/PSNetScanners/Dbg/Nullable.cs @@ -0,0 +1,150 @@ +#if !NETCOREAPP + +namespace System.Diagnostics.CodeAnalysis; + +/// Specifies that null is allowed as an input even if the corresponding type disallows it. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class AllowNullAttribute : Attribute { } + +/// Specifies that null is disallowed as an input even if the corresponding type allows it. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class DisallowNullAttribute : Attribute { } + +/// Specifies that an output may be null even if the corresponding type disallows it. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class MaybeNullAttribute : Attribute { } + +/// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class NotNullAttribute : Attribute { } + +/// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class MaybeNullWhenAttribute : Attribute +{ + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } +} + +/// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class NotNullWhenAttribute : Attribute +{ + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } +} + +/// Specifies that the output will be non-null if the named parameter is non-null. +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class NotNullIfNotNullAttribute : Attribute +{ + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; + + /// Gets the associated parameter name. + public string ParameterName { get; } +} + +/// Applied to a method that will never return under any circumstance. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class DoesNotReturnAttribute : Attribute { } + +/// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class DoesNotReturnIfAttribute : Attribute +{ + /// Initializes the attribute with the specified parameter value. + /// + /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to + /// the associated parameter matches this value. + /// + public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; + + /// Gets the condition parameter value. + public bool ParameterValue { get; } +} + +/// Specifies that the method or property will ensure that the listed field and property members have not-null values. +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] +[ExcludeFromCodeCoverage] +internal sealed class MemberNotNullAttribute : Attribute +{ + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute(string member) => Members = new[] { member }; + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute(params string[] members) => Members = members; + + /// Gets field or property member names. + public string[] Members { get; } +} + +/// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] +[ExcludeFromCodeCoverage] +internal sealed class MemberNotNullWhenAttribute : Attribute +{ + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + + /// Gets field or property member names. + public string[] Members { get; } +} + +#endif diff --git a/src/PSNetScanners/DnsResult.cs b/src/PSNetScanners/DnsResult.cs new file mode 100644 index 0000000..0795929 --- /dev/null +++ b/src/PSNetScanners/DnsResult.cs @@ -0,0 +1,43 @@ +using System; +using System.Net; + +namespace PSNetScanners; + +public abstract class DnsResult(DnsStatus status) +{ + public DnsStatus Status { get; } = status; +} + +public sealed class DnsSuccess : DnsResult +{ + public string HostName { get => _entry.HostName; } + + public IPAddress[] AddressList { get => _entry.AddressList; } + + public string[] Aliases { get => _entry.Aliases; } + + private readonly IPHostEntry _entry; + + internal DnsSuccess(IPHostEntry entry) : base(DnsStatus.Success) + { + _entry = entry; + } + + public override string ToString() => HostName; +} + +public class DnsFailure : DnsResult +{ + public Exception Exception { get; } + + internal DnsFailure(DnsStatus status, Exception exception) + : base(status) + { + Exception = exception; + } + + internal static DnsFailure CreateTimeout() => + new(DnsStatus.Timeout, new TimeoutException()); + + public override string ToString() => Exception.Message; +} diff --git a/src/PSNetScanners/Enums.cs b/src/PSNetScanners/Enums.cs new file mode 100644 index 0000000..e4113eb --- /dev/null +++ b/src/PSNetScanners/Enums.cs @@ -0,0 +1,21 @@ +namespace PSNetScanners; + +internal enum Type +{ + Success, + Error +} + +public enum DnsStatus +{ + Success, + Timeout, + Error +} + +public enum TcpStatus +{ + Opened, + TimedOut, + Closed +} diff --git a/src/PSNetScanners/ExceptionHelpers.cs b/src/PSNetScanners/ExceptionHelpers.cs new file mode 100644 index 0000000..6ee189d --- /dev/null +++ b/src/PSNetScanners/ExceptionHelpers.cs @@ -0,0 +1,10 @@ +using System; +using System.Management.Automation; + +namespace PSNetScanners; + +internal static class ExceptionHelpers +{ + internal static ErrorRecord CreateProcessing(this Exception exception, object context) => + new(exception, errorId: "ProcessingTaskFailure", ErrorCategory.ConnectionError, context); +} diff --git a/src/PSNetScanners/Internal/_Format.cs b/src/PSNetScanners/Internal/_Format.cs new file mode 100644 index 0000000..adad67e --- /dev/null +++ b/src/PSNetScanners/Internal/_Format.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; +using System.Management.Automation; + +namespace PSNetScanners.Internal; + +#pragma warning disable IDE1006 + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class _Format +{ + [Hidden, EditorBrowsable(EditorBrowsableState.Never)] + public static string GetFormattedLatency(PingResult pingResult) => pingResult._displayLatency; +} diff --git a/src/PSNetScanners/PSNetScanners.csproj b/src/PSNetScanners/PSNetScanners.csproj new file mode 100644 index 0000000..2f16263 --- /dev/null +++ b/src/PSNetScanners/PSNetScanners.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + PSNetScanners + enable + true + latest + + + + + + + + true + true + + + + + + + diff --git a/src/PSNetScanners/PingResult.cs b/src/PSNetScanners/PingResult.cs new file mode 100644 index 0000000..a0ab85d --- /dev/null +++ b/src/PSNetScanners/PingResult.cs @@ -0,0 +1,124 @@ +using System; +using System.Net; +using System.Net.NetworkInformation; +using System.Threading.Tasks; + +namespace PSNetScanners; + +public sealed class PingResult +{ + private IPStatus? _status; + + private IPAddress? _ip; + + private long? _latency; + + private string? _displayAddress; + + internal readonly string _displayLatency; + + public string Source { get; } + + public string Destination { get; } + + public IPAddress? Address + { + get => _ip ??= Status is IPStatus.Success ? Reply?.Address : null; + } + + public string DisplayAddress + { + get => _displayAddress ??= Address?.ToString() ?? "*"; + } + + public long Latency + { + get => _latency ??= Reply?.RoundtripTime ?? 0; + } + + public IPStatus Status + { + get => _status ??= Reply?.Status ?? IPStatus.TimedOut; + } + + public DnsResult? DnsResult { get; } + + public PingReply? Reply { get; } + + private PingResult( + string source, + string destination, + DnsResult? dns = null, + PingReply? reply = null) + { + Source = source; + Destination = destination; + DnsResult = dns; + Reply = reply; + _displayLatency = Status is IPStatus.Success ? $"{Latency} ms" : "*"; + } + + internal static async Task CreateAsync( + string source, + string destination, + PingAsyncOptions options, + Cancellation cancellation) + { + using Ping ping = new(); + Task pingTask = ping.SendPingAsync( + hostNameOrAddress: destination, + timeout: options.TaskTimeout, + buffer: options.Buffer, + options: options.PingOptions); + + if (!options.ResolveDns) + { + return new PingResult( + source: source, + destination: destination, + reply: await pingTask); + } + + Task dnsTask = GetDnsAsync(destination, options, cancellation); + Task result = await Task.WhenAny(pingTask, cancellation.Task, dnsTask); + + if (result != dnsTask && result != pingTask) + { + return new PingResult( + source: source, + destination: destination, + dns: DnsFailure.CreateTimeout()); + } + + return new PingResult( + source: source, + destination: destination, + dns: await dnsTask, + reply: await pingTask); + } + + private static async Task GetDnsAsync( + string destination, + PingAsyncOptions options, + Cancellation cancellation) + { + Task dns = Dns.GetHostEntryAsync(destination); + Task timeout = cancellation.GetTimeoutTask(options.TaskTimeout); + Task result = await Task.WhenAny(dns, timeout); + + if (result == timeout) + { + return DnsFailure.CreateTimeout(); + } + + try + { + IPHostEntry entry = await dns; + return new DnsSuccess(entry); + } + catch (Exception exception) + { + return new DnsFailure(DnsStatus.Error, exception); + } + } +} diff --git a/src/PSNetScanners/PingWorker.cs b/src/PSNetScanners/PingWorker.cs new file mode 100644 index 0000000..8212989 --- /dev/null +++ b/src/PSNetScanners/PingWorker.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Net.NetworkInformation; +using System.Threading.Tasks; +using PSNetScanners.Abstractions; + +namespace PSNetScanners; + +internal sealed class PingWorker : WorkerBase +{ + protected override Task Worker { get; } + + private readonly PingAsyncOptions _options; + + internal PingWorker(PingAsyncOptions options) + : base(options.ThrottleLimit, new Cancellation()) + { + _options = options; + Worker = Task.Run(Start, Token); + } + + protected override async Task Start() + { + List> tasks = []; + + while (!InputQueue.IsCompleted) + { + if (InputQueue.TryTake(out string host, 0, Token)) + { + tasks.Add(PingResult.CreateAsync( + source: Source, + destination: host, + options: _options, + cancellation: _cancellation)); + + if (tasks.Count == _throttle) + { + await ProcessOneAsync(tasks); + } + } + } + + while (tasks.Count > 0) + { + await ProcessOneAsync(tasks); + } + + OutputQueue.CompleteAdding(); + } + + protected override async Task ProcessTaskAsync(Task task) + { + try + { + PingResult result = await task; + OutputQueue.Add(Output.CreateSuccess(result), Token); + } + catch (PingException exception) + { + ErrorRecord error = exception.InnerException.CreateProcessing(task); + OutputQueue.Add(Output.CreateError(error), Token); + } + catch (Exception exception) + { + ErrorRecord error = exception.CreateProcessing(task); + OutputQueue.Add(Output.CreateError(error), Token); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _cancellation.Dispose(); + } +} diff --git a/src/PSNetScanners/Structs.cs b/src/PSNetScanners/Structs.cs new file mode 100644 index 0000000..56eb98d --- /dev/null +++ b/src/PSNetScanners/Structs.cs @@ -0,0 +1,54 @@ +using System.Management.Automation; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace PSNetScanners; + +internal record struct PingAsyncOptions( + PingOptions PingOptions, + int ThrottleLimit, + int TaskTimeout, + byte[] Buffer, + bool ResolveDns); + +internal record struct Output(Type Type, object Data) +{ + internal static Output CreateSuccess(object Data) => + new(Type.Success, Data); + + internal static Output CreateError(ErrorRecord error) => + new(Type.Error, error); +} + +internal readonly record struct TcpInput +{ + internal string Source { get; } + + internal string Target { get; } + + internal int Port { get; } + + internal AddressFamily AddressFamily { get; } + + internal TcpInput(string source, string target, int port) + { + Source = source; + Target = target; + Port = port; + AddressFamily = IPAddress.TryParse(target, out IPAddress ip) + ? ip.AddressFamily + : AddressFamily.InterNetwork; + } + + internal void Deconstruct( + out string source, + out string target, + out int port) + { + source = Source; + target = Target; + port = Port; + } + +} diff --git a/src/PSNetScanners/TcpResult.cs b/src/PSNetScanners/TcpResult.cs new file mode 100644 index 0000000..1b6ce26 --- /dev/null +++ b/src/PSNetScanners/TcpResult.cs @@ -0,0 +1,65 @@ +using System; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace PSNetScanners; + +public sealed class TcpResult +{ + public string Source { get; } + + public string Destination { get; } + + public int Port { get; } + + public TcpStatus Status { get; } + + public Exception? Details { get; } + + private TcpResult( + TcpInput input, + TcpStatus status, + Exception? details = null) + { + (Source, Destination, Port) = input; + Status = status; + Details = details; + } + + private static TcpResult CreateSuccess(TcpInput input) => + new(input, TcpStatus.Opened); + + private static TcpResult CreateTimeout(TcpInput input) => + new(input, TcpStatus.TimedOut, new TimeoutException()); + + private static TcpResult CreateError(TcpInput input, Exception exception) => + new(input, TcpStatus.Closed, exception); + + internal static async Task CreateAsync( + TcpInput input, + Cancellation cancellation, + int timeout) + { + try + { + using TcpClient tcp = new(input.AddressFamily); + Task tcpTask = tcp.ConnectAsync(input.Target, input.Port); + Task result = await Task.WhenAny( + tcpTask, + cancellation.Task, + cancellation.GetTimeoutTask(timeout)); + + if (result == tcpTask) + { + await tcpTask; + return CreateSuccess(input); + } + + return CreateTimeout(input); + } + catch (Exception exception) + { + return CreateError(input, exception); + } + } +} diff --git a/src/PSNetScanners/TcpWorker.cs b/src/PSNetScanners/TcpWorker.cs new file mode 100644 index 0000000..4d93fed --- /dev/null +++ b/src/PSNetScanners/TcpWorker.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Threading.Tasks; +using PSNetScanners.Abstractions; + +namespace PSNetScanners; + +internal sealed class TcpWorker : WorkerBase +{ + protected override Task Worker { get; } + + private readonly int _timeout; + + internal TcpWorker(int throttle, int timeout) + : base(throttle, new Cancellation()) + { + _timeout = timeout; + Worker = Task.Run(Start, Token); + } + + protected override async Task Start() + { + List> tasks = []; + while (!InputQueue.IsCompleted) + { + if (InputQueue.TryTake(out TcpInput input, 0, Token)) + { + tasks.Add(TcpResult.CreateAsync( + input: input, + cancellation: _cancellation, + timeout: _timeout)); + + if (tasks.Count == _throttle) + { + await ProcessOneAsync(tasks); + } + } + } + + while (tasks.Count > 0) + { + await ProcessOneAsync(tasks); + } + + OutputQueue.CompleteAdding(); + } + + protected override async Task ProcessTaskAsync(Task task) + { + try + { + TcpResult result = await task; + OutputQueue.Add(Output.CreateSuccess(result), Token); + } + catch (Exception exception) + { + ErrorRecord error = exception.CreateProcessing(task); + OutputQueue.Add(Output.CreateError(error), Token); + } + } +} diff --git a/tests/PingAsync.tests.ps1 b/tests/PingAsync.tests.ps1 new file mode 100644 index 0000000..5d8dcb2 --- /dev/null +++ b/tests/PingAsync.tests.ps1 @@ -0,0 +1,143 @@ +using namespace System.IO + +$moduleName = (Get-Item ([Path]::Combine($PSScriptRoot, '..', 'module', '*.psd1'))).BaseName +$manifestPath = [Path]::Combine($PSScriptRoot, '..', 'output', $moduleName) + +Import-Module $manifestPath +Import-Module ([Path]::Combine($PSScriptRoot, 'common.psm1')) + +Describe TestPingAsyncCommand { + Context 'Output Streams' { + It 'Success' { + Test-PingAsync -Target github.com | + Should -BeOfType ([PSNetScanners.PingResult]) + } + + It 'Error' { + { Test-PingAsync -Target "$([guid]::NewGuid()).com" -ErrorAction Stop } | + Should -Throw -ExceptionType ([System.Net.Sockets.SocketException]) + } + } + + Context 'DnsResult Type' { + It 'DnsSuccess' { + $result = Test-PingAsync 8.8.8.8 -ResolveDns + $result.DnsResult | Should -BeOfType ([PSNetScanners.DnsSuccess]) + $result.DnsResult.Status | Should -Be ([PSNetScanners.DnsStatus]::Success) + $result.DnsResult.AddressList | Should -BeOfType ([ipaddress]) + $result.DnsResult.Aliases.Count | Should -BeGreaterOrEqual 0 + } + + It 'DnsFailure' { + $result = makeiprange 10.0.0 1 255 | + Test-PingAsync -ResolveDns | + Where-Object { $_.DnsResult.Status -eq [PSNetScanners.DnsStatus]::Error } | + Select-Object -First 1 + $result.DnsResult | Should -BeOfType ([PSNetScanners.DnsFailure]) + $result.DnsResult.Status | Should -Be ([PSNetScanners.DnsStatus]::Error) + } + } + + Context 'PingResult Type' { + BeforeAll { + $ping = Test-PingAsync 127.0.0.1 + $ping | Out-Null + } + + It 'Source' { + $ping.Source | Should -Not -BeNullOrEmpty + $ping.Source | Should -BeOfType ([string]) + } + + It 'Destination' { + $ping.Destination | Should -Not -BeNullOrEmpty + $ping.Destination | Should -BeOfType ([string]) + } + + It 'DisplayAddress' { + $ping.DisplayAddress | Should -Not -BeNullOrEmpty + $ping.DisplayAddress | Should -BeOfType ([string]) + } + + It 'Status' { + $ping.Status | Should -BeOfType ([System.Net.NetworkInformation.IPStatus]) + } + + It 'Address' { + $ping.Address | Should -BeOfType ([ipaddress]) + } + + It 'Latency' { + $ping.Latency | Should -BeOfType ([long]) + } + } + + Context 'Test-PingAsync' { + BeforeAll { + $range = makeiprange 127.0.0 1 255 + $range | Out-Null + } + + It 'Parallel Pings' { + Measure-Command { $range | Test-PingAsync } | + ForEach-Object TotalMinutes | + Should -BeLessThan 2 + } + + It 'Stops processing early' { + Measure-Command { $range | Test-PingAsync | Select-Object -First 10 } | + ForEach-Object TotalSeconds | + Should -BeLessThan 10 + } + } + + Context 'Parameters' { + BeforeAll { + $range = makeiprange 127.0.0 1 20 + $range | Out-Null + } + + It 'ConnectionTimeout' { + $range | Test-PingAsync -ConnectionTimeout 200 -ErrorAction Stop | + Should -HaveCount 20 + + $range | Test-PingAsync -ConnectionTimeout 200 -ResolveDns -ErrorAction Stop | + Should -HaveCount 20 + } + + It 'ThrottleLimit' { + $targets | Test-PingAsync -ThrottleLimit 1 | + Should -HaveCount 17 + + $range | Test-PingAsync -ThrottleLimit 300 -ErrorAction Stop | + Should -HaveCount 20 + } + + It 'BufferSize' { + $range | Test-PingAsync -BufferSize 1 -ErrorAction Stop | + Should -HaveCount 20 + } + + It 'Ttl' { + $range | Test-PingAsync -Ttl 1 -ErrorAction Stop | + Should -HaveCount 20 + } + + It 'DontFragment' { + $range | Test-PingAsync -DontFragment -ErrorAction Stop | + Should -HaveCount 20 + } + } + + Context 'Formatting' { + BeforeAll { + $ping = Test-PingAsync 127.0.0.1 + $ping | Out-Null + } + + It 'Gets format string for Latency' { + [PSNetScanners.Internal._Format]::GetFormattedLatency($ping) | + Should -Not -BeNullOrEmpty + } + } +} diff --git a/tests/TcpAsync.tests.ps1 b/tests/TcpAsync.tests.ps1 new file mode 100644 index 0000000..a616400 --- /dev/null +++ b/tests/TcpAsync.tests.ps1 @@ -0,0 +1,81 @@ +using namespace System.IO + +$moduleName = (Get-Item ([Path]::Combine($PSScriptRoot, '..', 'module', '*.psd1'))).BaseName +$manifestPath = [Path]::Combine($PSScriptRoot, '..', 'output', $moduleName) + +Import-Module $manifestPath +Import-Module ([Path]::Combine($PSScriptRoot, 'common.psm1')) + +Describe TestPingAsyncCommand { + Context 'Output Streams' { + It 'Success' { + Test-TcpAsync -Target github.com -Port 80 | + Should -BeOfType ([PSNetScanners.TcpResult]) + } + } + + Context 'TcpResult Type' { + BeforeAll { + $result = Test-TcpAsync -Target github.com -Port 80 + $result | Out-Null + } + + It 'Source' { + $result.Source | Should -Not -BeNullOrEmpty + $result.Source | Should -BeOfType ([string]) + } + + It 'Destination' { + $result.Destination | Should -Not -BeNullOrEmpty + $result.Destination | Should -BeOfType ([string]) + } + + It 'Port' { + $result.Port | Should -Not -BeNullOrEmpty + $result.Port | Should -BeOfType ([int]) + } + + It 'Status' { + $result.Status | Should -BeExactly ([PSNetScanners.TcpStatus]::Opened) + } + + It 'Details' { + $result = Test-TcpAsync -Target github.com -Port 8080 -ConnectionTimeout ([int]::MaxValue) + $result.Details | Should -BeOfType ([System.Net.Sockets.SocketException]) + } + } + + Context 'Test-TcpAsync' { + It 'Parallel Tcp Tests' { + Measure-Command { $targets | Test-TcpAsync } | + ForEach-Object TotalSeconds | + Should -BeLessOrEqual 150 + } + + It 'Stops processing early' { + Measure-Command { $targets | Test-TcpAsync | Select-Object -First 5 } | + ForEach-Object TotalSeconds | + Should -BeLessOrEqual 1 + } + } + + Context 'Parameters' { + It 'ThrottleLimit' { + $result = $targets | Test-TcpAsync -ThrottleLimit 1 -ConnectionTimeout ([int]::MaxValue) + $result | Should -HaveCount $targets.Count + $result.Status | Should -Contain ([PSNetScanners.TcpStatus]::Opened) + $result.Status | Should -Contain ([PSNetScanners.TcpStatus]::Closed) + } + + It 'ConnectionTimeout' { + $result = $targets | Test-TcpAsync -ThrottleLimit $targets.Count -ConnectionTimeout 200 + $result | Should -HaveCount $targets.Count + $result.Status | Should -Contain ([PSNetScanners.TcpStatus]::Opened) + $result.Status | Should -Contain ([PSNetScanners.TcpStatus]::TimedOut) + $result | + Where-Object Status -EQ TimedOut | + ForEach-Object Details | + Should -BeOfType ([System.TimeoutException]) + } + } +} diff --git a/tests/common.psm1 b/tests/common.psm1 new file mode 100644 index 0000000..8114f6f --- /dev/null +++ b/tests/common.psm1 @@ -0,0 +1,28 @@ +function makeiprange { + param([string] $ip, [int] $start, [int] $end) + + $start..$end | ForEach-Object { "$ip.$_" } +} + +$targets = @' +Target,Port +google.com,80 +github.com,80 +cisco.com,80 +amazon.com,80 +google.com,443 +cisco.com,443 +amazon.com,443 +google.com,389 +cisco.com,389 +amazon.com,389 +google.com,8080 +github.com,8080 +amazon.com,8080 +cisco.com,8080 +google.com,636 +cisco.com,636 +amazon.com,636 +'@ | ConvertFrom-Csv + +Export-ModuleMember -Function * -Variable * diff --git a/tools/InvokeBuild.ps1 b/tools/InvokeBuild.ps1 new file mode 100644 index 0000000..dd81671 --- /dev/null +++ b/tools/InvokeBuild.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ProjectBuilder.ProjectInfo] $ProjectInfo +) + +task Clean { + $ProjectInfo.CleanRelease() +} + +task BuildDocs { + if (Test-Path $ProjectInfo.Documentation.Source) { + $helpParams = $ProjectInfo.Documentation.GetParams() + $null = New-ExternalHelp @helpParams + } +} + +task BuildManaged { + $arguments = $ProjectInfo.GetBuildArgs() + Push-Location -LiteralPath $ProjectInfo.Project.Source.FullName + + try { + foreach ($framework in $ProjectInfo.Project.TargetFrameworks) { + Write-Host "Compiling for $framework" + dotnet @arguments --framework $framework + + if ($LASTEXITCODE) { + throw "Failed to compiled code for $framework" + } + } + } + finally { + Pop-Location + } +} + +task CopyToRelease { + $ProjectInfo.Module.CopyToRelease() + $ProjectInfo.Project.CopyToRelease() +} + +task Package { + $ProjectInfo.Project.ClearNugetPackage() + $repoParams = $ProjectInfo.Project.GetPSRepoParams() + + if (Get-PSRepository -Name $repoParams.Name -ErrorAction SilentlyContinue) { + Unregister-PSRepository -Name $repoParams.Name + } + + Register-PSRepository @repoParams + try { + $publishModuleSplat = @{ + Path = $ProjectInfo.Project.Release + Repository = $repoParams.Name + } + Publish-Module @publishModuleSplat + } + finally { + Unregister-PSRepository -Name $repoParams.Name + } +} + +task Analyze { + if (-not $ProjectInfo.AnalyzerPath) { + Write-Host 'No Analyzer Settings found, skipping' + return + } + + $pssaSplat = $ProjectInfo.GetAnalyzerParams() + $results = Invoke-ScriptAnalyzer @pssaSplat + + if ($results) { + $results | Out-String + throw 'Failed PsScriptAnalyzer tests, build failed' + } +} + +task PesterTests { + if (-not $ProjectInfo.Pester.PesterScript) { + Write-Host 'No Pester tests found, skipping' + return + } + + $ProjectInfo.Pester.ClearResultFile() + + if (-not (dotnet tool list --global | Select-String coverlet.console -SimpleMatch)) { + Write-Host 'Installing dotnet tool coverlet.console' -ForegroundColor Yellow + dotnet tool install --global coverlet.console + } + + coverlet $ProjectInfo.Pester.GetTestArgs($PSVersionTable.PSVersion) + + if ($LASTEXITCODE) { + throw 'Pester failed tests' + } +} + +task Build -Jobs Clean, BuildManaged, CopyToRelease, BuildDocs, Package +task Test -Jobs BuildManaged, Analyze, PesterTests diff --git a/tools/PesterTest.ps1 b/tools/PesterTest.ps1 new file mode 100644 index 0000000..5646712 --- /dev/null +++ b/tools/PesterTest.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [String] $TestPath, + + [Parameter(Mandatory)] + [String] $OutputFile +) + +Get-ChildItem ([IO.Path]::Combine($PSScriptRoot, 'Modules')) -Directory | + Import-Module -Name { $_.FullName } -Force -DisableNameChecking + +[PSCustomObject] $PSVersionTable | Select-Object *, @{ + Name = 'Architecture' + Expression = { + switch ([IntPtr]::Size) { + 4 { 'x86' } + 8 { 'x64' } + default { 'Unknown' } + } + } +} | Format-List | Out-Host + +$configuration = [PesterConfiguration]::Default +$configuration.Output.Verbosity = 'Detailed' +$configuration.Run.Path = $TestPath +$configuration.Run.Throw = $true +$configuration.TestResult.Enabled = $true +$configuration.TestResult.OutputPath = $OutputFile +$configuration.TestResult.OutputFormat = 'NUnitXml' + +Invoke-Pester -Configuration $configuration -WarningAction Ignore diff --git a/tools/ProjectBuilder/Documentation.cs b/tools/ProjectBuilder/Documentation.cs new file mode 100644 index 0000000..1ba3e01 --- /dev/null +++ b/tools/ProjectBuilder/Documentation.cs @@ -0,0 +1,12 @@ +using System.Collections; + +namespace ProjectBuilder; + +public record struct Documentation(string Source, string Output) +{ + public readonly Hashtable GetParams() => new() + { + ["Path"] = Source, + ["OutputPath"] = Output + }; +} diff --git a/tools/ProjectBuilder/Extensions.cs b/tools/ProjectBuilder/Extensions.cs new file mode 100644 index 0000000..cc2c0fe --- /dev/null +++ b/tools/ProjectBuilder/Extensions.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; + +namespace ProjectBuilder; + +internal static class Extensions +{ + internal static void CopyRecursive(this DirectoryInfo source, string? destination) + { + if (destination is null) + { + throw new ArgumentNullException($"Destination path is null.", nameof(destination)); + } + + if (!Directory.Exists(destination)) + { + Directory.CreateDirectory(destination); + } + + foreach (DirectoryInfo dir in source.EnumerateDirectories("*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(dir.FullName.Replace(source.FullName, destination)); + } + + foreach (FileInfo file in source.EnumerateFiles("*", SearchOption.AllDirectories)) + { + file.CopyTo(file.FullName.Replace(source.FullName, destination)); + } + } +} diff --git a/tools/ProjectBuilder/Module.cs b/tools/ProjectBuilder/Module.cs new file mode 100644 index 0000000..a2ff7cf --- /dev/null +++ b/tools/ProjectBuilder/Module.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Management.Automation; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ProjectBuilder; + +public sealed class Module +{ + public DirectoryInfo Root { get; } + + public FileInfo? Manifest { get; internal set; } + + public Version? Version { get; internal set; } + + public string Name { get; } + + public string PreRequisitePath { get; } + + private string? Release { get => _info.Project.Release; } + + private readonly UriBuilder _builder = new(_base); + + private const string _base = "https://www.powershellgallery.com"; + + private const string _path = "api/v2/package/{0}/{1}"; + + private readonly ProjectInfo _info; + + private Hashtable? _req; + + internal Module( + DirectoryInfo directory, + string name, + ProjectInfo info) + { + Root = directory; + Name = name; + PreRequisitePath = InitPrerequisitePath(Root); + _info = info; + } + + public void CopyToRelease() => Root.CopyRecursive(Release); + + internal IEnumerable GetRequirements(string path) + { + _req ??= ImportRequirements(path); + + if (_req is { Count: 0 }) + { + return []; + } + + List modules = new(_req.Count); + foreach (DictionaryEntry entry in _req) + { + modules.Add(new ModuleDownload + { + Module = entry.Key.ToString(), + Version = LanguagePrimitives.ConvertTo(entry.Value) + }); + } + + return DownloadModules([.. modules]); + } + + private static Hashtable ImportRequirements(string path) + { + using PowerShell powerShell = PowerShell.Create(RunspaceMode.CurrentRunspace); + return powerShell + .AddCommand("Import-PowerShellDataFile") + .AddArgument(path) + .Invoke() + .FirstOrDefault(); + } + + private string[] DownloadModules(ModuleDownload[] modules) + { + List> tasks = new(modules.Length); + List output = new(modules.Length); + + foreach ((string module, Version version) in modules) + { + string destination = GetDestination(module); + string modulePath = GetModulePath(module); + + if (Directory.Exists(modulePath)) + { + output.Add(modulePath); + continue; + } + + Console.WriteLine($"Installing build pre-req '{module}'"); + _builder.Path = string.Format(_path, module, version); + Task task = GetModuleAsync( + uri: _builder.Uri.ToString(), + destination: destination, + expandPath: modulePath); + tasks.Add(task); + } + + output.AddRange(WaitTask(tasks)); + return [.. output]; + } + + private static string[] WaitTask(List> tasks) => + WaitTaskAsync(tasks).GetAwaiter().GetResult(); + + private static void ExpandArchive(string source, string destination) => + ZipFile.ExtractToDirectory(source, destination); + + private static async Task WaitTaskAsync( + List> tasks) + { + List completedTasks = new(tasks.Count); + while (tasks.Count > 0) + { + Task awaiter = await Task.WhenAny(tasks); + tasks.Remove(awaiter); + string module = await awaiter; + completedTasks.Add(module); + } + return [.. completedTasks]; + } + + private string GetDestination(string module) => + Path.Combine(PreRequisitePath, Path.ChangeExtension(module, "zip")); + + private string GetModulePath(string module) => + Path.Combine(PreRequisitePath, module); + + private static async Task GetModuleAsync( + string uri, + string destination, + string expandPath) + { + using (FileStream fs = File.Create(destination)) + { + using HttpClient client = new(); + using Stream stream = await client.GetStreamAsync(uri); + await stream.CopyToAsync(fs); + } + + ExpandArchive(destination, expandPath); + File.Delete(destination); + return expandPath; + } + + private static string InitPrerequisitePath(DirectoryInfo root) + { + string path = Path.Combine(root.Parent.FullName, "tools", "Modules"); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + return path; + } +} diff --git a/tools/ProjectBuilder/Pester.cs b/tools/ProjectBuilder/Pester.cs new file mode 100644 index 0000000..fccaac1 --- /dev/null +++ b/tools/ProjectBuilder/Pester.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace ProjectBuilder; + +public sealed class Pester +{ + public string? PesterScript + { + get + { + _pesterPath ??= Path.Combine( + _info.Root.FullName, + "tools", + "PesterTest.ps1"); + + if (_testsExist = File.Exists(_pesterPath)) + { + return _pesterPath; + } + + return null; + } + } + + public string? ResultPath { get => _testsExist ? Path.Combine(_info.Project.Build, "TestResults") : null; } + + public string? ResultFile { get => _testsExist ? Path.Combine(ResultPath, "Pester.xml") : null; } + + private readonly ProjectInfo _info; + + private string? _pesterPath; + + private bool _testsExist; + + internal Pester(ProjectInfo info) => _info = info; + + private void CreateResultPath() + { + if (!Directory.Exists(ResultPath)) + { + Directory.CreateDirectory(ResultPath); + } + } + + public void ClearResultFile() + { + if (File.Exists(ResultFile)) + { + File.Delete(ResultFile); + } + } + + public string[] GetTestArgs(Version version) + { + CreateResultPath(); + + List arguments = [ + "-NoProfile", + "-NonInteractive", + ]; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + arguments.AddRange([ "-ExecutionPolicy", "Bypass" ]); + } + + arguments.AddRange([ + "-File", PesterScript!, + "-TestPath", Path.Combine(_info.Root.FullName, "tests"), + "-OutputFile", ResultFile! + ]); + + Regex re = new("^|$", RegexOptions.Compiled); + string targetArgs = re.Replace(string.Join("\" \"", [.. arguments]), "\""); + string pwsh = Regex.Replace(Environment.GetCommandLineArgs().First(), @"\.dll$", string.Empty); + string unitCoveragePath = Path.Combine(ResultPath, "UnitCoverage.json"); + string watchFolder = Path.Combine(_info.Project.Release, "bin", _info.Project.TestFramework); + string sourceMappingFile = Path.Combine(ResultPath, "CoverageSourceMapping.txt"); + + if (version is not { Major: >= 7, Minor: > 0 }) + { + targetArgs = re.Replace(targetArgs, "\""); + watchFolder = re.Replace(watchFolder, "\""); + } + + arguments.Clear(); + arguments.AddRange([ + watchFolder, + "--target", pwsh, + "--targetargs", targetArgs, + "--output", Path.Combine(ResultPath, "Coverage.xml"), + "--format", "cobertura" + ]); + + if (File.Exists(unitCoveragePath)) + { + arguments.AddRange([ "--merge-with", unitCoveragePath ]); + } + + if (Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is "true") + { + arguments.AddRange([ "--source-mapping-file", sourceMappingFile ]); + File.WriteAllText( + sourceMappingFile, + $"|{_info.Root.FullName}{Path.DirectorySeparatorChar}=/_/"); + } + + return [.. arguments]; + } +} diff --git a/tools/ProjectBuilder/Project.cs b/tools/ProjectBuilder/Project.cs new file mode 100644 index 0000000..831c292 --- /dev/null +++ b/tools/ProjectBuilder/Project.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections; +using System.IO; +using System.Linq; + +namespace ProjectBuilder; + +public sealed class Project +{ + public DirectoryInfo Source { get; } + + public string Build { get; } + + public string? Release { get; internal set; } + + public string[]? TargetFrameworks { get; internal set; } + + public string? TestFramework { get => TargetFrameworks.FirstOrDefault(); } + + private Configuration Configuration { get => _info.Configuration; } + + private readonly ProjectInfo _info; + + internal Project(DirectoryInfo source, string build, ProjectInfo info) + { + Source = source; + Build = build; + _info = info; + } + + public void CopyToRelease() + { + if (TargetFrameworks is null) + { + throw new ArgumentNullException( + "TargetFrameworks is null.", + nameof(TargetFrameworks)); + } + + foreach (string framework in TargetFrameworks) + { + DirectoryInfo buildFolder = new(Path.Combine( + Source.FullName, + "bin", + Configuration.ToString(), + framework, + "publish")); + + string binFolder = Path.Combine(Release, "bin", framework); + buildFolder.CopyRecursive(binFolder); + } + } + + public Hashtable GetPSRepoParams() => new() + { + ["Name"] = "LocalRepo", + ["SourceLocation"] = Build, + ["PublishLocation"] = Build, + ["InstallationPolicy"] = "Trusted" + }; + + public void ClearNugetPackage() + { + string nugetPath = Path.Combine( + Build, + $"{_info.Module.Name}.{_info.Module.Version}.nupkg"); + + if (File.Exists(nugetPath)) + { + File.Delete(nugetPath); + } + } +} diff --git a/tools/ProjectBuilder/ProjectBuilder.csproj b/tools/ProjectBuilder/ProjectBuilder.csproj new file mode 100644 index 0000000..6a1a2a5 --- /dev/null +++ b/tools/ProjectBuilder/ProjectBuilder.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0 + enable + latest + ProjectBuilder + + + + + + + diff --git a/tools/ProjectBuilder/ProjectInfo.cs b/tools/ProjectBuilder/ProjectInfo.cs new file mode 100644 index 0000000..b1f7b2c --- /dev/null +++ b/tools/ProjectBuilder/ProjectInfo.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Xml; + +namespace ProjectBuilder; + +public sealed class ProjectInfo +{ + public DirectoryInfo Root { get; } + + public Module Module { get; } + + public Configuration Configuration { get; internal set; } + + public Documentation Documentation { get; internal set; } + + public Project Project { get; } + + public Pester Pester { get; } + + public string? AnalyzerPath + { + get + { + _analyzerPath ??= Path.Combine( + Root.FullName, + "ScriptAnalyzerSettings.psd1"); + + if (File.Exists(_analyzerPath)) + { + return _analyzerPath; + } + + return null; + } + } + + private string? _analyzerPath; + + private ProjectInfo(string path) + { + Root = AssertDirectory(path); + + Module = new Module( + directory: AssertDirectory(GetModulePath(path)), + name: Path.GetFileNameWithoutExtension(path), + info: this); + + Project = new Project( + source: AssertDirectory(GetSourcePath(path, Module.Name)), + build: GetBuildPath(path), + info: this); + + Pester = new(this); + } + + public static ProjectInfo Create( + string path, + Configuration configuration) + { + ProjectInfo builder = new(path) + { + Configuration = configuration + }; + builder.Module.Manifest = GetManifest(builder); + builder.Module.Version = GetManifestVersion(builder); + builder.Project.Release = GetReleasePath( + builder.Project.Build, + builder.Module.Name, + builder.Module.Version!); + builder.Project.TargetFrameworks = GetTargetFrameworks(GetProjectFile(builder)); + builder.Documentation = new Documentation + { + Source = Path.Combine(builder.Root.FullName, "docs", "en-US"), + Output = Path.Combine(builder.Project.Release, "en-US") + }; + + return builder; + } + + public IEnumerable GetRequirements() + { + string req = Path.Combine(Root.FullName, "tools", "requiredModules.psd1"); + if (!File.Exists(req)) + { + return []; + } + return Module.GetRequirements(req); + } + + public void CleanRelease() + { + if (Directory.Exists(Project.Release)) + { + Directory.Delete(Project.Release, recursive: true); + } + Directory.CreateDirectory(Project.Release); + } + + public string[] GetBuildArgs() => + [ + "publish", + "--configuration", Configuration.ToString(), + "--verbosity", "q", + "-nologo", + $"-p:Version={Module.Version}" + ]; + + public Hashtable GetAnalyzerParams() => new() + { + ["Path"] = Project.Release, + ["Settings"] = AnalyzerPath, + ["Recurse"] = true, + ["ErrorAction"] = "SilentlyContinue" + }; + + private static string[] GetTargetFrameworks(string path) + { + XmlDocument xmlDocument = new(); + xmlDocument.Load(path); + return xmlDocument + .SelectSingleNode("Project/PropertyGroup/TargetFrameworks") + .InnerText + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + } + + private static string GetBuildPath(string path) => + Path.Combine(path, "output"); + + private static string GetSourcePath(string path, string moduleName) => + Path.Combine(path, "src", moduleName); + + private static string GetModulePath(string path) => + Path.Combine(path, "module"); + + private static string GetReleasePath( + string buildPath, + string moduleName, + Version version) => Path.Combine( + buildPath, + moduleName, + LanguagePrimitives.ConvertTo(version)); + + private static DirectoryInfo AssertDirectory(string path) + { + DirectoryInfo directory = new(path); + return directory.Exists ? directory + : throw new ArgumentException( + $"Path '{path}' could not be found or is not a Directory.", + nameof(path)); + } + + private static FileInfo GetManifest(ProjectInfo builder) => + builder.Module.Root.EnumerateFiles("*.psd1").FirstOrDefault() + ?? throw new FileNotFoundException( + $"Manifest file could not be found in '{builder.Root.FullName}'"); + + private static string GetProjectFile(ProjectInfo builder) => + builder.Project.Source.EnumerateFiles("*.csproj").FirstOrDefault()?.FullName + ?? throw new FileNotFoundException( + $"Project file could not be found in ''{builder.Project.Source.FullName}'"); + + private static Version? GetManifestVersion(ProjectInfo builder) + { + using PowerShell powershell = PowerShell.Create(RunspaceMode.CurrentRunspace); + Hashtable? moduleInfo = powershell + .AddCommand("Import-PowerShellDataFile") + .AddArgument(builder.Module.Manifest?.FullName) + .Invoke() + .FirstOrDefault(); + + return powershell.HadErrors + ? throw powershell.Streams.Error.First().Exception + : LanguagePrimitives.ConvertTo(moduleInfo?["ModuleVersion"]); + } +} diff --git a/tools/ProjectBuilder/Types.cs b/tools/ProjectBuilder/Types.cs new file mode 100644 index 0000000..3106122 --- /dev/null +++ b/tools/ProjectBuilder/Types.cs @@ -0,0 +1,11 @@ +using System; + +namespace ProjectBuilder; + +public enum Configuration +{ + Debug, + Release +} + +internal record struct ModuleDownload(string Module, Version Version); diff --git a/tools/prompt.ps1 b/tools/prompt.ps1 new file mode 100644 index 0000000..483e08b --- /dev/null +++ b/tools/prompt.ps1 @@ -0,0 +1 @@ +function prompt { "PS $($PWD.Path -replace '.+(?=\\)', '..')$('>' * ($nestedPromptLevel + 1)) " } diff --git a/tools/requiredModules.psd1 b/tools/requiredModules.psd1 new file mode 100644 index 0000000..a486014 --- /dev/null +++ b/tools/requiredModules.psd1 @@ -0,0 +1,6 @@ +@{ + InvokeBuild = '5.11.2' + platyPS = '0.14.2' + PSScriptAnalyzer = '1.22.0' + Pester = '5.6.0' +}