diff --git a/.github/workflows/ci-powershell.yml b/.github/workflows/ci-powershell.yml index adc4cd1f2..900d1806b 100644 --- a/.github/workflows/ci-powershell.yml +++ b/.github/workflows/ci-powershell.yml @@ -51,8 +51,18 @@ jobs: - name: Run Pester Tests shell: powershell run: | - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: powershell diff --git a/.github/workflows/ci-pwsh7_5.yml b/.github/workflows/ci-pwsh7_5.yml index 1b0c51cce..fe19e54ff 100644 --- a/.github/workflows/ci-pwsh7_5.yml +++ b/.github/workflows/ci-pwsh7_5.yml @@ -72,7 +72,18 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: pwsh diff --git a/.github/workflows/ci-pwsh_lts.yml b/.github/workflows/ci-pwsh_lts.yml index 70ab3d0d3..de2e79856 100644 --- a/.github/workflows/ci-pwsh_lts.yml +++ b/.github/workflows/ci-pwsh_lts.yml @@ -71,7 +71,18 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: pwsh diff --git a/.github/workflows/ci-pwsh_preview.yml b/.github/workflows/ci-pwsh_preview.yml index 43198540f..60c2e9c0d 100644 --- a/.github/workflows/ci-pwsh_preview.yml +++ b/.github/workflows/ci-pwsh_preview.yml @@ -71,7 +71,18 @@ jobs: - name: Run Pester Tests shell: pwsh run: | - Invoke-Build Test + # Check if the runner is in debug mode + if ($env:RUNNER_DEBUG -eq '1') { + $debug = $true + } else { + $debug = $false + } + + if ($debug) { + Invoke-Build Test -PesterVerbosity Diagnostic + } else { + Invoke-Build Test + } - name: Build Packages shell: pwsh diff --git a/.gitignore b/.gitignore index 079cb2f3a..c0662ab79 100644 --- a/.gitignore +++ b/.gitignore @@ -266,3 +266,5 @@ examples/PetStore/data/PetData.json packers/choco/pode.nuspec packers/choco/tools/ChocolateyInstall.ps1 docs/Getting-Started/Samples.md +examples/HelloService/*_svcsettings.json +examples/HelloService/svc_settings diff --git a/Pode.sln b/Pode.sln index 66eb3805a..02438e28a 100644 --- a/Pode.sln +++ b/Pode.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{41F81369-868 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Pode", "src\Listener\Pode.csproj", "{772D5C9F-1B25-46A7-8977-412A5F7F77D1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PodeMonitor", "src\PodeMonitor\PodeMonitor.csproj", "{A927D6A5-A2AC-471A-9ABA-45916B597EB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/docs/Hosting/PortsBelow1024.md b/docs/Hosting/PortsBelow1024.md new file mode 100644 index 000000000..c5a1c31fa --- /dev/null +++ b/docs/Hosting/PortsBelow1024.md @@ -0,0 +1,55 @@ +# Using Ports Below 1024 + +#### Introduction + +Traditionally in Linux, binding to ports below 1024 requires root privileges. This is a security measure, as these low-numbered ports are considered privileged. However, running applications as the root user poses significant security risks. This article explores methods to use these privileged ports with PowerShell (`pwsh`) in Linux, without running it as the root user. +There are different methods to achieve the goals. +Reverse Proxy is the right approach for a production environment, primarily if the server is connected directly to the internet. +The other solutions are reasonable after an in-depth risk analysis. + +#### Using a Reverse Proxy + +A reverse proxy like Nginx can listen on the privileged port and forward requests to your application running on an unprivileged port. + +**Configuration:** + +* Configure Nginx to listen on port 443 and forward requests to the port where your PowerShell script is listening. +* This method is widely used in web applications for its additional benefits like load balancing and SSL termination. + +#### iptables Redirection + +Using iptables, you can redirect traffic from a privileged port to a higher, unprivileged port. + +**Implementation:** + +* Set up an iptables rule to redirect traffic from, say, port 443 to a higher port where your PowerShell script is listening. +* `sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080` + +**Benefits:** + +* This approach doesn't require changing the privileges of the PowerShell executable or script. + +#### Using `setcap` Command + +The `setcap` utility can grant specific capabilities to an executable, like `pwsh`, enabling it to bind to privileged ports. + +**How it Works:** + +* Run `sudo setcap 'cap_net_bind_service=+ep' $(which pwsh)`. This command sets the `CAP_NET_BIND_SERVICE` capability on the PowerShell executable, allowing it to bind to any port below 1024. + +**Security Consideration:** + +* This method enhances security by avoiding running PowerShell as root, but it still grants significant privileges to the PowerShell process. + +#### Utilizing Authbind + +Authbind is a tool that allows a non-root user to bind to privileged ports. + +**Setup:** + +* Install Authbind, configure it to allow the desired port, and then start your PowerShell script using Authbind. +* For instance, `authbind --deep pwsh yourscript.ps1` allows the script to bind to a privileged port. + +**Advantages:** + +* It provides a finer-grained control over port access and doesn't require setting special capabilities on the PowerShell binary itself. diff --git a/docs/Hosting/RunAsService.md b/docs/Hosting/RunAsService.md index f880dc63c..734a7fbb8 100644 --- a/docs/Hosting/RunAsService.md +++ b/docs/Hosting/RunAsService.md @@ -1,138 +1,275 @@ -# Service +# Using Pode as a Service -Rather than having to manually invoke your Pode server script each time, it's best if you can have it start automatically when your computer/server starts. Below you'll see how to set your script to run as either a Windows or a Linux service. +Pode provides built-in functions to easily manage services across platforms (Windows, Linux, macOS). These functions allow you to register, start, stop, suspend, resume, query, and unregister Pode services in a cross-platform way. -## Windows +--- -To run your Pode server as a Windows service, we recommend using the [`NSSM`](https://nssm.cc) tool. To install on Windows you can use Chocolatey: +## Registering a Service + +The `Register-PodeService` function creates the necessary service files and configurations for your system. + +#### Example: ```powershell -choco install nssm -y +Register-PodeService -Name "HelloService" -Description "Example Pode Service" -ParameterString "-Verbose" -Start ``` -Once installed, you'll need to set the location of the `pwsh` or `powershell` executables as a variable: +This registers a service named "HelloService" and starts it immediately after registration. The service runs your Pode script with the specified parameters. -```powershell -$exe = (Get-Command pwsh.exe).Source +### `Register-PodeService` Parameters -# or +The `Register-PodeService` function offers several parameters to customize your service registration: -$exe = (Get-Command powershell.exe).Source -``` +- **`-Name`** *(string)*: + The name of the service to register. + **Mandatory**. + +- **`-Description`** *(string)*: + A brief description of the service. Defaults to `"This is a Pode service."`. + +- **`-DisplayName`** *(string)*: + The display name for the service (Windows only). Defaults to `"Pode Service($Name)"`. + +- **`-StartupType`** *(string)*: + Specifies the startup type of the service (`'Automatic'` or `'Manual'`). Defaults to `'Automatic'`. + +- **`-ParameterString`** *(string)*: + Additional parameters to pass to the worker script when the service is run. Defaults to an empty string. + +- **`-LogServicePodeHost`** *(switch)*: + Enables logging for the Pode service host. + +- **`-ShutdownWaitTimeMs`** *(int)*: + Maximum time in milliseconds to wait for the service to shut down gracefully before forcing termination. Defaults to `30,000 ms`. + +- **`-StartMaxRetryCount`** *(int)*: + Maximum number of retries to start the PowerShell process before giving up. Defaults to `3`. + +- **`-StartRetryDelayMs`** *(int)*: + Delay in milliseconds between retry attempts to start the PowerShell process. Defaults to `5,000 ms`. + +- **`-WindowsUser`** *(string)*: + Specifies the username under which the service will run. Defaults to the current user (Windows only). + +- **`-LinuxUser`** *(string)*: + Specifies the username under which the service will run. Defaults to the current user (Linux Only). + +- **`-Agent`** *(switch)*: + Create an Agent instead of a Daemon in MacOS (MacOS Only). -Next, define the name of the Windows service; as well as the full file path to your Pode server script, and the arguments to be supplied to PowerShell: +- **`-Start`** *(switch)*: + Starts the service immediately after registration. + +- **`-Password`** *(securestring)*: + A secure password for the service account (Windows only). If omitted, the service account will be `'NT AUTHORITY\SYSTEM'`. + +- **`-SecurityDescriptorSddl`** *(string)*: + A security descriptor in SDDL format specifying the permissions for the service (Windows only). + +- **`-SettingsPath`** *(string)*: + Directory to store the service configuration file (`_svcsettings.json`). Defaults to a directory under the script path. + +- **`-LogPath`** *(string)*: + Path for the service log files. Defaults to a directory under the script path. + +--- + +## Starting a Service + +You can start a registered service using the `Start-PodeService` function. + +#### Example: ```powershell -$name = 'Pode Web Server' -$file = 'C:\Pode\Server.ps1' -$arg = "-ExecutionPolicy Bypass -NoProfile -Command `"$($file)`"" +Start-PodeService -Name "HelloService" ``` -Finally, install and start the service: +This returns `$true` if the service starts successfully, `$false` otherwise. + +--- + +## Stopping a Service + +To stop a running service, use the `Stop-PodeService` function. + +#### Example: ```powershell -nssm install $name $exe $arg -nssm start $name +Stop-PodeService -Name "HelloService" ``` -!!! info - You can now navigate to your server, ie: `http://localhost:8080`. +This returns `$true` if the service stops successfully, `$false` otherwise. + +--- + +## Suspending a Service -To stop (or remove) the service afterwards, you can use the following: +Suspend a running service (Windows only) with the `Suspend-PodeService` function. + +#### Example: ```powershell -nssm stop $name -nssm remove $name confirm +Suspend-PodeService -Name "HelloService" ``` -## Linux +This pauses the service, returning `$true` if successful. + +--- + +## Resuming a Service + +Resume a suspended service (Windows only) using the `Resume-PodeService` function. -To run your Pode server as a Linux service you just need to create a `.service` file at `/etc/systemd/system`. The following is example content for an example `pode-server.service` file, which run PowerShell Core (`pwsh`), as well as you script: +#### Example: -```bash -sudo vim /etc/systemd/system/pode-server.service +```powershell +Resume-PodeService -Name "HelloService" ``` -```bash -[Unit] -Description=Pode Web Server -After=network.target +This resumes the service, returning `$true` if successful. + +--- + +## Querying a Service -[Service] -ExecStart=/usr/bin/pwsh -c /usr/src/pode/server.ps1 -nop -ep Bypass -Restart=always +To check the status of a service, use the `Get-PodeService` function. -[Install] -WantedBy=multi-user.target -Alias=pode-server.service +#### Example: + +```powershell +Get-PodeService -Name "HelloService" ``` -Finally, start the service: +This returns a hashtable with the service details: + +```powershell +Name Value +---- ----- +Status Running +Pid 17576 +Name HelloService +Sudo True +``` + +--- + +## Restarting a Service + +Restart a running service using the `Restart-PodeService` function. + +#### Example: ```powershell -sudo systemctl start pode-server +Restart-PodeService -Name "HelloService" ``` -!!! info - You can now navigate to your server, ie: `http://localhost:8080`. +This stops and starts the service, returning `$true` if successful. + +--- + +## Unregistering a Service -To stop the service afterwards, you can use the following: +When you no longer need a service, unregister it with the `Unregister-PodeService` function. + +#### Example: ```powershell -sudo systemctl stop pode-server +Unregister-PodeService -Name "HelloService" -Force ``` -### Using Ports Below 1024 -#### Introduction +This forcefully stops and removes the service, returning `$true` if successful. + +--- + +## Alternative Methods for Windows and Linux + +If the Pode functions are unavailable or you prefer manual management, you can use traditional methods to configure Pode as a service. + +### Windows (NSSM) + +To use NSSM for Pode as a Windows service: + +1. Install NSSM using Chocolatey: + + ```powershell + choco install nssm -y + ``` + +2. Configure the service: + + ```powershell + $exe = (Get-Command pwsh.exe).Source + $name = 'Pode Web Server' + $file = 'C:\Pode\Server.ps1' + $arg = "-ExecutionPolicy Bypass -NoProfile -Command `"$($file)`"" + nssm install $name $exe $arg + nssm start $name + ``` -Traditionally in Linux, binding to ports below 1024 requires root privileges. This is a security measure, as these low-numbered ports are considered privileged. However, running applications as the root user poses significant security risks. This article explores methods to use these privileged ports with PowerShell (`pwsh`) in Linux, without running it as the root user. -There are different methods to achieve the goals. -Reverse Proxy is the right approach for a production environment, primarily if the server is connected directly to the internet. -The other solutions are reasonable after an in-depth risk analysis. +3. Stop or remove the service: -#### Using a Reverse Proxy + ```powershell + nssm stop $name + nssm remove $name confirm + ``` -A reverse proxy like Nginx can listen on the privileged port and forward requests to your application running on an unprivileged port. +--- -**Configuration:** +### Linux (systemd) -* Configure Nginx to listen on port 443 and forward requests to the port where your PowerShell script is listening. -* This method is widely used in web applications for its additional benefits like load balancing and SSL termination. +To configure Pode as a Linux service: -#### iptables Redirection +1. Create a service file: -Using iptables, you can redirect traffic from a privileged port to a higher, unprivileged port. + ```bash + sudo vim /etc/systemd/system/pode-server.service + ``` -**Implementation:** +2. Add the following configuration: -* Set up an iptables rule to redirect traffic from, say, port 443 to a higher port where your PowerShell script is listening. -* `sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080` + ```bash + [Unit] + Description=Pode Web Server + After=network.target -**Benefits:** + [Service] + ExecStart=/usr/bin/pwsh -c /usr/src/pode/server.ps1 -nop -ep Bypass + Restart=always -* This approach doesn't require changing the privileges of the PowerShell executable or script. + [Install] + WantedBy=multi-user.target + Alias=pode-server.service + ``` -#### Using `setcap` Command +3. Start and stop the service: -The `setcap` utility can grant specific capabilities to an executable, like `pwsh`, enabling it to bind to privileged ports. + ```bash + sudo systemctl start pode-server + sudo systemctl stop pode-server + ``` -**How it Works:** +--- -* Run `sudo setcap 'cap_net_bind_service=+ep' $(which pwsh)`. This command sets the `CAP_NET_BIND_SERVICE` capability on the PowerShell executable, allowing it to bind to any port below 1024. +## Using Ports Below 1024 -**Security Consideration:** +For privileged ports, consider: -* This method enhances security by avoiding running PowerShell as root, but it still grants significant privileges to the PowerShell process. +1. **Reverse Proxy:** Use Nginx to forward traffic from port 443 to an unprivileged port. -#### Utilizing Authbind +2. **iptables Redirection:** Redirect port 443 to an unprivileged port: -Authbind is a tool that allows a non-root user to bind to privileged ports. + ```bash + sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 8080 + ``` -**Setup:** +3. **setcap Command:** Grant PowerShell permission to bind privileged ports: -* Install Authbind, configure it to allow the desired port, and then start your PowerShell script using Authbind. -* For instance, `authbind --deep pwsh yourscript.ps1` allows the script to bind to a privileged port. + ```bash + sudo setcap 'cap_net_bind_service=+ep' $(which pwsh) + ``` -**Advantages:** +4. **Authbind:** Configure Authbind to allow binding to privileged ports: -* It provides a finer-grained control over port access and doesn't require setting special capabilities on the PowerShell binary itself. + ```bash + authbind --deep pwsh yourscript.ps1 + ``` diff --git a/examples/HelloService/HelloService.ps1 b/examples/HelloService/HelloService.ps1 new file mode 100644 index 000000000..678c8bb99 --- /dev/null +++ b/examples/HelloService/HelloService.ps1 @@ -0,0 +1,196 @@ +<# +.SYNOPSIS + PowerShell script to register, manage, and set up a Pode service named '$ServiceName'. + +.DESCRIPTION + This script provides commands to register, start, stop, query, suspend, resume, restart, and unregister a Pode service named '$ServiceName'. + It also sets up a Pode server that listens on the specified port (default 8080) and includes a basic GET route that responds with 'Hello, Service!'. + + The script checks if the Pode module exists locally and imports it; otherwise, it imports Pode from the system. + + To test the Pode server's HTTP endpoint: + Invoke-RestMethod -Uri http://localhost:8080/ -Method Get + # Response: 'Hello, Service!' + +.PARAMETER ServiceName + Name of the service to register (Default 'Hello Service'). + +.PARAMETER Register + Registers the $ServiceName with Pode. + +.PARAMETER Password + A secure password for the service account (Windows only). If omitted, the service account will be 'NT AUTHORITY\SYSTEM'. + +.PARAMETER Agent + Defines the service as an Agent instead of a Daemon.(macOS only) + +.PARAMETER Unregister + Unregisters the $ServiceName from Pode. Use with the -Force switch to forcefully unregister the service. + +.PARAMETER Force + Used with the -Unregister parameter to forcefully unregister the service. + +.PARAMETER Start + Starts the $ServiceName. + +.PARAMETER Stop + Stops the $ServiceName. + +.PARAMETER Query + Queries the status of the $ServiceName. + +.PARAMETER Suspend + Suspends the $ServiceName. + +.PARAMETER Resume + Resumes the $ServiceName. + +.PARAMETER Restart + Restarts the $ServiceName. + +.EXAMPLE + Register the service: + ./HelloService.ps1 -Register + +.EXAMPLE + Start the service: + ./HelloService.ps1 -Start + +.EXAMPLE + Query the service: + ./HelloService.ps1 -Query + +.EXAMPLE + Stop the service: + ./HelloService.ps1 -Stop + +.EXAMPLE + Unregister the service: + ./HelloService.ps1 -Unregister -Force + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/HelloService/HelloService.ps1 + +.NOTES + Author: Pode Team + License: MIT License +#> + +[CmdletBinding(DefaultParameterSetName = 'Inbuilt')] +param( + [Parameter( ParameterSetName = 'Inbuilt')] + [int] + $Port = 8080, + + [Parameter( ParameterSetName = 'Inbuilt')] + [string] + $ServiceName = 'Hello Service', + + [Parameter(Mandatory = $true, ParameterSetName = 'Register')] + [switch] + $Register, + + [Parameter(Mandatory = $false, ParameterSetName = 'Register', ValueFromPipeline = $true )] + [securestring] + $Password, + + [switch] + $Agent, + + [Parameter(Mandatory = $true, ParameterSetName = 'Unregister')] + [switch] + $Unregister, + + [Parameter( ParameterSetName = 'Unregister')] + [switch] + $Force, + + [Parameter( ParameterSetName = 'Start')] + [switch] + $Start, + + [Parameter( ParameterSetName = 'Stop')] + [switch] + $Stop, + + [Parameter( ParameterSetName = 'Query')] + [switch] + $Query, + + [Parameter( ParameterSetName = 'Suspend')] + [switch] + $Suspend, + + [Parameter( ParameterSetName = 'Resume')] + [switch] + $Resume, + + [Parameter( ParameterSetName = 'Restart')] + [switch] + $Restart +) +try { + # Get the path of the script being executed + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + # Get the parent directory of the script's path + $podePath = Split-Path -Parent -Path $ScriptPath + + # Check if the Pode module file exists in the specified path + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + # If the Pode module file exists, import it + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + # If the Pode module file does not exist, import the Pode module from the system + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { + # If there is any error during the module import, throw the error + throw +} + + +if ( $Register.IsPresent) { + return Register-PodeService -Name $ServiceName -ParameterString "-Port $Port" -Password $Password -Agent:($Agent.IsPresent) +} +if ( $Unregister.IsPresent) { + return Unregister-PodeService -Name $ServiceName -Force:$Force -Agent:($Agent.IsPresent) +} +if ($Start.IsPresent) { + return Start-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Stop.IsPresent) { + return Stop-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Suspend.IsPresent) { + return Suspend-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Resume.IsPresent) { + return Resume-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Query.IsPresent) { + return Get-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +if ($Restart.IsPresent) { + return Restart-PodeService -Name $ServiceName -Agent:($Agent.IsPresent) +} + +# Start the Pode server +Start-PodeServer { + New-PodeLoggingMethod -File -Name 'errors' -MaxDays 4 -Path './logs' | Enable-PodeErrorLogging -Levels Informational + + # Add an HTTP endpoint listening on localhost at port 8080 + Add-PodeEndpoint -Address localhost -Port $Port -Protocol Http + + # Add a route for GET requests to the root path '/' + Add-PodeRoute -Method Get -Path '/' -ScriptBlock { + # Send a text response with 'Hello, world!' + Write-PodeTextResponse -Value 'Hello, Service!' + } +} diff --git a/examples/HelloService/HelloServices.ps1 b/examples/HelloService/HelloServices.ps1 new file mode 100644 index 000000000..94b9730c3 --- /dev/null +++ b/examples/HelloService/HelloServices.ps1 @@ -0,0 +1,191 @@ +<# +.SYNOPSIS + Script to manage multiple Pode services and set up a basic Pode server. + +.DESCRIPTION + This script registers, starts, stops, queries, and unregisters multiple Pode services based on the specified hashtable. + Additionally, it sets up a Pode server that listens on a defined port and includes routes to handle incoming HTTP requests. + + The script checks if the Pode module exists in the local path and imports it; otherwise, it uses the system-wide Pode module. + +.PARAMETER Register + Registers all services specified in the hashtable. + +.PARAMETER Password + A secure password for the service account (Windows only). If omitted, the service account will be 'NT AUTHORITY\SYSTEM'. + +.PARAMETER Agent + Defines the service as an Agent instead of a Daemon.(macOS only) + +.PARAMETER Unregister + Unregisters all services specified in the hashtable. Use with -Force to force unregistration. + +.PARAMETER Force + Forces unregistration when used with the -Unregister parameter. + +.PARAMETER Start + Starts all services specified in the hashtable. + +.PARAMETER Stop + Stops all services specified in the hashtable. + +.PARAMETER Query + Queries the status of all services specified in the hashtable. + +.PARAMETER Suspend + Suspend the 'Hello Service'. + +.PARAMETER Resume + Resume the 'Hello Service'. + +.PARAMETER Restart + Restart the 'Hello Service'. + +.EXAMPLE + Register all services: + ./HelloServices.ps1 -Register + +.EXAMPLE + Start all services: + ./HelloServices.ps1 -Start + +.EXAMPLE + Query the status of all services: + ./HelloServices.ps1 -Query + +.EXAMPLE + Stop all services: + ./HelloServices.ps1 -Stop + +.EXAMPLE + Forcefully unregister all services: + ./HelloServices.ps1 -Unregister -Force + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/HelloService/HelloServices.ps1 + +.NOTES + Author: Pode Team + License: MIT License +#> + + +[CmdletBinding(DefaultParameterSetName = 'Inbuilt')] +param( + [Parameter( ParameterSetName = 'Inbuilt')] + [int] + $Port = 8080, + + [Parameter(Mandatory = $true, ParameterSetName = 'Register')] + [switch] + $Register, + + [Parameter(Mandatory = $false, ParameterSetName = 'Register', ValueFromPipeline = $true )] + [securestring] + $Password, + + [switch] + $Agent, + + [Parameter(Mandatory = $true, ParameterSetName = 'Unregister')] + [switch] + $Unregister, + + [Parameter( ParameterSetName = 'Unregister')] + [switch] + $Force, + + [Parameter( ParameterSetName = 'Start')] + [switch] + $Start, + + [Parameter( ParameterSetName = 'Stop')] + [switch] + $Stop, + + [Parameter( ParameterSetName = 'Query')] + [switch] + $Query, + + [Parameter( ParameterSetName = 'Suspend')] + [switch] + $Suspend, + + [Parameter( ParameterSetName = 'Resume')] + [switch] + $Resume, + + [Parameter( ParameterSetName = 'Restart')] + [switch] + $Restart +) +try { + # Get the path of the script being executed + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + # Get the parent directory of the script's path + $podePath = Split-Path -Parent -Path $ScriptPath + + # Check if the Pode module file exists in the specified path + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + # If the Pode module file exists, import it + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + # If the Pode module file does not exist, import the Pode module from the system + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { + # If there is any error during the module import, throw the error + throw +} +$services = @{ + 'HelloService1' = 8081 + 'HelloService2' = 8082 + 'HelloService3' = 8083 +} + +if ( $Register.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Register-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) -ParameterString "-Port $($_.Value)" -Password $Password } +} +if ( $Unregister.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { try { Unregister-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) -Force:$Force }catch { Write-Error -Exception $_.Exception } } + +} +if ($Start.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Start-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Stop.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Stop-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Query.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Get-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Resume.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Resume-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Query.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Get-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +if ($Restart.IsPresent) { + return $services.GetEnumerator() | ForEach-Object { Restart-PodeService -Name $($_.Key) -Agent:($Agent.IsPresent) } +} + +# Start the Pode server +Start-PodeServer { + New-PodeLoggingMethod -File -Name "errors-$port" -MaxDays 4 -Path './logs' | Enable-PodeErrorLogging -Levels Informational + + # Add an HTTP endpoint listening on localhost at port 8080 + Add-PodeEndpoint -Address localhost -Port $Port -Protocol Http + + # Add a route for GET requests to the root path '/' + Add-PodeRoute -Method Get -Path '/' -ScriptBlock { + # Send a text response with 'Hello, world!' + Write-PodeTextResponse -Value 'Hello, Service!' + } +} diff --git a/examples/HelloWorld/servicesettings.json b/examples/HelloWorld/servicesettings.json new file mode 100644 index 000000000..881b69b12 --- /dev/null +++ b/examples/HelloWorld/servicesettings.json @@ -0,0 +1,11 @@ +{ + "PodeMonitorWorker ": { + "ScriptPath": "C:\\Users\\m_dan\\Documents\\GitHub\\Pode\\examples\\HelloWorld\\HelloWorld.ps1", + "PwshPath": "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + "ParameterString": "", + "LogFilePath": "C:\\Users\\m_dan\\Documents\\GitHub\\Pode\\examples\\HelloWorld\\logs\\PodeMonitorService.Prod.log", + "Quiet": true, + "DisableTermination": true, + "ShutdownWaitTimeMs": 30000 + } +} \ No newline at end of file diff --git a/pode.build.ps1 b/pode.build.ps1 index bcbed0ed9..ab4b59439 100644 --- a/pode.build.ps1 +++ b/pode.build.ps1 @@ -103,6 +103,9 @@ param( [string] $UICulture = 'en-US', + [switch] + $DisableLifecycleServiceOperations, + [string[]] [ValidateSet('netstandard2.0', 'net8.0', 'net9.0', 'net10.0')] $TargetFrameworks = @('netstandard2.0', 'net8.0', 'net9.0'), @@ -127,28 +130,51 @@ $Versions = @{ <# .SYNOPSIS - Checks if the current environment is running on Windows. + Installs a specified package using the appropriate package manager for the OS. .DESCRIPTION - This function determines if the current PowerShell session is running on Windows. - It inspects `$PSVersionTable.Platform` and `$PSVersionTable.PSEdition` to verify the OS, - returning `$true` for Windows and `$false` for other platforms. + This function installs a specified package at a given version using platform-specific + package managers. For Windows, it uses Chocolatey (`choco`). On Unix-based systems, + it checks for `brew`, `apt-get`, and `yum` to handle installations. The function sets + the security protocol to TLS 1.2 to ensure secure connections during the installation. + +.PARAMETER name + The name of the package to install (e.g., 'git'). + +.PARAMETER version + The version of the package to install, required only for Chocolatey on Windows. .OUTPUTS - [bool] - Returns `$true` if the current environment is Windows, otherwise `$false`. + None. .EXAMPLE - if (Test-PodeBuildIsWindows) { - Write-Host "This script is running on Windows." - } + Invoke-PodeBuildInstall -Name 'git' -Version '2.30.0' + # Installs version 2.30.0 of Git on Windows if Chocolatey is available. .NOTES - - Useful for cross-platform scripts to conditionally execute Windows-specific commands. - - The `$PSVersionTable.Platform` variable may be `$null` in certain cases, so `$PSEdition` is used as an additional check. + - Requires administrator or sudo privileges on Unix-based systems. + - This function supports package installation on both Windows and Unix-based systems. + - If `choco` is available, it will use `choco` for Windows, and `brew`, `apt-get`, or `yum` for Unix-based systems. #> -function Test-PodeBuildIsWindows { - $v = $PSVersionTable - return ($v.Platform -ilike '*win*' -or ($null -eq $v.Platform -and $v.PSEdition -ieq 'desktop')) +function Invoke-PodeBuildInstall($name, $version) { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + if (Test-PodeBuildIsWindows) { + if (Test-PodeBuildCommand 'choco') { + choco install $name --version $version -y --no-progress + } + } + else { + if (Test-PodeBuildCommand 'brew') { + brew install $name + } + elseif (Test-PodeBuildCommand 'apt-get') { + sudo apt-get install $name -y + } + elseif (Test-PodeBuildCommand 'yum') { + sudo yum install $name -y + } + } } <# @@ -265,76 +291,54 @@ function Test-PodeBuildCommand($cmd) { <# .SYNOPSIS - Retrieves the branch name from the GitHub Actions environment variable. + Checks if the current environment is running on Windows. .DESCRIPTION - This function extracts the branch name from the `GITHUB_REF` environment variable, - which is commonly set in GitHub Actions workflows. It removes the 'refs/heads/' prefix - from the branch reference, leaving only the branch name. + This function determines if the current PowerShell session is running on Windows. + It inspects `$PSVersionTable.Platform` and `$PSVersionTable.PSEdition` to verify the OS, + returning `$true` for Windows and `$false` for other platforms. .OUTPUTS - [string] - The name of the GitHub branch. + [bool] - Returns `$true` if the current environment is Windows, otherwise `$false`. .EXAMPLE - $branch = Get-PodeBuildBranch - Write-Host "Current branch: $branch" - # Output example: Current branch: main + if (Test-PodeBuildIsWindows) { + Write-Host "This script is running on Windows." + } .NOTES - - Only relevant in environments where `GITHUB_REF` is defined (e.g., GitHub Actions). - - Returns an empty string if `GITHUB_REF` is not set. + - Useful for cross-platform scripts to conditionally execute Windows-specific commands. + - The `$PSVersionTable.Platform` variable may be `$null` in certain cases, so `$PSEdition` is used as an additional check. #> -function Get-PodeBuildBranch { - return ($env:GITHUB_REF -ireplace 'refs\/heads\/', '') +function Test-PodeBuildIsWindows { + $v = $PSVersionTable + return ($v.Platform -ilike '*win*' -or ($null -eq $v.Platform -and $v.PSEdition -ieq 'desktop')) } + <# .SYNOPSIS - Installs a specified package using the appropriate package manager for the OS. + Retrieves the branch name from the GitHub Actions environment variable. .DESCRIPTION - This function installs a specified package at a given version using platform-specific - package managers. For Windows, it uses Chocolatey (`choco`). On Unix-based systems, - it checks for `brew`, `apt-get`, and `yum` to handle installations. The function sets - the security protocol to TLS 1.2 to ensure secure connections during the installation. - -.PARAMETER name - The name of the package to install (e.g., 'git'). - -.PARAMETER version - The version of the package to install, required only for Chocolatey on Windows. + This function extracts the branch name from the `GITHUB_REF` environment variable, + which is commonly set in GitHub Actions workflows. It removes the 'refs/heads/' prefix + from the branch reference, leaving only the branch name. .OUTPUTS - None. + [string] - The name of the GitHub branch. .EXAMPLE - Invoke-PodeBuildInstall -Name 'git' -Version '2.30.0' - # Installs version 2.30.0 of Git on Windows if Chocolatey is available. + $branch = Get-PodeBuildBranch + Write-Host "Current branch: $branch" + # Output example: Current branch: main .NOTES - - Requires administrator or sudo privileges on Unix-based systems. - - This function supports package installation on both Windows and Unix-based systems. - - If `choco` is available, it will use `choco` for Windows, and `brew`, `apt-get`, or `yum` for Unix-based systems. + - Only relevant in environments where `GITHUB_REF` is defined (e.g., GitHub Actions). + - Returns an empty string if `GITHUB_REF` is not set. #> -function Invoke-PodeBuildInstall($name, $version) { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - - if (Test-PodeBuildIsWindows) { - if (Test-PodeBuildCommand 'choco') { - choco install $name --version $version -y --no-progress - } - } - else { - if (Test-PodeBuildCommand 'brew') { - brew install $name - } - elseif (Test-PodeBuildCommand 'apt-get') { - sudo apt-get install $name -y - } - elseif (Test-PodeBuildCommand 'yum') { - sudo yum install $name -y - } - } +function Get-PodeBuildBranch { + return ($env:GITHUB_REF -ireplace 'refs\/heads\/', '') } <# @@ -493,14 +497,107 @@ function Invoke-PodeBuildDotnetBuild { $AssemblyVersion = '' } - # Use dotnet publish for .NET Core and .NET 5+ + # Perform the build for the target runtime dotnet publish --configuration Release --self-contained --framework $target $AssemblyVersion --output ../Libs/$target + # Throw an error if the build fails if (!$?) { throw "Build failed for target framework '$target'." } } +<# +.SYNOPSIS + Builds the Pode Monitor Service for multiple target platforms using .NET SDK. + +.DESCRIPTION + This function automates the build process for the Pode Monitor Service. It: + - Determines the highest installed .NET SDK version. + - Verifies compatibility with the required SDK version. + - Optionally sets an assembly version during the build. + - Builds the service for specified runtime targets across platforms (Windows, Linux, macOS). + - Allows defining custom constants for conditional compilation. + +.PARAMETER Version + Specifies the assembly version to use for the build. If not provided, no version is set. + +.PARAMETER DisableLifecycleServiceOperations + If specified, excludes lifecycle service operations during the build by omitting related compilation constants. + +.INPUTS + None. The function does not accept pipeline input. + +.OUTPUTS + None. The function produces build artifacts in the output directory. + +.NOTES + This function is designed to work with .NET SDK and assumes it is installed and configured properly. + It throws an error if the build process fails for any target. + +.EXAMPLE + Invoke-PodeBuildDotnetMonitorSrvBuild -Version "1.0.0" + + Builds the Pode Monitor Service with an assembly version of 1.0.0. + +.EXAMPLE + Invoke-PodeBuildDotnetMonitorSrvBuild -DisableLifecycleServiceOperations + + Builds the Pode Monitor Service without lifecycle service operations. + +.EXAMPLE + Invoke-PodeBuildDotnetMonitorSrvBuild + + Builds the Pode Monitor Service for all target runtimes without a specific assembly version. +#> +function Invoke-PodeBuildDotnetMonitorSrvBuild() { + # Retrieve the highest installed SDK version + $majorVersion = ([version](dotnet --version)).Major + + # Determine if the target framework is compatible + $isCompatible = $majorVersions -ge $requiredSdkVersion + + # Skip build if not compatible + if ($isCompatible) { + Write-Output "SDK for target framework '$target' is compatible with the '$AvailableSdkVersion' framework." + } + else { + Write-Warning "SDK for target framework '$target' is not compatible with the '$AvailableSdkVersion' framework. Skipping build." + return + } + + # Optionally set assembly version + if ($Version) { + Write-Host "Assembly Version $Version" + $AssemblyVersion = "-p:Version=$Version" + } + else { + $AssemblyVersion = '' + } + + foreach ($target in @('win-x64', 'win-arm64' , 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64','linux-arm','win-x86','linux-musl-x64')) { + $DefineConstants = @() + $ParamConstants = '' + + # Add compilation constants if lifecycle operations are enabled + if (!$DisableLifecycleServiceOperations) { + $DefineConstants += 'ENABLE_LIFECYCLE_OPERATIONS' + } + + # Prepare constants for the build parameters + if ($DefineConstants.Count -gt 0) { + $ParamConstants = "-p:DefineConstants=`"$( $DefineConstants -join ';')`"" + } + + # Perform the build for the target runtime + dotnet publish --runtime $target --output ../Bin/$target --configuration Release $AssemblyVersion $ParamConstants + + # Throw an error if the build fails + if (!$?) { + throw "dotnet publish failed for $($target)" + } + } +} + <# .SYNOPSIS Retrieves the end-of-life (EOL) and supported versions of PowerShell. @@ -549,7 +646,7 @@ function Get-PodeBuildPwshEOL { .DESCRIPTION This function detects whether the current operating system is Windows by checking - the `$IsWindows` automatic variable, the presence of the `$env:ProgramFiles` variable, + the `Test-PodeBuildIsWindows` automatic variable, the presence of the `$env:ProgramFiles` variable, and the PowerShell Edition in `$PSVersionTable`. This function returns `$true` if any of these indicate Windows. @@ -878,8 +975,6 @@ function Split-PodeBuildPwshPath { } } - - # Check if the script is running under Invoke-Build if (($null -eq $PSCmdlet.MyInvocation) -or ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('BuildRoot') -and ($null -eq $BuildRoot))) { Write-Host 'This script is intended to be run with Invoke-Build. Please use Invoke-Build to execute the tasks defined in this script.' -ForegroundColor Yellow @@ -1077,9 +1172,6 @@ Add-BuildTask Build BuildDeps, { Remove-Item -Path ./src/Libs -Recurse -Force | Out-Null } - - - # Retrieve the SDK version being used # $dotnetVersion = dotnet --version @@ -1100,6 +1192,20 @@ Add-BuildTask Build BuildDeps, { Pop-Location } + if (Test-Path ./src/Bin) { + Remove-Item -Path ./src/Bin -Recurse -Force | Out-Null + } + + try { + Push-Location ./src/PodeMonitor + Invoke-PodeBuildDotnetMonitorSrvBuild + } + finally { + Pop-Location + } + + + } @@ -1181,7 +1287,7 @@ Add-BuildTask PackageFolder Build, { New-Item -Path $path -ItemType Directory -Force | Out-Null # which source folders do we need? create them and copy their contents - $folders = @('Private', 'Public', 'Misc', 'Libs', 'Locales') + $folders = @('Private', 'Public', 'Misc', 'Libs', 'Locales', 'Bin') $folders | ForEach-Object { New-Item -ItemType Directory -Path (Join-Path $path $_) -Force | Out-Null Copy-Item -Path "./src/$($_)/*" -Destination (Join-Path $path $_) -Force -Recurse | Out-Null @@ -1213,11 +1319,62 @@ Add-BuildTask TestNoBuild TestDeps, { Remove-Module Pester -Force -ErrorAction Ignore Import-Module Pester -Force -RequiredVersion $Versions.Pester } - + Write-Output '' # for windows, output current netsh excluded ports if (Test-PodeBuildIsWindows) { netsh int ipv4 show excludedportrange protocol=tcp | Out-Default + + # Retrieve the current Windows identity and token + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + + # Gather user information + $user = $identity.Name + $isElevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $adminStatus = if ($isElevated) { 'Administrator' } else { 'Standard User' } + $groups = $identity.Groups | ForEach-Object { + try { + $_.Translate([Security.Principal.NTAccount]).Value + } + catch { + $_.Value # Fallback to SID if translation fails + } + } + + # Generate output + Write-Output 'Pester Execution Context (Windows):' + Write-Output " - User: $user" + Write-Output " - Role: $adminStatus" + Write-Output " - Elevated Privileges: $isElevated" + Write-Output " - Group Memberships: $( $groups -join ', ')" + } + + + if ($IsLinux) { + $user = whoami + $groupsRaw = (groups $user | Out-String).Trim() + $groups = $groupsRaw -split '\s+' | Where-Object { $_ -ne ':' } | Sort-Object -Unique + + # Check for sudo privileges based on group membership + $isSudoUser = $groups -match '\bwheel\b' -or $groups -match '\badmin\b' -or $groups -match '\bsudo\b' -or $groups -match '\badm\b' + + Write-Output 'Pester Execution Context (Linux):' + Write-Output " - User: $user" + Write-Output " - Groups: $( $groups -join ', ')" + Write-Output " - Sudo: $($isSudoUser -eq $true)" } + + if ($IsMacOS) { + $user = whoami + $groups = (id -Gn $user).Split(' ') # Use `id -Gn` for consistent group names on macOS + $formattedGroups = $groups -join ', ' + Write-Output 'Pester Execution Context (macOS):' + Write-Output " - User: $user" + Write-Output " - Groups: $formattedGroups" + } + + Write-Output '' + if ($UICulture -ne ([System.Threading.Thread]::CurrentThread.CurrentUICulture) ) { $originalUICulture = [System.Threading.Thread]::CurrentThread.CurrentUICulture Write-Output "Original UICulture is $originalUICulture" @@ -1383,6 +1540,12 @@ Add-BuildTask CleanLibs { Remove-Item -Path $path -Recurse -Force | Out-Null } + $path = './src/Bin' + if (Test-Path -Path $path -PathType Container) { + Write-Host "Removing $path contents" + Remove-Item -Path $path -Recurse -Force | Out-Null + } + Write-Host "Cleanup $path done" } @@ -1589,7 +1752,7 @@ Add-BuildTask SetupPowerShell { #> # Synopsis: Build the Release Notes -task ReleaseNotes { +Add-BuildTask ReleaseNotes { if ([string]::IsNullOrWhiteSpace($ReleaseNoteVersion)) { Write-Host 'Please provide a ReleaseNoteVersion' -ForegroundColor Red return diff --git a/src/Locales/ar/Pode.psd1 b/src/Locales/ar/Pode.psd1 index 1c7ff7daf..b90907e9e 100644 --- a/src/Locales/ar/Pode.psd1 +++ b/src/Locales/ar/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "الدالة '{0}' لا تقبل مصفوفة كمدخل لأنبوب البيانات." unsupportedStreamCompressionEncodingExceptionMessage = 'تشفير الضغط غير مدعوم للتشفير {0}' LocalEndpointConflictExceptionMessage = "تم تعريف كل من '{0}' و '{1}' كنقاط نهاية محلية لـ OpenAPI، لكن يُسمح فقط بنقطة نهاية محلية واحدة لكل تعريف API." + serviceAlreadyRegisteredException = "الخدمة '{0}' مسجلة بالفعل." + serviceIsNotRegisteredException = "الخدمة '{0}' غير مسجلة." + serviceCommandFailedException = "فشل الأمر '{0}' في الخدمة '{1}'." + serviceRegistrationException = "فشل تسجيل الخدمة '{0}'." + serviceIsRunningException = "الخدمة '{0}' تعمل. استخدم المعامل -Force للإيقاف بالقوة." + serviceUnRegistrationException = "فشل إلغاء تسجيل الخدمة '{0}'." + passwordRequiredForServiceUserException = "مطلوب كلمة مرور عند تحديد مستخدم الخدمة في نظام Windows. يرجى تقديم كلمة مرور صالحة للمستخدم '{0}'." + featureNotSupportedException = '{0} مدعومة فقط على نظام التشغيل Windows.' } diff --git a/src/Locales/de/Pode.psd1 b/src/Locales/de/Pode.psd1 index c90847e01..72b872acc 100644 --- a/src/Locales/de/Pode.psd1 +++ b/src/Locales/de/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Die Funktion '{0}' akzeptiert kein Array als Pipeline-Eingabe." unsupportedStreamCompressionEncodingExceptionMessage = 'Die Stream-Komprimierungskodierung wird nicht unterstützt: {0}' LocalEndpointConflictExceptionMessage = "Sowohl '{0}' als auch '{1}' sind als lokale OpenAPI-Endpunkte definiert, aber es ist nur ein lokaler Endpunkt pro API-Definition erlaubt." + serviceAlreadyRegisteredException = "Der Dienst '{0}' ist bereits registriert." + serviceIsNotRegisteredException = "Der Dienst '{0}' ist nicht registriert." + serviceCommandFailedException = "Der Dienstbefehl '{0}' ist bei dem Dienst '{1}' fehlgeschlagen." + serviceRegistrationException = "Die Registrierung des Dienstes '{0}' ist fehlgeschlagen." + serviceIsRunningException = "Der Dienst '{0}' läuft. Verwenden Sie den Parameter -Force, um den Dienst zwangsweise zu stoppen." + serviceUnRegistrationException = "Die Abmeldung des Dienstes '{0}' ist fehlgeschlagen." + passwordRequiredForServiceUserException = "Ein Passwort ist erforderlich, wenn ein Dienstbenutzer unter Windows angegeben wird. Bitte geben Sie ein gültiges Passwort für den Benutzer '{0}' an." + featureNotSupportedException = '{0} wird nur unter Windows unterstützt.' } \ No newline at end of file diff --git a/src/Locales/en-us/Pode.psd1 b/src/Locales/en-us/Pode.psd1 index 56340e18f..b3078c12f 100644 --- a/src/Locales/en-us/Pode.psd1 +++ b/src/Locales/en-us/Pode.psd1 @@ -291,4 +291,13 @@ getRequestBodyNotAllowedExceptionMessage = "'{0}' operations cannot have a Request Body. Use -AllowNonStandardBody to override this restriction." fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "The function '{0}' does not accept an array as pipeline input." unsupportedStreamCompressionEncodingExceptionMessage = 'Unsupported stream compression encoding: {0}' - LocalEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition."} \ No newline at end of file + LocalEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition." + serviceAlreadyRegisteredException = "Service '{0}' is already registered." + serviceIsNotRegisteredException = "Service '{0}' is not registered." + serviceCommandFailedException = "Service command '{0}' failed on service '{1}'." + serviceRegistrationException = "Service '{0}' registration failed." + serviceIsRunningException = "Service '{0}' is running. Use the -Force parameter to forcefully stop." + serviceUnRegistrationException = "Service '{0}' unregistration failed." + passwordRequiredForServiceUserException = "A password is required when specifying a service user on Windows. Please provide a valid password for the user '{0}'." + featureNotSupportedException = '{0} is supported only on Windows.' +} \ No newline at end of file diff --git a/src/Locales/en/Pode.psd1 b/src/Locales/en/Pode.psd1 index 44a1ee102..4740f95fc 100644 --- a/src/Locales/en/Pode.psd1 +++ b/src/Locales/en/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "The function '{0}' does not accept an array as pipeline input." unsupportedStreamCompressionEncodingExceptionMessage = 'Unsupported stream compression encoding: {0}' LocalEndpointConflictExceptionMessage = "Both '{0}' and '{1}' are defined as local OpenAPI endpoints, but only one local endpoint is allowed per API definition." + serviceAlreadyRegisteredException = "Service '{0}' is already registered." + serviceIsNotRegisteredException = "Service '{0}' is not registered." + serviceCommandFailedException = "Service command '{0}' failed on service '{1}'." + serviceRegistrationException = "Service '{0}' registration failed." + serviceIsRunningException = "Service '{0}' is running. Use the -Force parameter to forcefully stop." + serviceUnRegistrationException = "Service '{0}' unregistration failed." + passwordRequiredForServiceUserException = "A password is required when specifying a service user on Windows. Please provide a valid password for the user '{0}'." + featureNotSupportedException = '{0} is supported only on Windows.' } \ No newline at end of file diff --git a/src/Locales/es/Pode.psd1 b/src/Locales/es/Pode.psd1 index 9c2ee1194..6601c1bcb 100644 --- a/src/Locales/es/Pode.psd1 +++ b/src/Locales/es/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La función '{0}' no acepta una matriz como entrada de canalización." unsupportedStreamCompressionEncodingExceptionMessage = 'La codificación de compresión de transmisión no es compatible: {0}' LocalEndpointConflictExceptionMessage = "Tanto '{0}' como '{1}' están definidos como puntos finales locales de OpenAPI, pero solo se permite un punto final local por definición de API." + serviceAlreadyRegisteredException = "El servicio '{0}' ya está registrado." + serviceIsNotRegisteredException = "El servicio '{0}' no está registrado." + serviceCommandFailedException = "El comando del servicio '{0}' falló en el servicio '{1}'." + serviceRegistrationException = "Falló el registro del servicio '{0}'." + serviceIsRunningException = "El servicio '{0}' está en ejecución. Utilice el parámetro -Force para detenerlo a la fuerza." + serviceUnRegistrationException = "La anulación del registro del servicio '{0}' falló." + passwordRequiredForServiceUserException = "Se requiere una contraseña al especificar un usuario de servicio en Windows. Por favor, proporcione una contraseña válida para el usuario '{0}'." + featureNotSupportedException = '{0} solo es compatible con Windows.' } \ No newline at end of file diff --git a/src/Locales/fr/Pode.psd1 b/src/Locales/fr/Pode.psd1 index 2c9dfc579..a4cc543d5 100644 --- a/src/Locales/fr/Pode.psd1 +++ b/src/Locales/fr/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La fonction '{0}' n'accepte pas un tableau en tant qu'entrée de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = "La compression de flux {0} n'est pas prise en charge." LocalEndpointConflictExceptionMessage = "Les deux '{0}' et '{1}' sont définis comme des points de terminaison locaux pour OpenAPI, mais un seul point de terminaison local est autorisé par définition d'API." + serviceAlreadyRegisteredException = "Le service '{0}' est déjà enregistré." + serviceIsNotRegisteredException = "Le service '{0}' n'est pas enregistré." + serviceCommandFailedException = "La commande de service '{0}' a échoué sur le service '{1}'." + serviceRegistrationException = "Échec de l'enregistrement du service '{0}'." + serviceIsRunningException = "Le service '{0}' est en cours d'exécution. Utilisez le paramètre -Force pour forcer l'arrêt." + serviceUnRegistrationException = "La désinscription du service '{0}' a échoué." + passwordRequiredForServiceUserException = "Un mot de passe est requis lors de la spécification d'un utilisateur de service sous Windows. Veuillez fournir un mot de passe valide pour l'utilisateur '{0}'." + featureNotSupportedException = '{0} est pris en charge uniquement sous Windows.' } \ No newline at end of file diff --git a/src/Locales/it/Pode.psd1 b/src/Locales/it/Pode.psd1 index 999bf85c3..e2976a630 100644 --- a/src/Locales/it/Pode.psd1 +++ b/src/Locales/it/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "La funzione '{0}' non accetta una matrice come input della pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'La compressione dello stream non è supportata per la codifica {0}' LocalEndpointConflictExceptionMessage = "Sia '{0}' che '{1}' sono definiti come endpoint locali OpenAPI, ma è consentito solo un endpoint locale per definizione API." + serviceAlreadyRegisteredException = "Il servizio '{0}' è già registrato." + serviceIsNotRegisteredException = "Il servizio '{0}' non è registrato." + serviceCommandFailedException = "Il comando '{0}' è fallito sul servizio '{1}'." + serviceRegistrationException = "Registrazione del servizio '{0}' non riuscita." + serviceIsRunningException = "Il servizio '{0}' è in esecuzione. Utilizzare il parametro -Force per interromperlo forzatamente." + serviceUnRegistrationException = "La cancellazione della registrazione del servizio '{0}' è fallita." + passwordRequiredForServiceUserException = "È richiesta una password quando si specifica un utente del servizio su Windows. Si prega di fornire una password valida per l'utente '{0}'." + featureNotSupportedException = '{0} è supportato solo su Windows.' } \ No newline at end of file diff --git a/src/Locales/ja/Pode.psd1 b/src/Locales/ja/Pode.psd1 index e65627c59..ce4be3394 100644 --- a/src/Locales/ja/Pode.psd1 +++ b/src/Locales/ja/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "関数 '{0}' は配列をパイプライン入力として受け付けません。" unsupportedStreamCompressionEncodingExceptionMessage = 'サポートされていないストリーム圧縮エンコーディングが提供されました: {0}' LocalEndpointConflictExceptionMessage = "'{0}' と '{1}' は OpenAPI のローカルエンドポイントとして定義されていますが、API 定義ごとに 1 つのローカルエンドポイントのみ許可されます。" + serviceAlreadyRegisteredException = "サービス '{0}' はすでに登録されています。" + serviceIsNotRegisteredException = "サービス '{0}' は登録されていません。" + serviceCommandFailedException = "サービスコマンド '{0}' はサービス '{1}' で失敗しました。" + serviceRegistrationException = "サービス '{0}' の登録に失敗しました。" + serviceIsRunningException = "サービス '{0}' が実行中です。強制的に停止するには、-Force パラメーターを使用してください。" + serviceUnRegistrationException = "サービス '{0}' の登録解除に失敗しました。" + passwordRequiredForServiceUserException = "Windowsでサービスユーザーを指定する際にはパスワードが必要です。ユーザー '{0}' に有効なパスワードを入力してください。" + featureNotSupportedException = '{0} は Windows のみでサポートされています。' } \ No newline at end of file diff --git a/src/Locales/ko/Pode.psd1 b/src/Locales/ko/Pode.psd1 index f64f0c61f..7eba12611 100644 --- a/src/Locales/ko/Pode.psd1 +++ b/src/Locales/ko/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "함수 '{0}'은(는) 배열을 파이프라인 입력으로 받지 않습니다." unsupportedStreamCompressionEncodingExceptionMessage = '지원되지 않는 스트림 압축 인코딩: {0}' LocalEndpointConflictExceptionMessage = "'{0}' 와 '{1}' 는 OpenAPI 로컬 엔드포인트로 정의되었지만, API 정의당 하나의 로컬 엔드포인트만 허용됩니다." + serviceAlreadyRegisteredException = "서비스 '{0}'가 이미 등록되었습니다." + serviceIsNotRegisteredException = "서비스 '{0}'가 등록되지 않았습니다." + serviceCommandFailedException = "서비스 명령 '{0}' 이(가) 서비스 '{1}' 에서 실패했습니다." + serviceRegistrationException = "서비스 '{0}' 등록에 실패했습니다." + serviceIsRunningException = "서비스 '{0}'가 실행 중입니다. 강제로 중지하려면 -Force 매개변수를 사용하세요." + serviceUnRegistrationException = "서비스 '{0}' 등록 취소에 실패했습니다." + passwordRequiredForServiceUserException = "Windows에서 서비스 사용자를 지정할 때는 비밀번호가 필요합니다. 사용자 '{0}'에 대해 유효한 비밀번호를 입력하세요." + featureNotSupportedException = '{0}는 Windows에서만 지원됩니다.' } \ No newline at end of file diff --git a/src/Locales/nl/Pode.psd1 b/src/Locales/nl/Pode.psd1 index d7933a0d9..74bb1da94 100644 --- a/src/Locales/nl/Pode.psd1 +++ b/src/Locales/nl/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "De functie '{0}' accepteert geen array als pipeline-invoer." unsupportedStreamCompressionEncodingExceptionMessage = 'Niet-ondersteunde streamcompressie-encodering: {0}' LocalEndpointConflictExceptionMessage = "Zowel '{0}' als '{1}' zijn gedefinieerd als lokale OpenAPI-eindpunten, maar er is slechts één lokaal eindpunt per API-definitie toegestaan." + serviceAlreadyRegisteredException = "De service '{0}' is al geregistreerd." + serviceIsNotRegisteredException = "De service '{0}' is niet geregistreerd." + serviceCommandFailedException = "De serviceopdracht '{0}' is mislukt op de service '{1}'." + serviceRegistrationException = "Registratie van de service '{0}' is mislukt." + serviceIsRunningException = "De service '{0}' draait. Gebruik de parameter -Force om de service geforceerd te stoppen." + serviceUnRegistrationException = "Het afmelden van de service '{0}' is mislukt." + passwordRequiredForServiceUserException = "Een wachtwoord is vereist bij het specificeren van een servicegebruiker in Windows. Geef een geldig wachtwoord op voor de gebruiker '{0}'." + featureNotSupportedException = '{0} wordt alleen ondersteund op Windows.' } \ No newline at end of file diff --git a/src/Locales/pl/Pode.psd1 b/src/Locales/pl/Pode.psd1 index cd632c469..f9be3abed 100644 --- a/src/Locales/pl/Pode.psd1 +++ b/src/Locales/pl/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "Funkcja '{0}' nie akceptuje tablicy jako wejścia potoku." unsupportedStreamCompressionEncodingExceptionMessage = 'Kodowanie kompresji strumienia nie jest obsługiwane: {0}' LocalEndpointConflictExceptionMessage = "Zarówno '{0}', jak i '{1}' są zdefiniowane jako lokalne punkty końcowe OpenAPI, ale na jedną definicję API dozwolony jest tylko jeden lokalny punkt końcowy." + serviceAlreadyRegisteredException = "Usługa '{0}' jest już zarejestrowana." + serviceIsNotRegisteredException = "Usługa '{0}' nie jest zarejestrowana." + serviceCommandFailedException = "Polecenie serwisu '{0}' nie powiodło się w serwisie '{1}'." + serviceRegistrationException = "Rejestracja usługi '{0}' nie powiodła się." + serviceIsRunningException = "Usługa '{0}' jest uruchomiona. Użyj parametru -Force, aby wymusić zatrzymanie." + serviceUnRegistrationException = "Nie udało się wyrejestrować usługi '{0}'." + passwordRequiredForServiceUserException = "Wymagane jest hasło podczas określania użytkownika usługi w systemie Windows. Podaj prawidłowe hasło dla użytkownika '{0}'." + featureNotSupportedException = '{0} jest obsługiwane tylko w systemie Windows.' } \ No newline at end of file diff --git a/src/Locales/pt/Pode.psd1 b/src/Locales/pt/Pode.psd1 index a0604c179..05d6026fc 100644 --- a/src/Locales/pt/Pode.psd1 +++ b/src/Locales/pt/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "A função '{0}' não aceita uma matriz como entrada de pipeline." unsupportedStreamCompressionEncodingExceptionMessage = 'A codificação de compressão de fluxo não é suportada.' LocalEndpointConflictExceptionMessage = "Tanto '{0}' quanto '{1}' estão definidos como endpoints locais do OpenAPI, mas apenas um endpoint local é permitido por definição de API." + serviceAlreadyRegisteredException = "O serviço '{0}' já está registrado." + serviceIsNotRegisteredException = "O serviço '{0}' não está registrado." + serviceCommandFailedException = "O comando do serviço '{0}' falhou no serviço '{1}'." + serviceRegistrationException = "Falha no registro do serviço '{0}'." + serviceIsRunningException = "O serviço '{0}' está em execução. Use o parâmetro -Force para forçar a parada." + serviceUnRegistrationException = "A anulação do registro do serviço '{0}' falhou." + passwordRequiredForServiceUserException = "Uma senha é necessária ao especificar um usuário de serviço no Windows. Por favor, forneça uma senha válida para o usuário '{0}'." + featureNotSupportedException = '{0} é compatível apenas com o Windows.' } \ No newline at end of file diff --git a/src/Locales/zh/Pode.psd1 b/src/Locales/zh/Pode.psd1 index 26b013c95..3abb39319 100644 --- a/src/Locales/zh/Pode.psd1 +++ b/src/Locales/zh/Pode.psd1 @@ -292,4 +292,12 @@ fnDoesNotAcceptArrayAsPipelineInputExceptionMessage = "函数 '{0}' 不接受数组作为管道输入。" unsupportedStreamCompressionEncodingExceptionMessage = '不支持的流压缩编码: {0}' LocalEndpointConflictExceptionMessage = "'{0}' 和 '{1}' 都被定义为 OpenAPI 的本地端点,但每个 API 定义仅允许一个本地端点。" + serviceAlreadyRegisteredException = "服务 '{0}' 已经注册。" + serviceIsNotRegisteredException = "服务 '{0}' 未注册。" + serviceCommandFailedException = "服务命令 '{0}' 在服务 '{1}' 上失败。" + serviceRegistrationException = "服务 '{0}' 注册失败。" + serviceIsRunningException = "服务 '{0}' 正在运行。使用 -Force 参数强制停止。" + serviceUnRegistrationException = "服务 '{0}' 的注销失败。" + passwordRequiredForServiceUserException = "在 Windows 中指定服务用户时需要密码。请为用户 '{0}' 提供有效的密码。" + featureNotSupportedException = '{0} 仅支持 Windows。' } \ No newline at end of file diff --git a/src/Pode.psd1 b/src/Pode.psd1 index ad02ac21c..936dc64af 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -498,7 +498,17 @@ 'Test-PodeScopedVariable', 'Clear-PodeScopedVariables', 'Get-PodeScopedVariable', - 'Use-PodeScopedVariables' + 'Use-PodeScopedVariables', + + # service + 'Register-PodeService', + 'Unregister-PodeService', + 'Start-PodeService', + 'Stop-PodeService', + 'Get-PodeService', + 'Suspend-PodeService', + 'Resume-PodeService', + 'Restart-PodeService' ) # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. diff --git a/src/Pode.psm1 b/src/Pode.psm1 index 3e2d95553..53622260f 100644 --- a/src/Pode.psm1 +++ b/src/Pode.psm1 @@ -135,6 +135,15 @@ try { Export-ModuleMember -Function ($funcs.Name) } } + + # Define Properties Display + if (!(Get-TypeData -TypeName 'PodeService')) { + $TypeData = @{ + TypeName = 'PodeService' + DefaultDisplayPropertySet = 'Name', 'Status', 'Pid' + } + Update-TypeData @TypeData + } } catch { throw ("Failed to load the Pode module. $_") diff --git a/src/PodeMonitor/IPausableHostedService.cs b/src/PodeMonitor/IPausableHostedService.cs new file mode 100644 index 000000000..0395b23cf --- /dev/null +++ b/src/PodeMonitor/IPausableHostedService.cs @@ -0,0 +1,26 @@ +namespace PodeMonitor +{ + /// + /// Defines a contract for a hosted service that supports pausing and resuming. + /// + public interface IPausableHostedService + { + /// + /// Pauses the hosted service. + /// This method is called when the service receives a pause command. + /// + void OnPause(); + + /// + /// Resumes the hosted service. + /// This method is called when the service receives a continue command after being paused. + /// + void OnContinue(); + + + + void Restart(); + + public ServiceState State { get; } + } +} diff --git a/src/PodeMonitor/PipeNameGenerator.cs b/src/PodeMonitor/PipeNameGenerator.cs new file mode 100644 index 000000000..6a4299dba --- /dev/null +++ b/src/PodeMonitor/PipeNameGenerator.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +namespace PodeMonitor +{ + public static class PipeNameGenerator + { + private const int MaxUnixPathLength = 104; // Max length for Unix domain sockets on macOS + private const string UnixTempDir = "/tmp"; // Short temporary directory for Unix systems + + public static string GeneratePipeName() + { + // Generate a unique name based on a GUID + string uniqueId = Guid.NewGuid().ToString("N").Substring(0, 8); + + if (OperatingSystem.IsWindows()) + { + // Use Windows named pipe format + return $"PodePipe_{uniqueId}"; + } + else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + // Use Unix domain socket format with a shorter temp directory + //string pipePath = Path.Combine(UnixTempDir, $"PodePipe_{uniqueId}"); + string pipePath = $"PodePipe_{uniqueId}"; + + // Ensure the path is within the allowed length for Unix domain sockets + if (pipePath.Length > MaxUnixPathLength) + { + throw new InvalidOperationException($"Generated pipe path exceeds the maximum length of {MaxUnixPathLength} characters: {pipePath}"); + } + + return pipePath; + } + else + { + throw new PlatformNotSupportedException("Unsupported operating system for pipe name generation."); + } + } + } +} diff --git a/src/PodeMonitor/PodeLogLevel.cs b/src/PodeMonitor/PodeLogLevel.cs new file mode 100644 index 000000000..ed91a0884 --- /dev/null +++ b/src/PodeMonitor/PodeLogLevel.cs @@ -0,0 +1,15 @@ + +namespace PodeMonitor +{ + /// + /// Enum representing the various log levels for PodeMonitorLogger. + /// + public enum PodeLogLevel + { + DEBUG, // Detailed information for debugging purposes + INFO, // General operational information + WARN, // Warning messages for potential issues + ERROR, // Error messages for failures + CRITICAL // Critical errors indicating severe failures + } +} \ No newline at end of file diff --git a/src/PodeMonitor/PodeMonitor.cs b/src/PodeMonitor/PodeMonitor.cs new file mode 100644 index 000000000..334b3a707 --- /dev/null +++ b/src/PodeMonitor/PodeMonitor.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Threading; + +namespace PodeMonitor +{ + /// + /// Enum representing possible states of the Pode service. + /// + public enum ServiceState + { + Unknown, // State is unknown + Running, // Service is running + Suspended, // Service is suspended + Starting, // Service is starting + Stopping // Service is stopping + } + + /// + /// Class responsible for managing and monitoring the Pode PowerShell process. + /// Provides functionality for starting, stopping, suspending, resuming, and restarting the process. + /// Communicates with the Pode process via named pipes. + /// + public class PodeMonitor + { + private readonly object _syncLock = new(); // Synchronization lock for thread safety + private Process _powerShellProcess; // PowerShell process instance + private NamedPipeClientStream _pipeClient; // Named pipe client for inter-process communication + + // Configuration properties + private readonly string _scriptPath; // Path to the Pode script + private readonly string _parameterString; // Parameters passed to the script + private readonly string _pwshPath; // Path to the PowerShell executable + private readonly bool _quiet; // Indicates whether the process runs in quiet mode + private readonly bool _disableTermination; // Indicates whether termination is disabled + private readonly int _shutdownWaitTimeMs; // Timeout for shutting down the process + private readonly string _pipeName; // Name of the named pipe for communication + private readonly string _stateFilePath; // Path to the service state file + + private DateTime _lastLogTime; // Tracks the last time the process logged activity + + public int StartMaxRetryCount { get; } // Maximum number of retries for starting the process + public int StartRetryDelayMs { get; } // Delay between retries in milliseconds + + private volatile ServiceState _state; + + + public ServiceState State { get => _state; set => _state = value; } + + public bool DisableTermination { get => _disableTermination; } + + + /// + /// Initializes a new instance of the class with the specified configuration options. + /// + /// Configuration options for the PodeMonitor. + public PodeMonitor(PodeMonitorWorkerOptions options) + { + // Initialize configuration properties + _scriptPath = options.ScriptPath; + _pwshPath = options.PwshPath; + _parameterString = options.ParameterString; + _quiet = options.Quiet; + _disableTermination = options.DisableTermination; + _shutdownWaitTimeMs = options.ShutdownWaitTimeMs; + StartMaxRetryCount = options.StartMaxRetryCount; + StartRetryDelayMs = options.StartRetryDelayMs; + + // Generate a unique pipe name + _pipeName = PipeNameGenerator.GeneratePipeName(); + + // Define the state file path only for Linux/macOS + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + + string homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string stateDirectory = OperatingSystem.IsLinux() + ? "/run/podemonitor" + : OperatingSystem.IsMacOS() + ? Path.Combine(homeDirectory, "Library", "LaunchAgents", "PodeMonitor") + : throw new PlatformNotSupportedException("The current platform is not supported for setting the state directory."); + try + { + if (!Directory.Exists(stateDirectory)) + { + Directory.CreateDirectory(stateDirectory); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, + $"Failed to create state directory at {stateDirectory}: {ex.Message}"); + throw; + } + + // Define the state file path (default to /var/tmp for Linux/macOS) + _stateFilePath = Path.Combine(stateDirectory, $"{Environment.ProcessId}.state"); + + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Initialized PodeMonitor with pipe name: {0} and state file: {1}",_pipeName,_stateFilePath); + } + } + + /// + /// Starts the Pode PowerShell process. If the process is already running, logs its status. + /// + public void StartPowerShellProcess() + { + lock (_syncLock) + { + if (_powerShellProcess != null && !_powerShellProcess.HasExited) + { + if ((DateTime.Now - _lastLogTime).TotalMinutes >= 5) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process is Alive."); + _lastLogTime = DateTime.Now; + } + return; + } + + try + { + // Configure the PowerShell process + _powerShellProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _pwshPath, + Arguments = BuildCommand(), + RedirectStandardOutput = true, // Redirect standard output for logging + RedirectStandardError = true, // Redirect standard error for logging + UseShellExecute = false, // Run without using shell execution + CreateNoWindow = true // Prevent the creation of a window + } + }; + + // Subscribe to the output stream for logging and state parsing + _powerShellProcess.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "Pode", _powerShellProcess.Id, args.Data); + ParseServiceState(args.Data); + } + }; + + // Subscribe to the error stream for logging errors + _powerShellProcess.ErrorDataReceived += (sender, args) => + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "Pode", _powerShellProcess.Id, args.Data); + }; + + // Start the process and begin reading the output/error streams + _powerShellProcess.Start(); + _powerShellProcess.BeginOutputReadLine(); + _powerShellProcess.BeginErrorReadLine(); + + _lastLogTime = DateTime.Now; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process started successfully."); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Failed to start Pode process: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + } + } + + /// + /// Stops the Pode PowerShell process gracefully. If it does not terminate, it is forcefully killed. + /// + public void StopPowerShellProcess() + { + lock (_syncLock) + { + if (_powerShellProcess == null || (_powerShellProcess.HasExited && Process.GetProcessById(_powerShellProcess.Id) == null)) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process is not running."); + return; + } + + try + { + if (InitializePipeClientWithRetry()) + { + SendPipeMessage("shutdown"); + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Waiting for {_shutdownWaitTimeMs} milliseconds for Pode process to exit..."); + WaitForProcessExit(_shutdownWaitTimeMs); + + if (_powerShellProcess != null && !_powerShellProcess.HasExited) + { + PodeMonitorLogger.Log(PodeLogLevel.WARN, "PodeMonitor", Environment.ProcessId, $"Pode process has exited:{_powerShellProcess.HasExited} Id:{_powerShellProcess.Id}"); + + PodeMonitorLogger.Log(PodeLogLevel.WARN, "PodeMonitor", Environment.ProcessId, "Pode process did not terminate gracefully. Killing process."); + _powerShellProcess.Kill(); + } + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pode process stopped successfully."); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Error stopping Pode process: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + finally + { + CleanupResources(); + } + } + } + + /// + /// Sends a suspend command to the Pode process via named pipe. + /// + public void SuspendPowerShellProcess() => ExecutePipeCommand("suspend"); + + /// + /// Sends a resume command to the Pode process via named pipe. + /// + public void ResumePowerShellProcess() => ExecutePipeCommand("resume"); + + /// + /// Sends a restart command to the Pode process via named pipe. + /// + public void RestartPowerShellProcess() => ExecutePipeCommand("restart"); + + + + /// + /// Executes a command by sending it to the Pode process via named pipe. + /// + /// The command to execute (e.g., "suspend", "resume", "restart"). + private void ExecutePipeCommand(string command) + { + lock (_syncLock) + { + try + { + if (InitializePipeClientWithRetry()) + { + SendPipeMessage(command); + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"{command.ToUpper()} command sent to Pode process."); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Error executing {command} command: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + finally + { + CleanupPipeClient(); + } + } + } + + /// + /// Parses the service state from the provided output message and updates the state variables. + /// + /// The output message containing the service state. + private void ParseServiceState(string output) + { + if (string.IsNullOrWhiteSpace(output)) return; + + if (output.StartsWith("Service State: ", StringComparison.OrdinalIgnoreCase)) + { + string state = output["Service State: ".Length..].Trim().ToLowerInvariant(); + + switch (state) + { + case "running": + UpdateServiceState(ServiceState.Running); + break; + case "suspended": + UpdateServiceState(ServiceState.Suspended); + break; + case "starting": + UpdateServiceState(ServiceState.Starting); + break; + case "stopping": + UpdateServiceState(ServiceState.Stopping); + break; + default: + PodeMonitorLogger.Log(PodeLogLevel.WARN, "PodeMonitor", Environment.ProcessId, $"Unknown service state: {state}"); + UpdateServiceState(ServiceState.Unknown); + break; + } + } + } + + /// + /// Updates the internal state variables based on the provided service state. + /// + /// The new service state. + private void UpdateServiceState(ServiceState state) + { + _state = state; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Service state updated to: {state}"); + // Write the state to the state file only on Linux/macOS + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + // Write the state to the state file + WriteServiceStateToFile(state); + } + } + + /// + /// Builds the PowerShell command to execute the Pode process. + /// + /// The PowerShell command string. + private string BuildCommand() + { + string podeServiceJson = $"{{\\\"DisableTermination\\\": {_disableTermination.ToString().ToLower()}, \\\"Quiet\\\": {_quiet.ToString().ToLower()}, \\\"PipeName\\\": \\\"{_pipeName}\\\"}}"; + return $"-NoProfile -Command \"& {{ $global:PodeService = '{podeServiceJson}' | ConvertFrom-Json; . '{_scriptPath}' {_parameterString} }}\""; + } + + /// + /// Initializes the named pipe client with a retry mechanism. + /// + /// The maximum number of retries for connection. + /// True if the pipe client is successfully connected; otherwise, false. + private bool InitializePipeClientWithRetry(int maxRetries = 3) + { + int attempts = 0; + + while (attempts < maxRetries) + { + try + { + if (_pipeClient == null) + { + _pipeClient = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut); + } + + if (!_pipeClient.IsConnected) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Connecting to pipe server (Attempt {attempts + 1})..."); + _pipeClient.Connect(10000); // Timeout of 10 seconds + } + + return _pipeClient.IsConnected; + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Pipe connection attempt {attempts + 1} failed: {ex.Message}"); + } + + attempts++; + Thread.Sleep(1000); + } + + return false; + } + + /// + /// Sends a message to the Pode process via named pipe. + /// + /// The message to send. + private void SendPipeMessage(string message) + { + try + { + using var writer = new StreamWriter(_pipeClient) { AutoFlush = true }; + writer.WriteLine(message); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Error sending message to pipe: {ex.Message}"); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, ex); + } + } + + /// + /// Waits for the Pode process to exit within the specified timeout. + /// + /// The timeout period in milliseconds. + private void WaitForProcessExit(int timeout) + { + int waited = 0; + while (!_powerShellProcess.HasExited && waited < timeout) + { + Thread.Sleep(200); + waited += 200; + } + } + + + /// + /// Writes the current service state to the state file. + /// + /// The service state to write. + private void WriteServiceStateToFile(ServiceState state) + { + lock (_syncLock) // Ensure thread-safe access + { + try + { + File.WriteAllText(_stateFilePath, state.ToString().ToLowerInvariant()); + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, $"Service state written to file: {_stateFilePath}"); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Failed to write service state to file: {ex.Message}"); + } + } + } + + /// + /// Deletes the service state file during cleanup. + /// + private void DeleteServiceStateFile() + { + lock (_syncLock) // Ensure thread-safe access + { + try + { + if (File.Exists(_stateFilePath)) + { + File.Delete(_stateFilePath); + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, $"Service state file deleted: {_stateFilePath}"); + } + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, $"Failed to delete service state file: {ex.Message}"); + } + } + } + + /// + /// Cleans up resources associated with the Pode process and the pipe client. + /// + private void CleanupResources() + { + _powerShellProcess?.Dispose(); + _powerShellProcess = null; + + CleanupPipeClient(); + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + DeleteServiceStateFile(); + } + } + + /// + /// Cleans up the named pipe client. + /// + private void CleanupPipeClient() + { + _pipeClient?.Dispose(); + _pipeClient = null; + } + } +} \ No newline at end of file diff --git a/src/PodeMonitor/PodeMonitor.csproj b/src/PodeMonitor/PodeMonitor.csproj new file mode 100644 index 000000000..cba19cd46 --- /dev/null +++ b/src/PodeMonitor/PodeMonitor.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + true + true + true + true + false + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64;linux-arm;win-x86;linux-musl-x64 + + + + + + + + + + + + + + + diff --git a/src/PodeMonitor/PodeMonitorLogger.cs b/src/PodeMonitor/PodeMonitorLogger.cs new file mode 100644 index 000000000..35e841568 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorLogger.cs @@ -0,0 +1,219 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace PodeMonitor +{ + + /// + /// A thread-safe logger for PodeMonitor that supports log rotation, exception logging, and log level filtering. + /// + public static partial class PodeMonitorLogger + { + private static readonly object _logLock = new(); // Ensures thread-safe writes + private static string _logFilePath = "PodeService.log"; // Default log file path + private static PodeLogLevel _minLogLevel = PodeLogLevel.INFO; // Default minimum log level + private const long DefaultMaxFileSize = 10 * 1024 * 1024; // Default max file size: 10 MB + + [GeneratedRegex(@"\x1B\[[0-9;]*[a-zA-Z]")] + private static partial Regex AnsiRegex(); + + /// + /// Initializes the logger with a custom log file path and minimum log level. + /// Validates the path, ensures the log file exists, and sets up log rotation. + /// + /// Path to the log file. + /// Minimum log level to record. + /// Maximum log file size in bytes before rotation. + public static void Initialize(string filePath, PodeLogLevel level, long maxFileSizeInBytes = DefaultMaxFileSize) + { + try + { + // Set the log file path and validate it + if (!string.IsNullOrWhiteSpace(filePath)) + { + ValidateLogPath(filePath); + _logFilePath = filePath; + } + + _minLogLevel = level; + + // Ensure the log file exists + if (!File.Exists(_logFilePath)) + { + using (File.Create(_logFilePath)) { }; + } + + // Perform log rotation if necessary + RotateLogFile(maxFileSizeInBytes); + + // Log initialization success + Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, + "Logger initialized. LogFilePath: {0}, MinLogLevel: {1}, MaxFileSize: {2} bytes", _logFilePath, _minLogLevel, maxFileSizeInBytes); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to initialize logger: {ex.Message}"); + } + } + + /// + /// Logs a message to the log file with the specified log level, context, and optional arguments. + /// + /// Log level. + /// Context of the log (e.g., "PodeMonitor"). + /// Process ID to include in the log. + /// Message to log. + /// Optional arguments for formatting the message. + public static void Log(PodeLogLevel level, string context, int pid, string message = "", params object[] args) + { + if (level < _minLogLevel || string.IsNullOrEmpty(message)) + { + return; // Skip logging for levels below the minimum or empty messages + } + + try + { + // Sanitize the message to remove ANSI escape codes + string sanitizedMessage = AnsiRegex().Replace(message, string.Empty); + + // Format the sanitized message + string formattedMessage = string.Format(sanitizedMessage, args); + + // Get the current time in ISO 8601 format (UTC) + string timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + // Construct the log entry + string logEntry = $"{timestamp} [PID:{pid}] [{level}] [{context}] {formattedMessage}"; + + // Thread-safe log file write + lock (_logLock) + { + using StreamWriter writer = new(_logFilePath, true); + writer.WriteLine(logEntry); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to log to file:"); + Console.WriteLine($"{context} - {message}"); + Console.WriteLine($"Error: {ex.Message}"); + } + } + + /// + /// Logs an exception and an optional message to the log file. + /// Includes exception stack trace and inner exception details. + /// + /// Log level. + /// Exception to log. + /// Optional message to include. + /// Optional arguments for formatting the message. + public static void Log(PodeLogLevel level, Exception exception, string message = null, params object[] args) + { + if (level < _minLogLevel || (exception == null && string.IsNullOrEmpty(message))) + { + return; // Skip logging if the level is below the minimum or there's nothing to log + } + + try + { + // Get the current time in ISO 8601 format (UTC) + string timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + // Format the message if provided + string logMessage = string.Empty; + + if (!string.IsNullOrEmpty(message)) + { + // Sanitize the message to remove ANSI escape codes + string sanitizedMessage = AnsiRegex().Replace(message, string.Empty); + logMessage = string.Format(sanitizedMessage, args); + } + + // Add exception details + if (exception != null) + { + logMessage += $"{Environment.NewLine}Exception: {exception.GetType().Name}"; + logMessage += $"{Environment.NewLine}Message: {exception.Message}"; + logMessage += $"{Environment.NewLine}Stack Trace: {exception.StackTrace}"; + + // Include inner exception details if any + var innerException = exception.InnerException; + while (innerException != null) + { + logMessage += $"{Environment.NewLine}Inner Exception: {innerException.GetType().Name}"; + logMessage += $"{Environment.NewLine}Message: {innerException.Message}"; + logMessage += $"{Environment.NewLine}Stack Trace: {innerException.StackTrace}"; + innerException = innerException.InnerException; + } + } + + // Construct the log entry + string logEntry = $"{timestamp} [PID:{Environment.ProcessId}] [{level}] [PodeMonitor] {logMessage}"; + + // Thread-safe log file write + lock (_logLock) + { + using StreamWriter writer = new(_logFilePath, true); + writer.WriteLine(logEntry); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to log exception to file: {ex.Message}"); + } + } + + /// + /// Ensures log rotation by renaming old logs when the current log file exceeds the specified size. + /// + /// Maximum size of the log file in bytes before rotation. + private static void RotateLogFile(long maxFileSizeInBytes) + { + lock (_logLock) + { + FileInfo logFile = new(_logFilePath); + if (logFile.Exists && logFile.Length > maxFileSizeInBytes) + { + string rotatedFilePath = $"{_logFilePath}.{DateTime.UtcNow:yyyyMMddHHmmss}"; + File.Move(_logFilePath, rotatedFilePath); + } + } + } + + /// + /// Validates the log file path to ensure it is writable. + /// Creates the directory if it does not exist. + /// + /// Path to validate. + private static void ValidateLogPath(string filePath) + { + string directory = Path.GetDirectoryName(filePath); + + if (string.IsNullOrWhiteSpace(directory)) + { + throw new ArgumentException("Invalid log file path: Directory cannot be determined."); + } + + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + try + { + string testFilePath = Path.Combine(directory, "test_write.log"); + using (var stream = File.Create(testFilePath)) + { + stream.WriteByte(0); + } + File.Delete(testFilePath); + } + catch (Exception ex) + { + throw new IOException($"Log directory is not writable: {directory}", ex); + } + } + } +} diff --git a/src/PodeMonitor/PodeMonitorMain.cs b/src/PodeMonitor/PodeMonitorMain.cs new file mode 100644 index 000000000..d06672efc --- /dev/null +++ b/src/PodeMonitor/PodeMonitorMain.cs @@ -0,0 +1,232 @@ +using System; +using System.IO; +using System.ServiceProcess; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; +using System.Text.Json; +using Microsoft.Extensions.Options; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace PodeMonitor +{ + /// + /// Entry point for the Pode service. Handles platform-specific configurations and signal-based operations. + /// + public static partial class Program + { + // Platform-dependent signal registration (for linux/macOS) + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SignalHandler(int signum); + + [LibraryImport("libc", EntryPoint = "signal")] + private static partial int Signal(int signum, SignalHandler handler); + + private const int SIGTSTP = 20; // Signal for pause + private const int SIGCONT = 18; // Signal for continue + private const int SIGHUP = 1; // Signal for restart + private const int SIGTERM = 15; // Signal for gracefully terminate a process. + + private static PodeMonitorWorker _workerInstance; // Global instance for managing worker operations + + /// + /// Entry point for the Pode service. + /// + /// Command-line arguments. + public static void Main(string[] args) + { + string customConfigFile = args.Length > 0 ? args[0] : "srvsettings.json"; // Default config file + string serviceName = "PodeService"; + + // Check if the custom configuration file exists + if (!File.Exists(customConfigFile)) + { + Console.WriteLine($"Configuration file '{customConfigFile}' not found. Please provide a valid configuration file."); + Environment.Exit(1); // Exit with a non-zero code to indicate failure + } + + // Load configuration + IConfigurationRoot config = new ConfigurationBuilder() + .AddJsonFile(customConfigFile, optional: false, reloadOnChange: true) + .Build(); + + serviceName = config.GetSection("PodeMonitorWorker:Name").Value ?? serviceName; + + string logFilePath = config.GetSection("PodeMonitorWorker:logFilePath").Value ?? "PodeMonitorService.log"; + + // Parse log level + string logLevelString = config.GetSection("PodeMonitorWorker:LogLevel").Value; + + if (!Enum.TryParse(logLevelString, true, out PodeLogLevel logLevel)) + { + Console.WriteLine($"Invalid or missing log level '{logLevelString}'. Defaulting to INFO."); + logLevel = PodeLogLevel.INFO; // Default log level + } + else + { + Console.WriteLine($"Log level set to '{logLevelString}'."); + } + + // Parse log max file size + string logMaxFileSizeString = config.GetSection("PodeMonitorWorker:LogMaxFileSize").Value; + if (!long.TryParse(logMaxFileSizeString, out long logMaxFileSize)) + { + Console.WriteLine($"Invalid or missing log max file size '{logMaxFileSizeString}'. Defaulting to 10 MB."); + logMaxFileSize = 10 * 1024 * 1024; // Default to 10 MB + } + // Initialize logger + PodeMonitorLogger.Initialize(logFilePath, logLevel, logMaxFileSize); + + // Configure host builder + var builder = CreateHostBuilder(args, customConfigFile); + + // Platform-specific logic + if (OperatingSystem.IsLinux()) + { + ConfigureLinux(builder); + } + else if (OperatingSystem.IsWindows()) + { + ConfigureWindows(builder, serviceName); + } + else if (OperatingSystem.IsMacOS()) + { + ConfigureMacOS(builder); + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.WARN, "PodeMonitor", Environment.ProcessId, "Unsupported platform. Exiting."); + } + } + + /// + /// Creates and configures the host builder for the Pode service. + /// + /// Command-line arguments. + /// Path to the custom configuration file. + /// The configured host builder. + private static IHostBuilder CreateHostBuilder(string[] args, string customConfigFile) + { + return Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(config => + { + config.AddJsonFile(customConfigFile, optional: false, reloadOnChange: true); + }) + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection("PodeMonitorWorker")); + + // Register PodeMonitor + services.AddSingleton(serviceProvider => + { + var options = serviceProvider.GetRequiredService>().Value; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Initializing PodeMonitor with options: {0}", JsonSerializer.Serialize(options)); + return new PodeMonitor(options); + }); + + // Register PodeMonitorWorker and track the instance + services.AddSingleton(provider => + { + var logger = provider.GetRequiredService>(); + var monitor = provider.GetRequiredService(); + var worker = new PodeMonitorWorker(logger, monitor); + _workerInstance = worker; // Track the instance globally + return worker; + }); + + // Add PodeMonitorWorker as a hosted service + services.AddHostedService(provider => provider.GetRequiredService()); + + // Register IPausableHostedService + services.AddSingleton(provider => provider.GetRequiredService()); + }); + } + +#if ENABLE_LIFECYCLE_OPERATIONS + /// + /// Configures the Pode service for linux, including signal handling. + /// + /// The host builder. + [SupportedOSPlatform("linux")] + private static void ConfigureLinux(IHostBuilder builder) + { + // Handle linux signals for pause, resume, and restart + Signal(SIGTSTP, HandleSignalStop); + Signal(SIGCONT, HandleSignalContinue); + Signal(SIGHUP, HandleSignalRestart); + builder.UseSystemd(); + builder.Build().Run(); + } + + /// + /// Configures the Pode service for macOS, including signal handling. + /// + /// The host builder. + [SupportedOSPlatform("macOS")] + private static void ConfigureMacOS(IHostBuilder builder) + { + // Use launchd for macOS + Signal(SIGTSTP, HandleSignalStop); + Signal(SIGCONT, HandleSignalContinue); + Signal(SIGHUP, HandleSignalRestart); + Signal(SIGTERM, HandleSignalTerminate); + builder.Build().Run(); + } + + /// + /// Configures the Pode service for Windows, enabling pause and continue support. + /// + /// The host builder. + /// The name of the service. + [SupportedOSPlatform("windows")] + private static void ConfigureWindows(IHostBuilder builder, string serviceName) + { + using var host = builder.Build(); + var service = new PodeMonitorWindowsService(host, serviceName); + ServiceBase.Run(service); + } + + private static void HandleSignalStop(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGTSTP received."); + HandlePause(); + } + + private static void HandleSignalTerminate(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGTERM received."); + HandleStop(); + } + + private static void HandleSignalContinue(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGCONT received."); + HandleContinue(); + } + + private static void HandleSignalRestart(int signum) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "SIGHUP received."); + HandleRestart(); + } + + private static void HandlePause() => _workerInstance?.OnPause(); + private static void HandleContinue() => _workerInstance?.OnContinue(); + private static void HandleRestart() => _workerInstance?.Restart(); + private static void HandleStop() => _workerInstance?.Shutdown(); +#else + [SupportedOSPlatform("linux")] + private static void ConfigureLinux(IHostBuilder builder) => builder.UseSystemd().Build().Run(); + + [SupportedOSPlatform("macOS")] + private static void ConfigureMacOS(IHostBuilder builder) => builder.Build().Run(); + + [SupportedOSPlatform("windows")] + private static void ConfigureWindows(IHostBuilder builder, string serviceName) => + builder.UseWindowsService().Build().Run(); +#endif + } +} diff --git a/src/PodeMonitor/PodeMonitorWindowsService.cs b/src/PodeMonitor/PodeMonitorWindowsService.cs new file mode 100644 index 000000000..b962e3c04 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorWindowsService.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.ServiceProcess; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Runtime.Versioning; +using System.Diagnostics; + +namespace PodeMonitor +{ + /// + /// Represents a Windows service that integrates with a Pode host and supports lifecycle operations such as start, stop, pause, continue, and restart. + /// + [SupportedOSPlatform("windows")] + public class PodeMonitorWindowsService : ServiceBase + { + private readonly IHost _host; // The Pode host instance + private const int CustomCommandRestart = 128; // Custom command for SIGHUP-like restart + + /// + /// Initializes a new instance of the PodeMonitorWindowsService class. + /// + /// The host instance managing the Pode application. + /// The name of the Windows service. + public PodeMonitorWindowsService(IHost host, string serviceName) + { + _host = host ?? throw new ArgumentNullException(nameof(host), "Host cannot be null."); + CanPauseAndContinue = true; // Enable support for pause and continue operations + ServiceName = serviceName ?? throw new ArgumentNullException(nameof(serviceName), "Service name cannot be null."); // Dynamically set the service name + } + + /// + /// Handles the service start operation. Initializes the Pode host and starts its execution. + /// + /// Command-line arguments passed to the service. + protected override void OnStart(string[] args) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service starting..."); + try + { + base.OnStart(args); // Call the base implementation + _host.StartAsync().Wait(); // Start the Pode host asynchronously and wait for it to complete + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service started successfully."); + } + catch (Exception ex) + { + // Log the exception to the custom log + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Service startup failed."); + + // Write critical errors to the Windows Event Log + EventLog.WriteEntry(ServiceName, $"Critical failure during service startup: {ex.Message}\n{ex.StackTrace}", + EventLogEntryType.Error); + + // Rethrow the exception to signal failure to the Windows Service Manager + throw; + } + } + + /// + /// Handles the service stop operation. Gracefully stops the Pode host. + /// + protected override void OnStop() + { + base.OnStop(); // Call the base implementation + _host.StopAsync().Wait(); // Stop the Pode host asynchronously and wait for it to complete + } + + /// + /// Handles the service pause operation. Pauses the Pode host by invoking IPausableHostedService. + /// + protected override void OnPause() + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service pausing..."); + base.OnPause(); // Call the base implementation + + // Retrieve the IPausableHostedService instance from the service container + var service = _host.Services.GetService(typeof(IPausableHostedService)); + if (service != null) + { + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, $"Resolved IPausableHostedService: {service.GetType().FullName}"); + ((IPausableHostedService)service).OnPause(); // Invoke the pause operation + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, "Error: Failed to resolve IPausableHostedService."); + } + } + + /// + /// Handles the service resume operation. Resumes the Pode host by invoking IPausableHostedService. + /// + protected override void OnContinue() + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service resuming..."); + base.OnContinue(); // Call the base implementation + + // Retrieve the IPausableHostedService instance from the service container + var service = _host.Services.GetService(typeof(IPausableHostedService)); + if (service != null) + { + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, $"Resolved IPausableHostedService: {service.GetType().FullName}"); + ((IPausableHostedService)service).OnContinue(); // Invoke the resume operation + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, "Error: Failed to resolve IPausableHostedService."); + } + } + + /// + /// Handles custom control commands sent to the service. Supports a SIGHUP-like restart operation. + /// + /// The custom command number. + protected override void OnCustomCommand(int command) + { + if (command == CustomCommandRestart) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Custom restart command received. Restarting service..."); + var service = _host.Services.GetService(typeof(IPausableHostedService)); + if (service != null) + { + ((IPausableHostedService)service).Restart(); // Trigger the restart operation + } + else + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, "PodeMonitor", Environment.ProcessId, "Error: Failed to resolve IPausableHostedService for restart."); + } + } + else + { + base.OnCustomCommand(command); // Handle other custom commands + } + } + } +} diff --git a/src/PodeMonitor/PodeMonitorWorker.cs b/src/PodeMonitor/PodeMonitorWorker.cs new file mode 100644 index 000000000..21e516b32 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorWorker.cs @@ -0,0 +1,226 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PodeMonitor +{ + /// + /// Manages the lifecycle of the Pode PowerShell process, supporting start, stop, pause, and resume operations. + /// Implements IPausableHostedService for handling pause and resume operations. + /// + public sealed class PodeMonitorWorker : BackgroundService, IPausableHostedService + { + // Logger instance for logging informational and error messages + private readonly ILogger _logger; + + // Instance of PodeMonitor to manage the Pode PowerShell process + private readonly PodeMonitor _pwshMonitor; + + // Delay in milliseconds to prevent rapid consecutive operations + private readonly int _delayMs = 5000; + + private bool _terminating = false; + + public ServiceState State => _pwshMonitor.State; + + + /// + /// Initializes a new instance of the PodeMonitorWorker class. + /// + /// Logger instance for logging messages and errors. + /// Instance of PodeMonitor for managing the PowerShell process. + public PodeMonitorWorker(ILogger logger, PodeMonitor pwshMonitor) + { + _logger = logger; // Assign the logger + _pwshMonitor = pwshMonitor; // Assign the shared PodeMonitor instance + _logger.LogInformation("PodeMonitorWorker initialized with shared PodeMonitor."); + } + + /// + /// The main execution loop for the worker. + /// Monitors and restarts the Pode PowerShell process if needed. + /// + /// Cancellation token to signal when the worker should stop. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "PodeMonitorWorker running at: {0}", DateTimeOffset.Now); + int retryCount = 0; // Tracks the number of retries in case of failures + + while (!stoppingToken.IsCancellationRequested && !_terminating) + { + try + { + retryCount = 0; // Reset retry count on success + + // Start the Pode PowerShell process + _pwshMonitor.StartPowerShellProcess(); + } + catch (Exception ex) + { + retryCount++; + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error in ExecuteAsync: {0}. Retry {1}/{2}", ex.Message, retryCount, _pwshMonitor.StartMaxRetryCount); + + // If retries exceed the maximum, log and exit the loop + if (retryCount >= _pwshMonitor.StartMaxRetryCount) + { + PodeMonitorLogger.Log(PodeLogLevel.CRITICAL, "PodeMonitor", Environment.ProcessId, "Maximum retry count reached. Exiting monitoring loop."); + break; + } + + // Delay before retrying + await Task.Delay(_pwshMonitor.StartRetryDelayMs, stoppingToken); + } + + // Add a delay between iterations + await Task.Delay(10000, stoppingToken); + } + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Monitoring loop has stopped."); + } + + /// + /// Stops the Pode PowerShell process gracefully. + /// + /// Cancellation token to signal when the stop should occur. + public override async Task StopAsync(CancellationToken stoppingToken) + { + Shutdown(); + + await base.StopAsync(stoppingToken); // Wait for the base StopAsync to complete + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service stopped successfully at: {0}", DateTimeOffset.Now); + } + + + /// + /// Shutdown the Pode PowerShell process by sending a shutdown command. + /// + public void Shutdown() + { + if ((!_terminating) && (_pwshMonitor.State == ServiceState.Running || _pwshMonitor.State == ServiceState.Suspended)) + { + + _terminating = true; + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service is stopping at: {0}", DateTimeOffset.Now); + + try + { + _pwshMonitor.StopPowerShellProcess(); // Stop the process + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Stop message sent via pipe at: {0}", DateTimeOffset.Now); + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error stopping PowerShell process: {0}", ex.Message); + } + } + } + + /// + /// Restarts the Pode PowerShell process by sending a restart command. + /// + public void Restart() + { + if ((!_terminating) && _pwshMonitor.State == ServiceState.Running || _pwshMonitor.State == ServiceState.Suspended) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Service restarting at: {0}", DateTimeOffset.Now); + try + { + _pwshMonitor.RestartPowerShellProcess(); // Restart the process + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Restart message sent via pipe at: {0}", DateTimeOffset.Now); + + //AddOperationDelay("Pause"); // Delay to ensure stability + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error during pause: {0}", ex.Message); + } + } + } + + /// + /// Pauses the Pode PowerShell process and adds a delay to ensure stable operation. + /// + public void OnPause() + { + if ((!_terminating) && _pwshMonitor.State == ServiceState.Running) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Pause command received at: {0}", DateTimeOffset.Now); + + try + { + _pwshMonitor.SuspendPowerShellProcess(); // Send pause command to the process + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Suspend message sent via pipe at: {0}", DateTimeOffset.Now); + var retryCount = 0; // Reset retry count on success + while (_pwshMonitor.State != ServiceState.Suspended) + { + if (retryCount >= 100) + { + PodeMonitorLogger.Log(PodeLogLevel.CRITICAL, "PodeMonitor", Environment.ProcessId, "Maximum retry count reached. Exiting monitoring loop."); + break; + } + + // Delay before retrying + Thread.Sleep(200); + } + //AddOperationDelay("Pause"); // Delay to ensure stability + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error during pause: {0}", ex.Message); + } + } + } + + /// + /// Resumes the Pode PowerShell process and adds a delay to ensure stable operation. + /// + public void OnContinue() + { + if ((!_terminating) && _pwshMonitor.State == ServiceState.Suspended) + { + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Continue command received at: {0}", DateTimeOffset.Now); + + try + { + _pwshMonitor.ResumePowerShellProcess(); // Send resume command to the process + + PodeMonitorLogger.Log(PodeLogLevel.INFO, "PodeMonitor", Environment.ProcessId, "Resume message sent via pipe at: {0}", DateTimeOffset.Now); + var retryCount = 0; // Reset retry count on success + while (_pwshMonitor.State == ServiceState.Suspended) + { + if (retryCount >= 100) + { + PodeMonitorLogger.Log(PodeLogLevel.CRITICAL, "PodeMonitor", Environment.ProcessId, "Maximum retry count reached. Exiting monitoring loop."); + break; + } + + // Delay before retrying + Thread.Sleep(200); + } + + + // AddOperationDelay("Resume"); // Delay to ensure stability + } + catch (Exception ex) + { + PodeMonitorLogger.Log(PodeLogLevel.ERROR, ex, "Error during continue: {0}", ex.Message); + } + } + } + + /// + /// Adds a delay to ensure that rapid consecutive operations are prevented. + /// + /// The name of the operation (e.g., "Pause" or "Resume"). + private void AddOperationDelay(string operation) + { + PodeMonitorLogger.Log(PodeLogLevel.DEBUG, "PodeMonitor", Environment.ProcessId, "{0} operation completed. Adding delay of {1} ms.", operation, _delayMs); + Thread.Sleep(_delayMs); // Introduce a delay + } + } +} diff --git a/src/PodeMonitor/PodeMonitorWorkerOptions.cs b/src/PodeMonitor/PodeMonitorWorkerOptions.cs new file mode 100644 index 000000000..a83f5a490 --- /dev/null +++ b/src/PodeMonitor/PodeMonitorWorkerOptions.cs @@ -0,0 +1,93 @@ +using System; + +namespace PodeMonitor +{ + /// + /// Configuration options for the PodeMonitorWorker service. + /// These options determine how the worker operates, including paths, parameters, and retry policies. + /// + public class PodeMonitorWorkerOptions + { + /// + /// The name of the service. + /// + public string Name { get; set; } + + /// + /// The path to the PowerShell script that the worker will execute. + /// + public string ScriptPath { get; set; } + + /// + /// The path to the PowerShell executable (pwsh). + /// + public string PwshPath { get; set; } + + /// + /// Additional parameters to pass to the PowerShell process. + /// Default is an empty string. + /// + public string ParameterString { get; set; } = ""; + + /// + /// The path to the log file where output from the PowerShell process will be written. + /// Default is an empty string (no logging). + /// + public string LogFilePath { get; set; } = ""; + + /// + /// The logging level for the service (e.g., DEBUG, INFO, WARN, ERROR, CRITICAL). + /// Default is INFO. + /// + public PodeLogLevel LogLevel { get; set; } = PodeLogLevel.INFO; + + /// + /// The maximum size (in bytes) of the log file before it is rotated. + /// Default is 10 MB (10 * 1024 * 1024 bytes). + /// + public long LogMaxFileSize { get; set; } = 10 * 1024 * 1024; + + /// + /// Indicates whether the PowerShell process should run in quiet mode, suppressing output. + /// Default is true. + /// + public bool Quiet { get; set; } = true; + + /// + /// Indicates whether termination of the PowerShell process is disabled. + /// Default is true. + /// + public bool DisableTermination { get; set; } = true; + + /// + /// The maximum time to wait (in milliseconds) for the PowerShell process to shut down. + /// Default is 30,000 milliseconds (30 seconds). + /// + public int ShutdownWaitTimeMs { get; set; } = 30000; + + /// + /// The maximum number of retries to start the PowerShell process before giving up. + /// Default is 3 retries. + /// + public int StartMaxRetryCount { get; set; } = 3; + + /// + /// The delay (in milliseconds) between retry attempts to start the PowerShell process. + /// Default is 5,000 milliseconds (5 seconds). + /// + public int StartRetryDelayMs { get; set; } = 5000; + + /// + /// Provides a string representation of the configured options for debugging or logging purposes. + /// + /// A string containing all configured options and their values. + public override string ToString() + { + return $"Name: {Name}, ScriptPath: {ScriptPath}, PwshPath: {PwshPath}, ParameterString: {ParameterString}, " + + $"LogFilePath: {LogFilePath}, LogLevel: {LogLevel}, LogMaxFileSize: {LogMaxFileSize}, Quiet: {Quiet}, " + + $"DisableTermination: {DisableTermination}, ShutdownWaitTimeMs: {ShutdownWaitTimeMs}, " + + $"StartMaxRetryCount: {StartMaxRetryCount}, StartRetryDelayMs: {StartRetryDelayMs}"; + } + } + +} diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index 288d4f0ab..2a74d9d48 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -50,7 +50,10 @@ function New-PodeContext { $Quiet, [switch] - $EnableBreakpoints + $EnableBreakpoints, + + [hashtable] + $Service ) # set a random server name if one not supplied @@ -96,6 +99,10 @@ function New-PodeContext { $ctx.Server.Quiet = $Quiet.IsPresent $ctx.Server.ComputerName = [System.Net.DNS]::GetHostName() + + if ($null -ne $Service) { + $ctx.Server.Service = $Service + } # list of created listeners/receivers $ctx.Listeners = @() $ctx.Receivers = @() @@ -144,6 +151,7 @@ function New-PodeContext { Tasks = 2 WebSockets = 2 Timers = 1 + Service = 0 } # set socket details for pode server @@ -320,13 +328,13 @@ function New-PodeContext { # routes for pages and api $ctx.Server.Routes = [ordered]@{ -# common methods + # common methods 'get' = [ordered]@{} 'post' = [ordered]@{} 'put' = [ordered]@{} 'patch' = [ordered]@{} 'delete' = [ordered]@{} -# other methods + # other methods 'connect' = [ordered]@{} 'head' = [ordered]@{} 'merge' = [ordered]@{} @@ -437,6 +445,7 @@ function New-PodeContext { Tasks = $null Files = $null Timers = $null + Service = $null } # threading locks, etc. @@ -632,6 +641,14 @@ function New-PodeRunspacePool { $PodeContext.RunspacePools.Gui.Pool.ApartmentState = 'STA' } + + if (Test-PodeServiceEnabled ) { + $PodeContext.Threads['Service'] = 1 + $PodeContext.RunspacePools.Service = @{ + Pool = [runspacefactory]::CreateRunspacePool(1, 1, $PodeContext.RunspaceState, $Host) + State = 'Waiting' + } + } } <# diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 8e4e9f37d..180f34f5f 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -145,27 +145,6 @@ function Get-PodePSVersionTable { return $PSVersionTable } -function Test-PodeIsAdminUser { - # check the current platform, if it's unix then return true - if (Test-PodeIsUnix) { - return $true - } - - try { - $principal = [System.Security.Principal.WindowsPrincipal]::new([System.Security.Principal.WindowsIdentity]::GetCurrent()) - if ($null -eq $principal) { - return $false - } - - return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) - } - catch [exception] { - Write-PodeHost 'Error checking user administrator priviledges' -ForegroundColor Red - Write-PodeHost $_.Exception.Message -ForegroundColor Red - return $false - } -} - function Get-PodeHostIPRegex { param( [Parameter(Mandatory = $true)] @@ -3744,10 +3723,10 @@ function Resolve-PodeObjectArray { # Changes to $clonedObject will not affect $originalObject and vice versa. .NOTES - This function uses the System.Management.Automation.PSSerializer class, which is available in - PowerShell 5.1 and later versions. The default depth parameter is set to 10 to handle nested - objects appropriately, but it can be customized via the -Deep parameter. - This is an internal function and may change in future releases of Pode. + - This function uses the System.Management.Automation.PSSerializer class, which is available in + PowerShell 5.1 and later versions. The default depth parameter is set to 10 to handle nested + objects appropriately, but it can be customized via the -Deep parameter. + - This is an internal function and may change in future releases of Pode. #> function Copy-PodeObjectDeepClone { param ( @@ -3766,4 +3745,367 @@ function Copy-PodeObjectDeepClone { # Deserialize the XML back into a new PSObject, creating a deep clone of the original return [System.Management.Automation.PSSerializer]::Deserialize($xmlSerializer) } -} \ No newline at end of file +} + +<# +.SYNOPSIS + Tests if the current user has administrative privileges on Windows or root/sudo privileges on Linux/macOS. + +.DESCRIPTION + This function checks the current user's privileges. On Windows, it checks if the user is an Administrator. + If the session is not elevated, you can optionally check if the user has the potential to elevate using the -Elevate switch. + On Linux and macOS, it checks if the user is either root or has sudo (Linux) or admin (macOS) privileges. + You can also check if the user has the potential to elevate by belonging to the sudo or admin group using the -Elevate switch. + +.PARAMETER Elevate + The -Elevate switch allows you to check if the current user has the potential to elevate to administrator/root privileges, + even if the session is not currently elevated. + +.PARAMETER Console + The -Console switch will output errors to the console if an exception occurs. + Otherwise, the errors will be written to the Pode error log. + +.EXAMPLE + Test-PodeAdminPrivilege + + If the user has administrative privileges, it returns $true. If not, it returns $false. + +.EXAMPLE + Test-PodeAdminPrivilege -Elevate + + This will check if the user has administrative/root/sudo privileges or the potential to elevate, + even if the session is not currently elevated. + +.EXAMPLE + Test-PodeAdminPrivilege -Elevate -Console + + This will check for admin privileges or potential to elevate and will output errors to the console if any occur. + +.OUTPUTS + [bool] + Returns $true if the user has administrative/root/sudo/admin privileges or the potential to elevate, + otherwise returns $false. + +.NOTES + - This function works across multiple platforms: Windows, Linux, and macOS. + On Linux/macOS, it checks for root, sudo, or admin group memberships, and optionally checks for elevation potential + if the -Elevate switch is used. + - This is an internal function and may change in future releases of Pode. +#> + +function Test-PodeAdminPrivilege { + param( + [switch] + $Elevate, + [switch] + $Console + ) + try { + # Check if the operating system is Windows + if (Test-PodeIsWindows) { + + # Retrieve the current Windows identity and token + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + + if ($null -eq $principal) { + return $false + } + + $isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if ($isAdmin) { + return $true + } + + # Check if the token is elevated + if ($identity.IsSystem -or $identity.IsAuthenticated -and $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + return $true + } + + if ($Elevate.IsPresent) { + # Use 'whoami /groups' to check if the user has the potential to elevate + $groups = whoami /groups + if ($groups -match 'S-1-5-32-544') { + return $true + } + } + return $false + } + else { + # Check if the operating system is Linux or macOS (both are Unix-like) + + # Check if the user is root (UID 0) + $isRoot = [int](id -u) + if ($isRoot -eq 0) { + return $true + } + + if ($Elevate.IsPresent) { + # Check if the user has sudo privileges by checking sudo group membership + $user = whoami + $groups = (groups $user) + Write-Verbose "User:$user Groups: $( $groups -join ',')" + # macOS typically uses 'admin' group for sudo privileges + return ($groups -match '\bwheel\b' -or $groups -match '\badmin\b' -or $groups -match '\bsudo\b' -or $groups -match '\badm\b') + } + return $false + } + } + catch [exception] { + if ($Console.IsPresent) { + Write-PodeHost 'Error checking user privileges' -ForegroundColor Red + Write-PodeHost $_.Exception.Message -ForegroundColor Red + } + else { + $_ | Write-PodeErrorLog + } + return $false + } +} + +<# +.SYNOPSIS + Starts a command with elevated privileges if the current session is not already elevated. + +.DESCRIPTION + This function checks if the current PowerShell session is running with administrator privileges. + If not, it re-launches the command as an elevated process. If the session is already elevated, + it will execute the command directly and return the result of the command. + +.PARAMETER Command + The PowerShell command to be executed. This can be any valid PowerShell command, script, or executable. + +.PARAMETER Arguments + The arguments to be passed to the command. This can be any valid argument list for the command or script. + +.EXAMPLE + Invoke-PodeWinElevatedCommand -Command "Get-Service" -Arguments "-Name 'W32Time'" + + This will run the `Get-Service` command with elevated privileges, pass the `-Name 'W32Time'` argument, and return the result. + +.EXAMPLE + Invoke-PodeWinElevatedCommand -Command "C:\Scripts\MyScript.ps1" -Arguments "-Param1 'Value1' -Param2 'Value2'" + + This will run the script `MyScript.ps1` with elevated privileges, pass the parameters `-Param1` and `-Param2`, and return the result. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Invoke-PodeWinElevatedCommand { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')] + param ( + [string] + $Command, + [string] + $Arguments, + [PSCredential] $Credential + ) + + + # Check if the current session is elevated + $isElevated = ([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + + if (-not $isElevated) { + + # Escape the arguments by replacing " with `" (escaping quotes) + $escapedArguments = $Arguments -replace '"', '"""' + $psCredential = '' + + # Combine command and arguments into a string to pass for elevated execution + # $escapedCommand = "`"$Command`" $Arguments" + if ($Credential) { + $password = Convertfrom-SecureString $Credential.Password + $psCredential = "-Credential ([pscredential]::new('$($Credential.UserName)', `$('$password'|ConvertTo-SecureString)))" + } + + # Combine command and arguments into a string for elevated execution + $escapedCommand = "$Command $psCredential $escapedArguments" + # Start elevated process with properly escaped command and arguments + $result = Start-Process -FilePath ((Get-Process -Id $PID).Path) ` + -ArgumentList '-NoProfile', '-ExecutionPolicy Bypass', "-Command & {$escapedCommand}" ` + -Verb RunAs -Wait -PassThru + + return $result + } + + # Run the command directly with arguments if elevated and capture the output + return Invoke-Expression "$Command $Arguments" +} + +<# +.SYNOPSIS + Converts a duration in milliseconds into a human-readable time format. + +.DESCRIPTION + This function takes an input duration in milliseconds and converts it into + a readable time format. It supports multiple output styles, such as verbose + (detailed text), compact (`dd:hh:mm:ss`), and concise (short notation). + Optionally, milliseconds can be excluded from the output. + +.PARAMETER Milliseconds + The duration in milliseconds to be converted. + +.PARAMETER VerboseOutput + If specified, outputs a detailed, descriptive format (e.g., "1 day, 2 hours, 3 minutes"). + +.PARAMETER CompactOutput + If specified, outputs a compact format (e.g., "dd:hh:mm:ss"). + +.PARAMETER ExcludeMilliseconds + If specified, excludes milliseconds from the output. + +.EXAMPLE + Convert-PodeMillisecondsToReadable -Milliseconds 123456789 + + Output: + 1d 10h 17m 36s + +.EXAMPLE + Convert-PodeMillisecondsToReadable -Milliseconds 123456789 -VerboseOutput + + Output: + 1 day, 10 hours, 17 minutes, 36 seconds, 789 milliseconds + +.EXAMPLE + Convert-PodeMillisecondsToReadable -Milliseconds 123456789 -CompactOutput -ExcludeMilliseconds + + Output: + 01:10:17:36 + +.NOTES + This is an internal function and may change in future releases of Pode. +#> + +function Convert-PodeMillisecondsToReadable { + param ( + [Parameter(Mandatory)] + [long]$Milliseconds, + + [switch]$VerboseOutput, # Provide detailed descriptions + [switch]$CompactOutput, # Provide compact format like dd:hh:mm:ss or mm:ss:ms + [switch]$ExcludeMilliseconds # Exclude milliseconds from the output + ) + + $timeSpan = [timespan]::FromMilliseconds($Milliseconds) + + if ($CompactOutput) { + # Dynamically build compact format + $components = @() + + # Include days only if greater than 0 + if ($timeSpan.Days -gt 0) { $components += '{0:D2}' -f $timeSpan.Days } + + # Include hours only if greater than 0 or days are included + if ($timeSpan.Hours -gt 0 -or $components.Count -gt 0) { $components += '{0:D2}' -f $timeSpan.Hours } + + # Include minutes if relevant + if ($timeSpan.Minutes -gt 0 -or $components.Count -gt 0) { $components += '{0:D2}' -f $timeSpan.Minutes } + + # Add seconds as the final required time component + $components += '{0:D2}' -f $timeSpan.Seconds + + # Append milliseconds if not excluded + if (-not $ExcludeMilliseconds) { + $components[-1] += ':{0:D3}' -f $timeSpan.Milliseconds + } + + # Join with ":" and return + return $components -join ':' + } + + # Default or verbose format + if ($VerboseOutput) { + $verboseParts = @() + if ($timeSpan.Days -gt 0) { $verboseParts += "$($timeSpan.Days) day$(if ($timeSpan.Days -ne 1) { 's' })" } + if ($timeSpan.Hours -gt 0) { $verboseParts += "$($timeSpan.Hours) hour$(if ($timeSpan.Hours -ne 1) { 's' })" } + if ($timeSpan.Minutes -gt 0) { $verboseParts += "$($timeSpan.Minutes) minute$(if ($timeSpan.Minutes -ne 1) { 's' })" } + if ($timeSpan.Seconds -gt 0) { $verboseParts += "$($timeSpan.Seconds) second$(if ($timeSpan.Seconds -ne 1) { 's' })" } + if (-not $ExcludeMilliseconds -and $timeSpan.Milliseconds -gt 0) { + $verboseParts += "$($timeSpan.Milliseconds) millisecond$(if ($timeSpan.Milliseconds -ne 1) { 's' })" + } + + return $verboseParts -join ' ' + } + + # Default concise format + $parts = @() + if ($timeSpan.Days -gt 0) { $parts += "$($timeSpan.Days)d" } + if ($timeSpan.Hours -gt 0 -or $parts.Count -gt 0) { $parts += "$($timeSpan.Hours)h" } + if ($timeSpan.Minutes -gt 0 -or $parts.Count -gt 0) { $parts += "$($timeSpan.Minutes)m" } + if ($timeSpan.Seconds -gt 0 -or $parts.Count -gt 0) { $parts += "$($timeSpan.Seconds)s" } + if (-not $ExcludeMilliseconds -and $timeSpan.Milliseconds -gt 0 -or $parts.Count -gt 0) { + $parts += "$($timeSpan.Milliseconds)ms" + } + + return $parts -join ':' +} + +<# +.SYNOPSIS + Determines the OS architecture for the current system. + +.DESCRIPTION + This function detects the operating system's architecture and maps it to a format compatible with + PowerShell installation requirements. It works on both Windows and Unix-based systems, translating + various architecture identifiers (e.g., 'amd64', 'x86_64') into standardized PowerShell-supported names + like 'x64', 'x86', 'arm64', and 'arm32'. On Linux, the function also checks for musl libc to provide + an architecture-specific identifier. + +.OUTPUTS + [string] - The architecture string, such as 'x64', 'x86', 'arm64', 'arm32', or 'musl-x64'. + +.EXAMPLE + $arch = Get-PodeOSPwshArchitecture + Write-Host "Current architecture: $arch" + +.NOTES + - For Windows, architecture is derived from the `PROCESSOR_ARCHITECTURE` environment variable. + - For Unix-based systems, architecture is determined using the `uname -m` command. + - The function adds support for identifying musl libc on Linux, returning 'musl-x64' if detected. + - If the architecture is not supported, the function returns an empty string. +#> +function Get-PodeOSPwshArchitecture { + # Initialize an empty variable for storing the detected architecture + $arch = [string]::Empty + + # Detect architecture on Unix-based systems (Linux/macOS) + if ($IsLinux -or $IsMacOS) { + # Use the 'uname -m' command to determine the system architecture + $arch = uname -m + } + else { + # For Windows, use the environment variable 'PROCESSOR_ARCHITECTURE' + $arch = $env:PROCESSOR_ARCHITECTURE + } + + # Map the detected architecture to PowerShell-compatible formats + switch ($arch.ToLowerInvariant()) { + 'amd64' { $arch = 'x64' } # 64-bit AMD architecture + 'x86' { $arch = 'x86' } # 32-bit Intel architecture + 'x86_64' { $arch = 'x64' } # 64-bit Intel architecture + 'armv7*' { $arch = 'arm32' } # 32-bit ARM architecture (v7 series) + 'aarch64*' { $arch = 'arm64' } # 64-bit ARM architecture (aarch64 series) + 'arm64' { $arch = 'arm64' } # Explicit ARM64 + 'arm64*' { $arch = 'arm64' } # Pattern matching for ARM64 + 'armv8*' { $arch = 'arm64' } # ARM v8 series + default { return '' } # Unsupported architectures, return empty string + } + + # Additional check for musl libc on Linux systems + if ($IsLinux) { + if ($arch -eq 'x64') { + # Check if musl libc is present + if (Get-Command ldd -ErrorAction SilentlyContinue) { + $lddOutput = ldd --version 2>&1 + if ($lddOutput -match 'musl') { + # Append 'musl-' prefix to architecture + $arch = 'musl-x64' + } + } + } + } + + # Return the final architecture string + return $arch +} diff --git a/src/Private/Logging.ps1 b/src/Private/Logging.ps1 index e2c11f81c..fc044ac51 100644 --- a/src/Private/Logging.ps1 +++ b/src/Private/Logging.ps1 @@ -135,7 +135,7 @@ function ConvertTo-PodeEventViewerLevel { function Get-PodeLoggingInbuiltType { param( [Parameter(Mandatory = $true)] - [ValidateSet('Errors', 'Requests')] + [ValidateSet('Errors', 'Requests','service')] [string] $Type ) @@ -191,6 +191,34 @@ function Get-PodeLoggingInbuiltType { "StackTrace: $($item.StackTrace)" ) + # join the details and return + return "$($row -join "`n")`n" + } + } + 'service' { + $script = { + param($item, $options) + + # do nothing if the error level isn't present + if (@($options.Levels) -inotcontains $item.Level) { + return + } + + # just return the item if Raw is set + if ($options.Raw) { + return $item + } + + # build the exception details + $row = @( + "Date: $($item.Date.ToString('yyyy-MM-dd HH:mm:ss'))", + "Level: $($item.Level)", + "ThreadId: $($item.ThreadId)", + "Server: $($item.Server)", + "Category: $($item.Category)", + "Message: $($item.Message)" + ) + # join the details and return return "$($row -join "`n")`n" } diff --git a/src/Private/Runspaces.ps1 b/src/Private/Runspaces.ps1 index 7bedb2f33..336e1b821 100644 --- a/src/Private/Runspaces.ps1 +++ b/src/Private/Runspaces.ps1 @@ -40,7 +40,6 @@ function Add-PodeRunspace { param( [Parameter(Mandatory = $true)] - [ValidateSet('Main', 'Signals', 'Schedules', 'Gui', 'Web', 'Smtp', 'Tcp', 'Tasks', 'WebSockets', 'Files', 'Timers')] [string] $Type, diff --git a/src/Private/Server.ps1 b/src/Private/Server.ps1 index 2472c1f91..3ef4ea8e5 100644 --- a/src/Private/Server.ps1 +++ b/src/Private/Server.ps1 @@ -207,6 +207,8 @@ function Start-PodeInternalServer { } } } + # Start Service Monitor + Start-PodeServiceHearthbeat } catch { throw @@ -332,8 +334,8 @@ function Restart-PodeInternalServer { Close-PodeDisposable -Disposable $PodeContext.Tokens.Cancellation $PodeContext.Tokens.Cancellation = [System.Threading.CancellationTokenSource]::new() - Close-PodeDisposable -Disposable $PodeContext.Tokens.Restart - $PodeContext.Tokens.Restart = [System.Threading.CancellationTokenSource]::new() + # Close-PodeDisposable -Disposable $PodeContext.Tokens.Restart + # $PodeContext.Tokens.Restart = [System.Threading.CancellationTokenSource]::new() # reload the configuration $PodeContext.Server.Configuration = Open-PodeConfiguration -Context $PodeContext @@ -344,6 +346,11 @@ function Restart-PodeInternalServer { # restart the server $PodeContext.Metrics.Server.RestartCount++ Start-PodeInternalServer + + # recreate the session tokens + + Close-PodeDisposable -Disposable $PodeContext.Tokens.Restart + $PodeContext.Tokens.Restart = [System.Threading.CancellationTokenSource]::new() } catch { $_ | Write-PodeErrorLog diff --git a/src/Private/Service.ps1 b/src/Private/Service.ps1 new file mode 100644 index 000000000..4f9ea3c3c --- /dev/null +++ b/src/Private/Service.ps1 @@ -0,0 +1,1641 @@ +<# +.SYNOPSIS + Tests if the Pode service is enabled. + +.DESCRIPTION + This function checks if the Pode service is enabled by verifying if the `Service` key exists in the `$PodeContext.Server` hashtable. + +.OUTPUTS + [Bool] - `$true` if the 'Service' key exists, `$false` if it does not. + +.EXAMPLE + Test-PodeServiceEnabled + + Returns `$true` if the Pode service is enabled, otherwise returns `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeServiceEnabled { + + # Check if the 'Service' key exists in the $PodeContext.Server hashtable + return $PodeContext.Server.ContainsKey('Service') +} + +<# +.SYNOPSIS + Starts the Pode Service Heartbeat using a named pipe for communication with a C# service. + +.DESCRIPTION + This function starts a named pipe server in PowerShell that listens for commands from a C# application. It supports two commands: + - 'shutdown': to gracefully stop the Pode server. + - 'restart': to restart the Pode server. + +.PARAMETER None + The function takes no parameters. It retrieves the pipe name from the Pode service context. + +.EXAMPLE + Start-PodeServiceHearthbeat + + This command starts the Pode service monitoring and waits for 'shutdown' or 'restart' commands from the named pipe. + +.NOTES + This is an internal function and may change in future releases of Pode. + + The function uses Pode's context for the service to manage the pipe server. The pipe listens for messages sent from a C# client + and performs actions based on the received message. + + If the pipe receives a 'shutdown' message, the Pode server is stopped. + If the pipe receives a 'restart' message, the Pode server is restarted. + + Global variable example: $global:PodeService=@{DisableTermination=$true;Quiet=$false;Pipename='ssss'} +#> +function Start-PodeServiceHearthbeat { + + # Check if the Pode service is enabled + if (Test-PodeServiceEnabled) { + + # Define the script block for the client receiver, listens for commands via the named pipe + $scriptBlock = { + $serviceState = 'running' + while (!$PodeContext.Tokens.Cancellation.IsCancellationRequested) { + + Write-PodeHost -Message "Initialize Listener Pipe $($PodeContext.Server.Service.PipeName)" -Force + Write-PodeHost -Message "Service State: $serviceState" -Force + Write-PodeHost -Message "Total Uptime: $(Get-PodeServerUptime -Total -Readable -OutputType Verbose -ExcludeMilliseconds)" -Force + if ((Get-PodeServerUptime) -gt 1000) { + Write-PodeHost -Message "Uptime Since Last Restart: $(Get-PodeServerUptime -Readable -OutputType Verbose -ExcludeMilliseconds)" -Force + } + Write-PodeHost -Message "Total Number of Restart: $(Get-PodeServerRestartCount)" -Force + try { + Start-Sleep -Milliseconds 100 + # Create a named pipe server stream + $pipeStream = [System.IO.Pipes.NamedPipeServerStream]::new( + $PodeContext.Server.Service.PipeName, + [System.IO.Pipes.PipeDirection]::InOut, + 1, # Max number of allowed concurrent connections + [System.IO.Pipes.PipeTransmissionMode]::Byte, + [System.IO.Pipes.PipeOptions]::None + ) + + Write-PodeHost -Message "Waiting for connection to the $($PodeContext.Server.Service.PipeName) pipe." -Force + $pipeStream.WaitForConnection() # Wait until a client connects + Write-PodeHost -Message "Connected to the $($PodeContext.Server.Service.PipeName) pipe." -Force + + # Create a StreamReader to read incoming messages from the pipe + $reader = [System.IO.StreamReader]::new($pipeStream) + + # Process incoming messages in a loop as long as the pipe is connected + while ($pipeStream.IsConnected) { + $message = $reader.ReadLine() # Read message from the pipe + if ( $PodeContext.Tokens.Cancellation.IsCancellationRequested) { + return + } + + if ($message) { + Write-PodeHost -Message "Received message: $message" -Force + + switch ($message) { + 'shutdown' { + # Process 'shutdown' message + Write-PodeHost -Message 'Server requested shutdown. Closing Pode ...' -Force + $serviceState = 'stopping' + Write-PodeHost -Message "Service State: $serviceState" -Force + Close-PodeServer # Gracefully stop Pode server + Start-Sleep 1 + Write-PodeHost -Message 'Closing Service Monitoring Heartbeat' -Force + return # Exit the loop + } + + 'restart' { + # Process 'restart' message + Write-PodeHost -Message 'Server requested restart. Restarting Pode ...' -Force + + $serviceState = 'starting' + Write-PodeHost -Message "Service State: $serviceState" -Force + Start-Sleep 1 + Restart-PodeServer # Restart Pode server + Write-PodeHost -Message 'Closing Service Monitoring Heartbeat' -Force + Start-Sleep 1 + return + # Exit the loop + } + + 'suspend' { + # Process 'suspend' message + Write-PodeHost -Message 'Server requested suspend. Suspending Pode ...' -Force + Start-Sleep 5 + $serviceState = 'suspended' + #Suspend-PodeServer # Suspend Pode server + # return # Exit the loop + } + + 'resume' { + # Process 'resume' message + Write-PodeHost -Message 'Server requested resume. Resuming Pode ...' -Force + Start-Sleep 5 + $serviceState = 'running' + #Resume-PodeServer # Resume Pode server + # return # Exit the loop + } + } + + } + break + } + } + catch { + $_ | Write-PodeErrorLog # Log any errors that occur during pipe operation + throw $_ + } + finally { + if ($reader) { + $reader.Dispose() + } + if ( $pipeStream) { + $pipeStream.Dispose() # Always dispose of the pipe stream when done + Write-PodeHost -Message "Disposing Listener Pipe $($PodeContext.Server.Service.PipeName)" -Force + } + } + + } + Write-PodeHost -Message 'Closing Service Monitoring Heartbeat' -Force + } + + # Assign a name to the Pode service + $PodeContext.Server.Service['Name'] = 'Service' + Write-Verbose -Message 'Starting service monitoring' + + # Start the runspace that runs the client receiver script block + $PodeContext.Server.Service['Runspace'] = Add-PodeRunspace -Type 'Service' -ScriptBlock ($scriptBlock) -PassThru + } +} + +<# +.SYNOPSIS + Registers a Pode service as a macOS LaunchAgent/Daemon. + +.DESCRIPTION + The `Register-PodeMacService` function creates a macOS plist file for the Pode service. It sets up the service + to run using `launchctl`, specifying options such as autostart, logging, and the executable path. + +.PARAMETER Name + The name of the Pode service. This is used to identify the service in macOS. + +.PARAMETER Description + A brief description of the service. This is not included in the plist file but can be useful for logging. + +.PARAMETER BinPath + The path to the directory where the PodeMonitor executable is located. + +.PARAMETER SettingsFile + The path to the configuration file (e.g., `srvsettings.json`) that the Pode service will use. + +.PARAMETER User + The user under which the Pode service will run. + +.PARAMETER Start + If specified, the service will be started after registration. + +.PARAMETER Autostart + If specified, the service will automatically start when the system boots. + +.PARAMETER OsArchitecture + Specifies the architecture of the operating system (e.g., `osx-x64` or `osx-arm64`). + +.PARAMETER Agent + A switch to create an Agent instead of a Daemon in MacOS. + +.OUTPUTS + Returns $true if successful. + +.EXAMPLE + Register-PodeMacService -Name 'MyPodeService' -Description 'My Pode service' -BinPath '/path/to/bin' ` + -SettingsFile '/path/to/srvsettings.json' -User 'podeuser' -Start -Autostart -OsArchitecture 'osx-arm64' + + Registers a Pode service on macOS and starts it immediately with autostart enabled. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Register-PodeMacService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [string] + $Description, + + [string] + $BinPath, + + [string] + $SettingsFile, + + [string] + $User, + + [string] + $OsArchitecture, + + [string] + $LogPath, + + [switch] + $Agent + ) + + $nameService = Get-PodeRealServiceName -Name $Name + + # Check if the service is already registered + if ((Test-PodeMacOsServiceIsRegistered $nameService -Agent:$Agent)) { + # Service is already registered. + throw ($PodeLocale.serviceAlreadyRegisteredException -f $nameService) + } + + # Determine whether the service should run at load + $runAtLoad = if ($Autostart.IsPresent) { '' } else { '' } + + + # Create a temporary file + $tempFile = [System.IO.Path]::GetTempFileName() + + # Create the plist content + @" + + + + + Label + $nameService + + ProgramArguments + + $BinPath/$OsArchitecture/PodeMonitor + $SettingsFile + + + WorkingDirectory + $BinPath + + RunAtLoad + $runAtLoad + + StandardOutPath + $LogPath/$nameService.stdout.log + + StandardErrorPath + $LogPath/$nameService.stderr.log + + KeepAlive + + SuccessfulExit + + + + + + +"@ | Set-Content -Path $tempFile -Encoding UTF8 + + Write-Verbose -Message "Service '$nameService' WorkingDirectory : $($BinPath)." + try { + if ($Agent) { + $plistPath = "$($HOME)/Library/LaunchAgents/$($nameService).plist" + Copy-Item -Path $tempFile -Destination $plistPath + #set rw r r permissions + chmod 644 $plistPath + # Load the plist with launchctl + launchctl load $plistPath + } + else { + $plistPath = "/Library/LaunchDaemons/$($nameService).plist" + & sudo cp $tempFile $plistPath + #set rw r r permissions + & sudo chmod 644 $plistPath + + & sudo chown root:wheel $plistPath + + # Load the plist with launchctl + & sudo launchctl load $plistPath + + } + + # Verify the service is now registered + if (! (Test-PodeMacOsServiceIsRegistered $nameService -Agent:$Agent)) { + # Service registration failed. + throw ($PodeLocale.serviceRegistrationException -f $nameService) + } + } + catch { + $_ | Write-PodeErrorLog + throw $_ # Rethrow the error after logging + } + + return $true +} + + +<# +.SYNOPSIS + Registers a new systemd service on a Linux system to run a Pode-based PowerShell worker. + +.DESCRIPTION + The `Register-PodeLinuxService` function configures and registers a new systemd service on a Linux system. + It sets up the service with the specified parameters, generates the service definition file, enables the service, + and optionally starts it. It can also create the necessary user if it does not exist. + +.PARAMETER Name + The name of the systemd service to be registered. + +.PARAMETER Description + A brief description of the service. Defaults to an empty string. + +.PARAMETER BinPath + The path to the directory containing the `PodeMonitor` executable. + +.PARAMETER SettingsFile + The path to the settings file for the Pode worker. + +.PARAMETER User + The name of the user under which the service will run. If the user does not exist, it will be created unless the `SkipUserCreation` switch is used. + +.PARAMETER Group + The group under which the service will run. Defaults to the same as the `User` parameter. + +.PARAMETER OsArchitecture + The architecture of the operating system (e.g., `x64`, `arm64`). Used to locate the appropriate binary. + +.OUTPUTS + Returns $true if successful. + +.EXAMPLE + Register-PodeLinuxService -Name "PodeExampleService" -Description "An example Pode service" ` + -BinPath "/usr/local/bin" -SettingsFile "/etc/pode/example-settings.json" ` + -User "podeuser" -Group "podegroup" -Start -OsArchitecture "x64" + + Registers a new systemd service named "PodeExampleService", creates the necessary user and group, + generates the service file, enables the service, and starts it. + +.EXAMPLE + Register-PodeLinuxService -Name "PodeExampleService" -BinPath "/usr/local/bin" ` + -SettingsFile "/etc/pode/example-settings.json" -User "podeuser" -SkipUserCreation ` + -OsArchitecture "arm64" + + Registers a new systemd service without creating the user, and does not start the service immediately. + +.NOTES + - This function assumes systemd is the init system on the Linux machine. + - The function will check if the service is already registered and will throw an error if it is. + - If the user specified by the `User` parameter does not exist, the function will create it unless the `SkipUserCreation` switch is used. + - This is an internal function and may change in future releases of Pode. +#> +function Register-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [string] + $Description, + + [string] + $BinPath, + + [string] + $SettingsFile, + + [string] + $User, + + [string] + $Group, + + [switch] + $Start, + + [string] + $OsArchitecture + ) + $nameService = Get-PodeRealServiceName -Name $Name + $null = systemctl status $nameService 2>&1 + + # Check if the service is already registered + if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 3) { + # Service is already registered. + throw ($PodeLocale.serviceAlreadyRegisteredException -f $nameService ) + } + # Create a temporary file + $tempFile = [System.IO.Path]::GetTempFileName() + + $execStart = "$BinPath/$OsArchitecture/PodeMonitor `"$SettingsFile`"" + # Create the service file + @" +[Unit] +Description=$Description +After=network.target + +[Service] +ExecStart=$execStart +WorkingDirectory=$BinPath +Restart=always +User=$User +KillMode=process +Environment=NOTIFY_SOCKET=/run/systemd/notify +Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 +# Uncomment and adjust if needed +# Group=$Group +# Environment=ASPNETCORE_ENVIRONMENT=Production + +[Install] +WantedBy=multi-user.target +"@ | Set-Content -Path $tempFile -Encoding UTF8 + + Write-Verbose -Message "Service '$nameService' ExecStart : $execStart)." + + & sudo cp $tempFile "/etc/systemd/system/$nameService" + + Remove-Item -path $tempFile -ErrorAction SilentlyContinue + + # Enable the service and check if it fails + try { + if (!(Enable-PodeLinuxService -Name $nameService)) { + # Service registration failed. + throw ($PodeLocale.serviceRegistrationException -f $nameService) + } + } + catch { + $_ | Write-PodeErrorLog + throw $_ # Rethrow the error after logging + return $false + } + + return $true +} + +<# +.SYNOPSIS + Registers a new Windows service to run a Pode-based PowerShell worker. + +.DESCRIPTION + The `Register-PodeMonitorWindowsService` function configures and registers a new Windows service to run a Pode-based PowerShell worker. + It sets up the service with the specified parameters, including paths to the Pode monitor executable, configuration file, + credentials, and security descriptor. The service can be optionally started immediately after registration. + +.PARAMETER Name + The name of the Windows service to be registered. + +.PARAMETER Description + A brief description of the service. Defaults to an empty string. + +.PARAMETER DisplayName + The display name of the service, as it will appear in the Windows Services Manager. + +.PARAMETER StartupType + Specifies how the service is started. Options are: 'Automatic', 'Manual', or 'Disabled'. Defaults to 'Automatic'. + +.PARAMETER BinPath + The path to the directory containing the `PodeMonitor` executable. + +.PARAMETER SettingsFile + The path to the configuration file for the Pode worker. + +.PARAMETER Credential + A `PSCredential` object specifying the credentials for the account under which the service will run. + +.PARAMETER SecurityDescriptorSddl + An SDDL string (Security Descriptor Definition Language) used to define the security of the service. + +.PARAMETER OsArchitecture + The architecture of the operating system (e.g., `x64`, `arm64`). Used to locate the appropriate binary. + +.OUTPUTS + Returns $true if successful. + +.EXAMPLE + Register-PodeMonitorWindowsService -Name "PodeExampleService" -DisplayName "Pode Example Service" ` + -BinPath "C:\Pode" -SettingsFile "C:\Pode\settings.json" ` + -StartupType "Automatic" -Credential (Get-Credential) -Start -OsArchitecture "x64" + + Registers a new Windows service named "PodeExampleService", creates the service with credentials, + generates the service, and starts it. + +.EXAMPLE + Register-PodeMonitorWindowsService -Name "PodeExampleService" -BinPath "C:\Pode" ` + -SettingsFile "C:\Pode\settings.json" -OsArchitecture "x64" + + Registers a new Windows service without credentials or immediate startup. + +.NOTES + - This function assumes the service binary exists at the specified `BinPath`. + - It checks if the service already exists and throws an error if it does. + - This is an internal function and may change in future releases of Pode. +#> + +function Register-PodeMonitorWindowsService { + param( + [string] + $Name, + + [string] + $Description, + + [string] + $DisplayName, + + [string] + $StartupType, + + [string] + $BinPath, + + [string] + $SettingsFile, + + [pscredential] + $Credential, + + [string] + $SecurityDescriptorSddl, + + [string] + $OsArchitecture + ) + + + # Check if service already exists + if (Get-Service -Name $Name -ErrorAction SilentlyContinue) { + # Service is already registered. + throw ($PodeLocale.serviceAlreadyRegisteredException -f "$Name") + + } + + # Parameters for New-Service + $params = @{ + Name = $Name + BinaryPathName = "`"$BinPath\$OsArchitecture\PodeMonitor.exe`" `"$SettingsFile`"" + DisplayName = $DisplayName + StartupType = $StartupType + Description = $Description + #DependsOn = 'NetLogon' + } + if ($SecurityDescriptorSddl) { + $params['SecurityDescriptorSddl'] = $SecurityDescriptorSddl + } + Write-Verbose -Message "Service '$Name' BinaryPathName : $($params['BinaryPathName'])." + + try { + $paramsString = $params.GetEnumerator() | ForEach-Object { "-$($_.Key) '$($_.Value)'" } + + $sv = Invoke-PodeWinElevatedCommand -Command 'New-Service' -Arguments ($paramsString -join ' ') -Credential $Credential + + if (!$sv) { + # Service registration failed. + throw ($PodeLocale.serviceRegistrationException -f "$Name") + } + } + catch { + $_ | Write-PodeErrorLog + throw $_ # Rethrow the error after logging + } + + return $true +} + + + + + +function Test-PodeUserServiceCreationPrivilege { + # Get the list of user privileges + $privileges = whoami /priv | Where-Object { $_ -match 'SeCreateServicePrivilege' } + + if ($privileges) { + return $true + } + else { + return $false + } +} + +<# +.SYNOPSIS + Confirms if the current user has the necessary privileges to run the script. + +.DESCRIPTION + This function checks if the user has administrative privileges on Windows or root/sudo privileges on Linux/macOS. + If the user does not have the required privileges, the script will output an appropriate message and exit. + +.PARAMETER None + This function does not accept any parameters. + +.EXAMPLE + Confirm-PodeAdminPrivilege + + This will check if the user has the necessary privileges to run the script. If not, it will output an error message and exit. + +.OUTPUTS + Exits the script if the necessary privileges are not available. + +.NOTES + This function works across Windows, Linux, and macOS, and checks for either administrative/root/sudo privileges or specific service-related permissions. +#> + +function Confirm-PodeAdminPrivilege { + # Check for administrative privileges + if (! (Test-PodeAdminPrivilege -Elevate)) { + if (Test-PodeIsWindows -and (Test-PodeUserServiceCreationPrivilege)) { + Write-PodeHost "Insufficient privileges. This script requires Administrator access or the 'SERVICE_CHANGE_CONFIG' (SeCreateServicePrivilege) permission to continue." -ForegroundColor Red + exit + } + + # Message for non-Windows (Linux/macOS) + Write-PodeHost "Insufficient privileges. This script must be run as root or with 'sudo' permissions to continue." -ForegroundColor Red + exit + } +} + +<# +.SYNOPSIS + Tests if a Linux service is registered. + +.DESCRIPTION + Checks if a specified Linux service is registered by using the `systemctl status` command. + It returns `$true` if the service is found or its status code matches either `0` or `3`. + +.PARAMETER Name + The name of the Linux service to test. + +.OUTPUTS + [bool] + Returns `$true` if the service is registered; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeLinuxServiceIsRegistered { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + $nameService = Get-PodeRealServiceName -Name $Name + $systemctlStatus = systemctl status $nameService 2>&1 + $isRegistered = ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 3) + Write-Verbose -Message ($systemctlStatus -join '`n') + return $isRegistered +} + +<# +.SYNOPSIS + Tests if a Linux service is active. + +.DESCRIPTION + Checks if a specified Linux service is currently active by using the `systemctl is-active` command. + It returns `$true` if the service is active. + +.PARAMETER Name + The name of the Linux service to check. + +.OUTPUTS + [bool] + Returns `$true` if the service is active; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeLinuxServiceIsActive { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $systemctlIsActive = systemctl is-active $nameService 2>&1 + $isActive = $systemctlIsActive -eq 'active' + Write-Verbose -Message ($systemctlIsActive -join '`n') + return $isActive +} + +<# +.SYNOPSIS + Disables a Linux service. + +.DESCRIPTION + Disables a specified Linux service by using the `sudo systemctl disable` command. + It returns `$true` if the service is successfully disabled. + +.PARAMETER Name + The name of the Linux service to disable. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully disabled; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Disable-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $systemctlDisable = & sudo systemctl disable $nameService 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($systemctlDisable -join '`n') + return $success +} + +<# +.SYNOPSIS + Enables a Linux service. + +.DESCRIPTION + Enables a specified Linux service by using the `sudo systemctl enable` command. + It returns `$true` if the service is successfully enabled. + +.PARAMETER Name + The name of the Linux service to enable. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully enabled; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Enable-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $systemctlEnable = & sudo systemctl enable $Name 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($systemctlEnable -join '`n') + return $success +} + +<# +.SYNOPSIS + Stops a Linux service. + +.DESCRIPTION + Stops a specified Linux service by using the `systemctl stop` command. + It returns `$true` if the service is successfully stopped. + +.PARAMETER Name + The name of the Linux service to stop. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully stopped; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Stop-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + #return (Send-PodeServiceSignal -Name $Name -Signal SIGTERM) + $serviceStopInfo = & sudo systemctl stop $nameService 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($serviceStopInfo -join "`n") + return $success +} + +<# +.SYNOPSIS + Starts a Linux service. + +.DESCRIPTION + Starts a specified Linux service by using the `systemctl start` command. + It returns `$true` if the service is successfully started. + +.PARAMETER Name + The name of the Linux service to start. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully started; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Start-PodeLinuxService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $serviceStartInfo = & sudo systemctl start $nameService 2>&1 + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($serviceStartInfo -join "`n") + return $success +} + +<# +.SYNOPSIS + Tests if a macOS service is registered. + +.DESCRIPTION + Checks if a specified macOS service is registered by using the `launchctl list` command. + It returns `$true` if the service is registered. + +.PARAMETER Name + The name of the macOS service to test. + +.PARAMETER Agent + Return only Agent type services. + +.OUTPUTS + [bool] + Returns `$true` if the service is registered; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeMacOsServiceIsRegistered { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + $nameService = Get-PodeRealServiceName -Name $Name + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $systemctlStatus = & sudo launchctl list $nameService 2>&1 + } + else { + $systemctlStatus = & launchctl list $nameService 2>&1 + } + $isRegistered = ($LASTEXITCODE -eq 0) + Write-Verbose -Message ($systemctlStatus -join '`n') + return $isRegistered +} + +<# +.SYNOPSIS + Checks if a Pode service is registered on the current operating system. + +.DESCRIPTION + This function determines if a Pode service with the specified name is registered, + based on the operating system. It delegates the check to the appropriate + platform-specific function or logic. + +.PARAMETER Name + The name of the Pode service to check. + +.EXAMPLE + Test-PodeServiceIsRegistered -Name 'MyService' + + Checks if the Pode service named 'MyService' is registered. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeServiceIsRegistered { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + if (Test-PodeIsWindows) { + $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$Name'" + return $null -eq $service + } + if ($IsLinux) { + return Test-PodeLinuxServiceIsRegistered -Name $Name + } + if ($IsMacOS) { + return Test-PodeMacOsServiceIsRegistered -Name $Name + } +} + +<# +.SYNOPSIS + Checks if a Pode service is active and running on the current operating system. + +.DESCRIPTION + This function determines if a Pode service with the specified name is active (running), + based on the operating system. It delegates the check to the appropriate platform-specific + function or logic. + +.PARAMETER Name + The name of the Pode service to check. + +.EXAMPLE + Test-PodeServiceIsActive -Name 'MyService' + + Checks if the Pode service named 'MyService' is active and running. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeServiceIsActive { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + if (Test-PodeIsWindows) { + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if ($service) { + # Check if the service is already running + return ($service.Status -ne 'Running') + } + return $false + } + if ($IsLinux) { + return Test-PodeLinuxServiceIsActive -Name $Name + } + if ($IsMacOS) { + return Test-PodeMacOsServiceIsActive -Name $Name + } + +} + + +<# +.SYNOPSIS + Tests if a macOS service is active. + +.DESCRIPTION + Checks if a specified macOS service is currently active by looking for the "PID" value in the output of `launchctl list`. + It returns `$true` if the service is active (i.e., if a PID is found). + +.PARAMETER Name + The name of the macOS service to check. + +.OUTPUTS + [bool] + Returns `$true` if the service is active; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Test-PodeMacOsServiceIsActive { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + if ($sudo) { + $serviceInfo = & sudo launchctl list $nameService + } + else { + $serviceInfo = & launchctl list $nameService + } + $isActive = $serviceInfo -match '"PID" = (\d+);' + Write-Verbose -Message ($serviceInfo -join "`n") + return $isActive.Count -eq 1 +} + +<# +.SYNOPSIS + Retrieves the PID of a macOS service. + +.DESCRIPTION + Retrieves the process ID (PID) of a specified macOS service by using `launchctl list`. + If the service is not active or a PID cannot be found, the function returns `0`. + +PARAMETER Name + The name of the macOS service whose PID you want to retrieve. + +.OUTPUTS + [int] + Returns the PID of the service if it is active; otherwise, returns `0`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Get-PodeMacOsServicePid { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $nameService = Get-PodeRealServiceName -Name $Name + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + if ($sudo) { + $serviceInfo = & sudo launchctl list $nameService + } + else { + $serviceInfo = & launchctl list $nameService + } + $pidString = $serviceInfo -match '"PID" = (\d+);' + Write-Verbose -Message ($serviceInfo -join "`n") + return $(if ($pidString.Count -eq 1) { ($pidString[0].split('= '))[1].trim(';') } else { 0 }) +} + +<# +.SYNOPSIS + Disables a macOS service. + +.DESCRIPTION + Disables a specified macOS service by using `launchctl unload` to unload the service's plist file. + It returns `$true` if the service is successfully disabled. + +.PARAMETER Name + The name of the macOS service to disable. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully disabled; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Disable-PodeMacOsService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + # Standardize service naming for Linux/macOS + $nameService = Get-PodeRealServiceName -Name $Name + + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $systemctlDisable = & sudo launchctl unload "/Library/LaunchDaemons/$nameService.plist" 2>&1 + } + else { + $systemctlDisable = & launchctl unload "$HOME/Library/LaunchAgents/$nameService.plist" 2>&1 + } + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($systemctlDisable -join '`n') + return $success +} + +<# +.SYNOPSIS + Stops a macOS service. + +.DESCRIPTION + Stops a specified macOS service by using the `launchctl stop` command. + It returns `$true` if the service is successfully stopped. + +.PARAMETER Name + The name of the macOS service to stop. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully stopped; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Stop-PodeMacOsService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + + return (Send-PodeServiceSignal -Name $Name -Signal SIGTERM -Agent:$Agent) +} + +<# +.SYNOPSIS + Starts a macOS service. + +.DESCRIPTION + Starts a specified macOS service by using the `launchctl start` command. + It returns `$true` if the service is successfully started. + +.PARAMETER Name + The name of the macOS service to start. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. + +.OUTPUTS + [bool] + Returns `$true` if the service is successfully started; otherwise, `$false`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Start-PodeMacOsService { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + $nameService = Get-PodeRealServiceName -Name $Name + + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $serviceStartInfo = & sudo launchctl start $nameService 2>&1 + } + else { + $serviceStartInfo = & launchctl start $nameService 2>&1 + } + $success = $LASTEXITCODE -eq 0 + Write-Verbose -Message ($serviceStartInfo -join "`n") + return $success +} + +<# +.SYNOPSIS + Sends a specified signal to a Pode service on Linux or macOS. + +.DESCRIPTION + The `Send-PodeServiceSignal` function sends a Unix signal (`SIGTSTP`, `SIGCONT`, `SIGHUP`, or `SIGTERM`) to a specified Pode service. It checks if the service is registered and active before sending the signal. The function supports both standard and elevated privilege operations based on the service's configuration. + +.PARAMETER Name + The name of the Pode service to signal. + +.PARAMETER Signal + The Unix signal to send to the service. Supported signals are: + - `SIGTSTP`: Stop the service temporarily (20). + - `SIGCONT`: Continue the service (18). + - `SIGHUP`: Restart the service (1). + - `SIGTERM`: Terminate the service gracefully (15). + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + [bool] Returns `$true` if the signal was successfully sent, otherwise `$false`. + +.EXAMPLE + Send-PodeServiceSignal -Name "MyPodeService" -Signal "SIGHUP" + + Sends the `SIGHUP` signal to the Pode service named "MyPodeService", instructing it to restart. + +.EXAMPLE + Send-PodeServiceSignal -Name "AnotherService" -Signal "SIGTERM" + + Sends the `SIGTERM` signal to gracefully stop the Pode service named "AnotherService". + +.NOTES + - This function is intended for use on Linux and macOS only. + - Requires administrative/root privileges to send signals to services running with elevated privileges. + - Logs verbose output for troubleshooting. + - This is an internal function and may change in future releases of Pode. +#> +function Send-PodeServiceSignal { + [CmdletBinding()] + [OutputType([bool])] + param( + # The name of the Pode service to signal + [Parameter(Mandatory = $true)] + [string] + $Name, + + # The Unix signal to send to the service + [Parameter(Mandatory = $true)] + [ValidateSet('SIGTSTP', 'SIGCONT', 'SIGHUP', 'SIGTERM')] + [string] + $Signal, + + [switch] + $Agent + ) + + # Standardize service naming for Linux/macOS + $nameService = Get-PodeRealServiceName -Name $Name + + # Map signal names to their corresponding Unix signal numbers + $signalMap = @{ + 'SIGTSTP' = 20 # Stop the process + 'SIGCONT' = 18 # Resume the process + 'SIGHUP' = 1 # Restart the process + 'SIGTERM' = 15 # Gracefully terminate the process + } + + # Retrieve the signal number from the map + $level = $signalMap[$Signal] + + # Check if the service is registered + if ((Test-PodeServiceIsRegistered -Name $nameService)) { + # Check if the service is currently active + if ((Test-PodeServiceIsActive -Name $nameService)) { + Write-Verbose -Message "Service '$Name' is active. Sending $Signal signal." + + # Retrieve service details, including the PID and privilege requirement + $svc = Get-PodeService -Name $Name -Agent:$Agent + + # Send the signal based on the privilege level + if ($svc.Sudo) { + & sudo /bin/kill -$($level) $svc.Pid + } + else { + & /bin/kill -$($level) $svc.Pid + } + + # Check the exit code to determine if the signal was sent successfully + $success = $LASTEXITCODE -eq 0 + if ($success) { + Write-Verbose -Message "$Signal signal sent to service '$Name'." + } + return $success + } + else { + Write-Verbose -Message "Service '$Name' is not running." + } + } + else { + # Throw an exception if the service is not registered + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + # Return false if the signal could not be sent + return $false +} + +<# +.SYNOPSIS + Waits for a Pode service to reach a specified status within a defined timeout period. + +.DESCRIPTION + The `Wait-PodeServiceStatus` function continuously checks the status of a specified Pode service and waits for it to reach the desired status (`Running`, `Stopped`, or `Suspended`). If the service does not reach the desired status within the timeout period, the function returns `$false`. + +.PARAMETER Name + The name of the Pode service to monitor. + +.PARAMETER Status + The desired status to wait for. Valid values are: + - `Running` + - `Stopped` + - `Suspended` + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the desired status. Defaults to 10 seconds. + +.EXAMPLE + Wait-PodeServiceStatus -Name "MyPodeService" -Status "Running" -Timeout 15 + + Waits up to 15 seconds for the Pode service named "MyPodeService" to reach the `Running` status. + +.EXAMPLE + Wait-PodeServiceStatus -Name "AnotherService" -Status "Stopped" + + Waits up to 10 seconds (default timeout) for the Pode service named "AnotherService" to reach the `Stopped` status. + +.OUTPUTS + [bool] Returns `$true` if the service reaches the desired status within the timeout period, otherwise `$false`. + +.NOTES + - The function checks the service status every second until the desired status is reached or the timeout period expires. + - If the service does not reach the desired status within the timeout period, the function returns `$false`. + - This is an internal function and may change in future releases of Pode. +#> +function Wait-PodeServiceStatus { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true)] + [ValidateSet('Running', 'Stopped', 'Suspended')] + [string] + $Status, + + [Parameter(Mandatory = $false)] + [int] + $Timeout = 10 + ) + + # Record the start time for timeout tracking + $startTime = Get-Date + Write-Verbose "Waiting for service '$Name' to reach status '$Status' with a timeout of $Timeout seconds." + + # Begin an infinite loop to monitor the service status + while ($true) { + # Retrieve the current status of the specified Pode service + $currentStatus = Get-PodeServiceStatus -Name $Name + + # Check if the service has reached the desired status + if ($currentStatus.Status -eq $Status) { + Write-Verbose "Service '$Name' has reached the desired status '$Status'." + return $true + } + + # Check if the timeout period has been exceeded + if ((Get-Date) -gt $startTime.AddSeconds($Timeout)) { + Write-Verbose "Timeout reached. Service '$Name' did not reach the desired status '$Status'." + return $false + } + + # Pause execution for 1 second before checking again + Start-Sleep -Seconds 1 + } +} + +<# +.SYNOPSIS + Retrieves the status of a Pode service on Windows, Linux, and macOS. + +.DESCRIPTION + The `Get-PodeServiceStatus` function provides detailed information about the status of a Pode service. + It queries the service's current state, process ID (PID), and whether elevated privileges (Sudo) are required, + adapting its behavior to the platform it runs on: + + - **Windows**: Retrieves service information using the `Win32_Service` class and maps common states to Pode-specific ones. + - **Linux**: Uses `systemctl` to determine the service status and reads additional state information from custom Pode state files if available. + - **macOS**: Checks service status via `launchctl` and processes custom Pode state files when applicable. + +.PARAMETER Name + Specifies the name of the Pode service to query. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Get-PodeServiceStatus -Name "MyPodeService" + Retrieves the status of the Pode service named "MyPodeService". + +.EXAMPLE + Get-PodeServiceStatus -Name "MyPodeService" -Agent + Retrieves the status of the agent-type Pode service named "MyPodeService" (macOS only). + +.OUTPUTS + [PSCustomObject] The function returns a custom object with the following properties: + - **Name**: The name of the service. + - **Status**: The current status of the service (e.g., Running, Stopped, Suspended). + - **Pid**: The process ID of the service. + - **Sudo**: A boolean indicating whether elevated privileges are required. + - **PathName**: The path to the service's configuration or executable. + - **Type**: The type of the service (e.g., Service, Daemon, Agent). + +.NOTES + - **Supported Status States**: Running, Stopped, Suspended, Starting, Stopping, Pausing, Resuming, Unknown. + - Requires administrative/root privileges for accessing service information on Linux and macOS. + - **Platform-specific Behaviors**: + - **Windows**: Leverages CIM to query service information and map states. + - **Linux**: Relies on `systemctl` and custom Pode state files for service details. + - **macOS**: Uses `launchctl` and Pode state files to assess service status. + - If the specified service is not found, the function returns `$null`. + - Logs errors and warnings to assist in troubleshooting. + - This function is internal to Pode and subject to changes in future releases. +#> +function Get-PodeServiceStatus { + [CmdletBinding()] + [OutputType([hashtable])] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + + + if (Test-PodeIsWindows) { + # Check if the service exists on Windows + $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$Name'" + + if ($service) { + switch ($service.State) { + 'Running' { $status = 'Running' } + 'Stopped' { $status = 'Stopped' } + 'Paused' { $status = 'Suspended' } + 'StartPending' { $status = 'Starting' } + 'StopPending' { $status = 'Stopping' } + 'PausePending' { $status = 'Pausing' } + 'ContinuePending' { $status = 'Resuming' } + default { $status = 'Unknown' } + } + return [PSCustomObject]@{ + PsTypeName = 'PodeService' + Name = $Name + Status = $status + Pid = $service.ProcessId + Sudo = $true + PathName = $service.PathName + Type = 'Service' + } + + } + else { + Write-Verbose -Message "Service '$Name' not found." + return $null + } + } + + elseif ($IsLinux) { + try { + $nameService = Get-PodeRealServiceName -Name $Name + # Check if the service exists on Linux (systemd) + if ((Test-PodeLinuxServiceIsRegistered -Name $nameService)) { + $servicePid = 0 + $status = $(systemctl show -p ActiveState $nameService | awk -F'=' '{print $2}') + + switch ($status) { + 'active' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $stateFilePath = "/var/run/podemonitor/$servicePid.state" + if (Test-Path -Path $stateFilePath) { + $status = Get-Content -Path $stateFilePath -Raw + $status = $status.Substring(0, 1).ToUpper() + $status.Substring(1) + } + } + 'reloading' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $status = 'Running' + } + 'maintenance' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $status = 'Suspended' + } + 'inactive' { + $status = 'Stopped' + } + 'failed' { + $status = 'Stopped' + } + 'activating' { + $servicePid = $(systemctl show -p MainPID $nameService | awk -F'=' '{print $2}') + $status = 'Starting' + } + 'deactivating' { + $status = 'Stopping' + } + default { + $status = 'Stopped' + } + } + return [PSCustomObject]@{ + PsTypeName = 'PodeService' + Name = $Name + Status = $status + Pid = $servicePid + Sudo = $true + PathName = "/etc/systemd/system/$nameService" + Type = 'Service' + } + } + else { + Write-Verbose -Message "Service '$nameService' not found." + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $null + } + } + + elseif ($IsMacOS) { + try { + $nameService = Get-PodeRealServiceName -Name $Name + # Check if the service exists on macOS (launchctl) + if ((Test-PodeMacOsServiceIsRegistered $nameService -Agent:$Agent)) { + $servicePid = Get-PodeMacOsServicePid -Name $nameService # Extract the PID from the match + + if ($Agent) { + $sudo = $false + } + else { + $sudo = !(Test-Path -Path "$($HOME)/Library/LaunchAgents/$nameService.plist" -PathType Leaf) + } + + if ($sudo) { + $stateFilePath = "/Library/LaunchDaemons/PodeMonitor/$servicePid.state" + $plistPath = "/Library/LaunchDaemons/$($nameService).plist" + $serviceType = 'Daemon' + } + else { + $stateFilePath = "$($HOME)/Library/LaunchAgents/PodeMonitor/$servicePid.state" + $plistPath = "$($HOME)/Library/LaunchAgents/$($nameService).plist" + $serviceType = 'Agent' + } + + if (Test-Path -Path $stateFilePath) { + $status = Get-Content -Path $stateFilePath -Raw + $status = $status.Substring(0, 1).ToUpper() + $status.Substring(1) + } + else { + $status = 'Stopped' + } + + return [PSCustomObject]@{ + PsTypeName = 'PodeService' + Name = $Name + Status = $status + Pid = $servicePid + Sudo = $sudo + PathName = $plistPath + Type = $serviceType + } + } + else { + Write-Verbose -Message "Service '$Name' not found." + return $null + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $null + } + } + +} + +<# +.SYNOPSIS + Returns the standardized service name for a Pode service based on the current platform. + +.DESCRIPTION + The `Get-PodeRealServiceName` function formats a Pode service name to match platform-specific conventions: + - On macOS, the service name is prefixed with `pode.` and suffixed with `.service`, with spaces replaced by underscores. + - On Linux, the service name is suffixed with `.service`, with spaces replaced by underscores. + - On Windows, the service name is returned as provided. + +.PARAMETER Name + The name of the Pode service to standardize. + +.EXAMPLE + Get-PodeRealServiceName -Name "My Pode Service" + + For macOS, returns: `pode.My_Pode_Service.service`. + For Linux, returns: `My_Pode_Service.service`. + For Windows, returns: `My Pode Service`. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Get-PodeRealServiceName { + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + # If the name already ends with '.service', return it directly + if ($Name -like '*.service') { + return $Name + } + + # Standardize service naming based on platform + if ($IsMacOS) { + return "pode.$Name.service".Replace(' ', '_') + } + elseif ($IsLinux) { + return "$Name.service".Replace(' ', '_') + } + else { + # Assume Windows or unknown platform + return $Name + } +} diff --git a/src/Public/Core.ps1 b/src/Public/Core.ps1 index 3d609f763..5479b4e78 100644 --- a/src/Public/Core.ps1 +++ b/src/Public/Core.ps1 @@ -150,6 +150,24 @@ function Start-PodeServer { $PodeContext = $null $ShowDoneMessage = $true + # check if podeWatchdog is configured + if ($PodeService) { + if ($null -ne $PodeService.DisableTermination -or + $null -ne $PodeService.Quiet -or + $null -ne $PodeService.PipeName + ) { + $DisableTermination = [switch]$PodeService.DisableTermination + $Quiet = [switch]$PodeService.Quiet + + $monitorService = @{ + DisableTermination = $PodeService.DisableTermination + Quiet = $PodeService.Quiet + PipeName = $PodeService.PipeName + } + write-podehost $PodeService -Explode -Force + } + } + try { # if we have a filepath, resolve it - and extract a root path from it if ($PSCmdlet.ParameterSetName -ieq 'file') { @@ -171,20 +189,23 @@ function Start-PodeServer { $RootPath = Get-PodeRelativePath -Path $RootPath -RootPath $MyInvocation.PSScriptRoot -JoinRoot -Resolve -TestPath } + $params = @{ + ScriptBlock = $ScriptBlock + FilePath = $FilePath + Threads = $Threads + Interval = $Interval + ServerRoot = $(Protect-PodeValue -Value $RootPath -Default $MyInvocation.PSScriptRoot) + ServerlessType = $ServerlessType + ListenerType = $ListenerType + EnablePool = $EnablePool + StatusPageExceptions = $StatusPageExceptions + DisableTermination = $DisableTermination + Quiet = $Quiet + EnableBreakpoints = $EnableBreakpoints + Service = $monitorService + } # create main context object - $PodeContext = New-PodeContext ` - -ScriptBlock $ScriptBlock ` - -FilePath $FilePath ` - -Threads $Threads ` - -Interval $Interval ` - -ServerRoot (Protect-PodeValue -Value $RootPath -Default $MyInvocation.PSScriptRoot) ` - -ServerlessType $ServerlessType ` - -ListenerType $ListenerType ` - -EnablePool $EnablePool ` - -StatusPageExceptions $StatusPageExceptions ` - -DisableTermination:$DisableTermination ` - -Quiet:$Quiet ` - -EnableBreakpoints:$EnableBreakpoints + $PodeContext = New-PodeContext @params # set it so ctrl-c can terminate, unless serverless/iis, or disabled if (!$PodeContext.Server.DisableTermination -and ($null -eq $psISE)) { @@ -1085,7 +1106,7 @@ function Add-PodeEndpoint { $obj.Url = "$($obj.Protocol)://$($obj.FriendlyName):$($obj.Port)/" # if the address is non-local, then check admin privileges - if (!$Force -and !(Test-PodeIPAddressLocal -IP $obj.Address) -and !(Test-PodeIsAdminUser)) { + if (!$Force -and !(Test-PodeIPAddressLocal -IP $obj.Address) -and !(Test-PodeAdminPrivilege -Console)) { # Must be running with administrator privileges to listen on non-localhost addresses throw ($PodeLocale.mustBeRunningWithAdminPrivilegesExceptionMessage) } diff --git a/src/Public/Metrics.ps1 b/src/Public/Metrics.ps1 index 0d47243d4..2d42b13e4 100644 --- a/src/Public/Metrics.ps1 +++ b/src/Public/Metrics.ps1 @@ -1,35 +1,104 @@ <# .SYNOPSIS -Returns the uptime of the server in milliseconds. + Returns the uptime of the server in milliseconds or in a human-readable format. .DESCRIPTION -Returns the uptime of the server in milliseconds. You can optionally return the total uptime regardless of server restarts. + Returns the uptime of the server in milliseconds by default. You can optionally return the total uptime regardless of server restarts or convert the uptime to a human-readable format with selectable output styles (e.g., Verbose, Compact). + Additionally, milliseconds can be excluded from the output if desired. .PARAMETER Total -If supplied, the total uptime of the server will be returned, regardless of restarts. + If supplied, the total uptime of the server will be returned, regardless of restarts. + +.PARAMETER Readable + If supplied, the uptime will be returned in a human-readable format instead of milliseconds. + +.PARAMETER OutputType + Specifies the format for the human-readable output. Valid options are: + - 'Verbose' for detailed descriptions (e.g., "1 day, 2 hours, 3 minutes"). + - 'Compact' for a compact format (e.g., "dd:hh:mm:ss"). + - Default is concise format (e.g., "1d 2h 3m"). + +.PARAMETER ExcludeMilliseconds + If supplied, milliseconds will be excluded from the human-readable output. + +.EXAMPLE + $currentUptime = Get-PodeServerUptime + # Output: 123456789 (milliseconds) + +.EXAMPLE + $totalUptime = Get-PodeServerUptime -Total + # Output: 987654321 (milliseconds) .EXAMPLE -$currentUptime = Get-PodeServerUptime + $readableUptime = Get-PodeServerUptime -Readable + # Output: "1d 10h 17m 36s" .EXAMPLE -$totalUptime = Get-PodeServerUptime -Total + $verboseUptime = Get-PodeServerUptime -Readable -OutputType Verbose + # Output: "1 day, 10 hours, 17 minutes, 36 seconds, 789 milliseconds" + +.EXAMPLE + $compactUptime = Get-PodeServerUptime -Readable -OutputType Compact + # Output: "01:10:17:36" + +.EXAMPLE + $compactUptimeNoMs = Get-PodeServerUptime -Readable -OutputType Compact -ExcludeMilliseconds + # Output: "01:10:17:36" #> function Get-PodeServerUptime { - [CmdletBinding()] - [OutputType([long])] + [CmdletBinding(DefaultParameterSetName = 'Milliseconds')] + [OutputType([long], [string])] param( + # Common to all parameter sets [switch] - $Total + $Total, + + # Default set: Milliseconds output + [Parameter(ParameterSetName = 'Readable')] + [switch] + $Readable, + + # Available only when -Readable is specified + [Parameter(ParameterSetName = 'Readable')] + [ValidateSet("Verbose", "Compact", "Default")] + [string] + $OutputType = "Default", + + # Available only when -Readable is specified + [Parameter(ParameterSetName = 'Readable')] + [switch] + $ExcludeMilliseconds ) + # Determine the appropriate start time $time = $PodeContext.Metrics.Server.StartTime if ($Total) { $time = $PodeContext.Metrics.Server.InitialLoadTime } - return [long]([datetime]::UtcNow - $time).TotalMilliseconds + # Calculate uptime in milliseconds + $uptimeMilliseconds = [long]([datetime]::UtcNow - $time).TotalMilliseconds + + # Handle readable output + if ($PSCmdlet.ParameterSetName -eq 'Readable') { + switch ($OutputType) { + "Verbose" { + return Convert-PodeMillisecondsToReadable -Milliseconds $uptimeMilliseconds -VerboseOutput -ExcludeMilliseconds:$ExcludeMilliseconds + } + "Compact" { + return Convert-PodeMillisecondsToReadable -Milliseconds $uptimeMilliseconds -CompactOutput -ExcludeMilliseconds:$ExcludeMilliseconds + } + "Default" { + return Convert-PodeMillisecondsToReadable -Milliseconds $uptimeMilliseconds -ExcludeMilliseconds:$ExcludeMilliseconds + } + } + } + + # Default to milliseconds if no readable output is requested + return $uptimeMilliseconds } + <# .SYNOPSIS Returns the number of times the server has restarted. diff --git a/src/Public/Service.ps1 b/src/Public/Service.ps1 new file mode 100644 index 000000000..1d04589ba --- /dev/null +++ b/src/Public/Service.ps1 @@ -0,0 +1,1169 @@ +<# +.SYNOPSIS + Registers a new Pode-based PowerShell worker as a service on Windows, Linux, or macOS. + +.DESCRIPTION + The `Register-PodeService` function configures and registers a Pode-based service that runs a PowerShell worker across multiple platforms + (Windows, Linux, macOS). It creates the service with parameters such as paths to the worker script, log files, and service-specific settings. + A `srvsettings.json` configuration file is generated, and the service can be optionally started after registration. + +.PARAMETER Name + Specifies the name of the service to be registered. This is a required parameter. + +.PARAMETER Description + A brief description of the service. Defaults to "This is a Pode service." + +.PARAMETER DisplayName + Specifies the display name for the service (Windows only). Defaults to "Pode Service($Name)". + +.PARAMETER StartupType + Specifies the startup type of the service ('Automatic' or 'Manual'). Defaults to 'Automatic'. + +.PARAMETER ParameterString + Any additional parameters to pass to the worker script when the service is run. Defaults to an empty string. + +.PARAMETER LogServicePodeHost + Enables logging for the Pode service host. If not provided, the service runs in quiet mode. + +.PARAMETER ShutdownWaitTimeMs + Maximum time in milliseconds to wait for the service to shut down gracefully before forcing termination. Defaults to 30,000 milliseconds (30 seconds). + +.PARAMETER StartMaxRetryCount + The maximum number of retries to start the PowerShell process before giving up. Default is 3 retries. + +.PARAMETER StartRetryDelayMs + The delay (in milliseconds) between retry attempts to start the PowerShell process. Default is 5,000 milliseconds (5 seconds). + +.PARAMETER WindowsUser + Specifies the username under which the service will run on Windows. Defaults to the current user if not provided. + +.PARAMETER LinuxUser + Specifies the username under which the service will run on Linux. Defaults to the current user if not provided. + +.PARAMETER Agent + Create an Agent instead of a Daemon on macOS (macOS only). + +.PARAMETER Start + A switch to start the service immediately after registration. + +.PARAMETER Password + A secure password for the service account (Windows only). If omitted, the service account defaults to 'NT AUTHORITY\SYSTEM'. + +.PARAMETER SecurityDescriptorSddl + A security descriptor in SDDL format, specifying the permissions for the service (Windows only). + +.PARAMETER SettingsPath + Specifies the directory to store the service configuration file (`_svcsettings.json`). If not provided, a default directory is used. + +.PARAMETER LogPath + Specifies the path for the service log files. If not provided, a default log directory is used. + +.PARAMETER LogLevel + Specifies the log verbosity level. Valid values are 'Debug', 'Info', 'Warn', 'Error', or 'Critical'. Defaults to 'Info'. + +.PARAMETER LogMaxFileSize + Specifies the maximum size of the log file in bytes. Defaults to 10 MB (10,485,760 bytes). + +.EXAMPLE + Register-PodeService -Name "PodeExampleService" -Description "Example Pode Service" -ParameterString "-Verbose" + + This example registers a Pode service named "PodeExampleService" with verbose logging enabled. + +.NOTES + - Supports cross-platform service registration on Windows, Linux, and macOS. + - Generates a `srvsettings.json` file with service-specific configurations. + - Automatically starts the service using the `-Start` switch after registration. + - Dynamically obtains the PowerShell executable path for compatibility across platforms. +#> +function Register-PodeService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [string] + $Description = 'This is a Pode service.', + + [Parameter()] + [string] + $DisplayName = "Pode Service($Name)", + + [Parameter()] + [string] + [validateset('Manual', 'Automatic')] + $StartupType = 'Automatic', + + [Parameter()] + [string] + $SecurityDescriptorSddl, + + [Parameter()] + [string] + $ParameterString = '', + + [Parameter()] + [switch] + $LogServicePodeHost, + + [Parameter()] + [int] + $ShutdownWaitTimeMs = 30000, + + [Parameter()] + [int] + $StartMaxRetryCount = 3, + + [Parameter()] + [int] + $StartRetryDelayMs = 5000, + + [Parameter()] + [string] + $WindowsUser, + + [Parameter()] + [string] + $LinuxUser, + + [Parameter()] + [switch] + $Start, + + [Parameter()] + [switch] + $Agent, + + [Parameter()] + [securestring] + $Password, + + [Parameter()] + [string] + $SettingsPath, + + [Parameter()] + [string] + $LogPath, + + [Parameter()] + [string] + [validateset('Debug', 'Info', 'Warn', 'Error', 'Critical')] + $LogLevel = 'Info', + + [Parameter()] + [Int64] + $LogMaxFileSize = 10 * 1024 * 1024 + ) + + # Ensure the script is running with the necessary administrative/root privileges. + # Exits the script if the current user lacks the required privileges. + Confirm-PodeAdminPrivilege + + try { + # Obtain the script path and directory + if ($MyInvocation.ScriptName) { + $ScriptPath = $MyInvocation.ScriptName + $MainScriptPath = Split-Path -Path $ScriptPath -Parent + } + else { + return $null + } + # Define log paths and ensure the log directory exists + if (! $LogPath) { + $LogPath = Join-Path -Path $MainScriptPath -ChildPath 'logs' + } + + if (! (Test-Path -Path $LogPath -PathType Container)) { + $null = New-Item -Path $LogPath -ItemType Directory -Force + } + + $LogFilePath = Join-Path -Path $LogPath -ChildPath "$($Name)_svc.log" + + # Dynamically get the PowerShell executable path + $PwshPath = (Get-Process -Id $PID).Path + + # Define configuration directory and settings file path + if (!$SettingsPath) { + $SettingsPath = Join-Path -Path $MainScriptPath -ChildPath 'svc_settings' + } + + if (! (Test-Path -Path $SettingsPath -PathType Container)) { + $null = New-Item -Path $settingsPath -ItemType Directory + } + + if (Test-PodeIsWindows) { + if ([string]::IsNullOrEmpty($WindowsUser)) { + if ( ($null -ne $Password)) { + $UserName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + } + } + else { + $UserName = $WindowsUser + if ( ($null -eq $Password)) { + throw ($Podelocale.passwordRequiredForServiceUserException -f $UserName) + } + } + } + elseif ($IsLinux) { + if ([string]::IsNullOrEmpty($LinuxUser)) { + $UserName = [System.Environment]::UserName + } + else { + $UserName = $LinuxUser + } + } + + $settingsFile = Join-Path -Path $settingsPath -ChildPath "$($Name)_svcsettings.json" + Write-Verbose -Message "Service '$Name' setting : $settingsFile." + + # Generate the service settings JSON file + $jsonContent = @{ + PodeMonitorWorker = @{ + ScriptPath = $ScriptPath + PwshPath = $PwshPath + ParameterString = $ParameterString + LogFilePath = $LogFilePath + Quiet = !$LogServicePodeHost.IsPresent + DisableTermination = $true + ShutdownWaitTimeMs = $ShutdownWaitTimeMs + Name = $Name + StartMaxRetryCount = $StartMaxRetryCount + StartRetryDelayMs = $StartRetryDelayMs + LogLevel = $LogLevel.ToUpper() + LogMaxFileSize = $LogMaxFileSize + } + } + + # Save JSON to the settings file + $jsonContent | ConvertTo-Json | Set-Content -Path $settingsFile -Encoding UTF8 + + # Determine OS architecture and call platform-specific registration functions + $osArchitecture = Get-PodeOSPwshArchitecture + + if ([string]::IsNullOrEmpty($osArchitecture)) { + Write-Verbose 'Unsupported Architecture' + return $false + } + + # Get the directory path where the Pode module is installed and store it in $binPath + $binPath = Join-Path -Path ((Get-Module -Name Pode).ModuleBase) -ChildPath 'Bin' + + if (Test-PodeIsWindows) { + $param = @{ + Name = $Name + Description = $Description + DisplayName = $DisplayName + StartupType = $StartupType + BinPath = $binPath + SettingsFile = $settingsFile + Credential = if ($Password) { [pscredential]::new($UserName, $Password) }else { $null } + SecurityDescriptorSddl = $SecurityDescriptorSddl + OsArchitecture = "win-$osArchitecture" + } + $operation = Register-PodeMonitorWindowsService @param + } + elseif ($IsLinux) { + $param = @{ + Name = $Name + Description = $Description + BinPath = $binPath + SettingsFile = $settingsFile + User = $User + Group = $Group + Start = $Start + OsArchitecture = "linux-$osArchitecture" + } + $operation = Register-PodeLinuxService @param + } + elseif ($IsMacOS) { + $param = @{ + Name = $Name + Description = $Description + BinPath = $binPath + SettingsFile = $settingsFile + User = $User + OsArchitecture = "osx-$osArchitecture" + LogPath = $LogPath + Agent = $Agent + } + + $operation = Register-PodeMacService @param + } + + # Optionally start the service if requested + if ( $operation -and $Start.IsPresent) { + $operation = Start-PodeService -Name $Name + } + + return $operation + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + +<# +.SYNOPSIS + Start a Pode-based service on Windows, Linux, or macOS. + +.DESCRIPTION + The `Start-PodeService` function ensures that a specified Pode-based service is running. If the service is not registered or fails to start, the function throws an error. It supports platform-specific service management commands: + - Windows: Uses `sc.exe`. + - Linux: Uses `systemctl`. + - macOS: Uses `launchctl`. + +.PARAMETER Name + The name of the service to start. + +.PARAMETER Async + Indicates whether to return immediately after issuing the start command. If not specified, the function waits until the service reaches the 'Running' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Running' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Start-PodeService -Name 'MyService' + + Starts the service named 'MyService' if it is not already running. + +.EXAMPLE + Start-PodeService -Name 'MyService' -Async + + Starts the service named 'MyService' and returns immediately. + +.NOTES + - This function checks for necessary administrative/root privileges before execution. + - Service state management behavior: + - If the service is already running, no action is taken. + - If the service is not registered, an error is thrown. + - Service name is retrieved from the `srvsettings.json` file if available. + - Platform-specific commands are invoked to manage service states: + - Windows: `sc.exe start`. + - Linux: `sudo systemctl start`. + - macOS: `sudo launchctl start`. + - Errors and logs are captured for debugging purposes. +#> +function Start-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle the current service state + switch ($service.Status) { + 'Running' { + Write-Verbose -Message "Service '$Name' is already running." + return $true + } + 'Suspended' { + Write-Verbose -Message "Service '$Name' is suspended. Cannot start a suspended service." + return $false + } + 'Stopped' { + Write-Verbose -Message "Service '$Name' is currently stopped. Attempting to start..." + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot start at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Start the service based on the OS + $serviceStarted = $false + if (Test-PodeIsWindows) { + $serviceStarted = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "start '$Name'" + } + elseif ($IsLinux) { + $serviceStarted = Start-PodeLinuxService -Name $Name + } + elseif ($IsMacOS) { + $serviceStarted = Start-PodeMacOsService -Name $Name -Agent:$Agent + } + + # Check if the service start command failed + if (!$serviceStarted) { + throw ($PodeLocale.serviceCommandFailedException -f 'Start', $Name) + } + + # Handle async or wait for start + if ($Async) { + Write-Verbose -Message "Async mode: Service start command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to start (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + +<# +.SYNOPSIS + Stop a Pode-based service on Windows, Linux, or macOS. + +.DESCRIPTION + The `Stop-PodeService` function ensures that a specified Pode-based service is stopped. If the service is not registered or fails to stop, the function throws an error. It supports platform-specific service management commands: + - Windows: Uses `sc.exe`. + - Linux: Uses `systemctl`. + - macOS: Uses `launchctl`. + +.PARAMETER Name + The name of the service to stop. + +.PARAMETER Async + Indicates whether to return immediately after issuing the stop command. If not specified, the function waits until the service reaches the 'Stopped' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Stopped' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Stop-PodeService -Name 'MyService' + + Stops the service named 'MyService' if it is currently running. + +.EXAMPLE + Stop-PodeService -Name 'MyService' -Async + + Stops the service named 'MyService' and returns immediately. + +.NOTES + - This function checks for necessary administrative/root privileges before execution. + - Service state management behavior: + - If the service is not running, no action is taken. + - If the service is not registered, an error is thrown. + - Service name is retrieved from the `srvsettings.json` file if available. + - Platform-specific commands are invoked to manage service states: + - Windows: `sc.exe`. + - Linux: `sudo systemctl stop`. + - macOS: `sudo launchctl stop`. + - Errors and logs are captured for debugging purposes. + +#> +function Stop-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] + $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] + $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle service states + switch ($service.Status) { + 'Stopped' { + Write-Verbose -Message "Service '$Name' is already stopped." + return $true + } + { $_ -eq 'Running' -or $_ -eq 'Suspended' } { + Write-Verbose -Message "Service '$Name' is currently $($service.Status). Attempting to stop..." + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot stop at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Stop the service + $serviceStopped = $false + if (Test-PodeIsWindows) { + $serviceStopped = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "stop '$Name'" + } + elseif ($IsLinux) { + $serviceStopped = Stop-PodeLinuxService -Name $Name + } + elseif ($IsMacOS) { + $serviceStopped = Stop-PodeMacOsService -Name $Name -Agent:$Agent + } + + if (!$serviceStopped) { + throw ($PodeLocale.serviceCommandFailedException -f 'Stop', $Name) + } + + # Handle async or wait for stop + if ($Async) { + Write-Verbose -Message "Async mode: Service stop command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to stop (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Stopped -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + + +<# +.SYNOPSIS + Suspend a specified service on Windows systems. + +.DESCRIPTION + The `Suspend-PodeService` function attempts to suspend a specified service by name. This functionality is supported only on Windows systems using `sc.exe`. On Linux and macOS, the suspend functionality for services is not natively available, and an appropriate error message is returned. + +.PARAMETER Name + The name of the service to suspend. + +.PARAMETER Async + Indicates whether to return immediately after issuing the suspend command. If not specified, the function waits until the service reaches the 'Suspended' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Suspended' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Suspend-PodeService -Name 'MyService' + + Suspends the service named 'MyService' if it is currently running. + +.NOTES + - This function requires administrative/root privileges to execute. + - Platform-specific behavior: + - Windows: Uses `sc.exe` with the `pause` argument. + - Linux: Sends the `SIGTSTP` signal to the service process. + - macOS: Sends the `SIGTSTP` signal to the service process. + - On Linux and macOS, an error is logged if the signal command fails or the functionality is unavailable. + - If the service is already suspended, no action is taken. + - If the service is not registered, an error is thrown. + +#> +function Suspend-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle the current service state + switch ($service.Status) { + 'Suspended' { + Write-Verbose -Message "Service '$Name' is already suspended." + return $true + } + 'Running' { + Write-Verbose -Message "Service '$Name' is currently running. Attempting to suspend..." + } + 'Stopped' { + Write-Verbose -Message "Service '$Name' is stopped. Cannot suspend a stopped service." + return $false + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot suspend at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Suspend the service based on the OS + $serviceSuspended = $false + if (Test-PodeIsWindows) { + $serviceSuspended = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "pause '$Name'" + } + elseif ($IsLinux ) { + $serviceSuspended = ( Send-PodeServiceSignal -Name $Name -Signal 'SIGTSTP') + } + elseif ( $IsMacOS) { + $serviceSuspended = ( Send-PodeServiceSignal -Name $Name -Signal 'SIGTSTP' -Agent:$Agent) + } + + # Check if the service suspend command failed + if (!$serviceSuspended) { + throw ($PodeLocale.serviceCommandFailedException -f 'Suspend', $Name) + } + + # Handle async or wait for suspend + if ($Async) { + Write-Verbose -Message "Async mode: Service suspend command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to suspend (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Suspended -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + + +<# +.SYNOPSIS + Resume a specified service on Windows systems. + +.DESCRIPTION + The `Resume-PodeService` function attempts to resume a specified service by name. This functionality is supported only on Windows systems using `sc.exe`. On Linux and macOS, the resume functionality for services is not natively available, and an appropriate error message is returned. + +.PARAMETER Name + The name of the service to resume. + +.PARAMETER Async + Indicates whether to return immediately after issuing the resume command. If not specified, the function waits until the service reaches the 'Running' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Running' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Resume-PodeService -Name 'MyService' + + Resumes the service named 'MyService' if it is currently paused. + +.NOTES + - This function requires administrative/root privileges to execute. + - Platform-specific behavior: + - Windows: Uses `sc.exe` with the `continue` argument. + - Linux: Sends the `SIGCONT` signal to the service process. + - macOS: Sends the `SIGCONT` signal to the service process. + - On Linux and macOS, an error is logged if the signal command fails or the functionality is unavailable. + - If the service is not paused, no action is taken. + - If the service is not registered, an error is thrown. + +#> +function Resume-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [ValidateRange(1, 300)] + [int] $Timeout = 10, + + [switch] + $Agent + ) + try { + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle the current service state + switch ($service.Status) { + 'Running' { + Write-Verbose -Message "Service '$Name' is already running. No need to resume." + return $true + } + 'Suspended' { + Write-Verbose -Message "Service '$Name' is currently suspended. Attempting to resume..." + } + 'Stopped' { + Write-Verbose -Message "Service '$Name' is stopped. Cannot resume a stopped service." + return $false + } + { $_ -eq 'Starting' -or $_ -eq 'Stopping' -or $_ -eq 'Pausing' -or $_ -eq 'Resuming' } { + Write-Verbose -Message "Service '$Name' is transitioning state ($($service.Status)). Cannot resume at this time." + return $false + } + default { + Write-Verbose -Message "Service '$Name' is in an unknown state ($($service.Status))." + return $false + } + } + + # Resume the service based on the OS + $serviceResumed = $false + if (Test-PodeIsWindows) { + $serviceResumed = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "continue '$Name'" + } + elseif ($IsLinux) { + $serviceResumed = Send-PodeServiceSignal -Name $Name -Signal 'SIGCONT' + } + elseif ($IsMacOS) { + $serviceResumed = Send-PodeServiceSignal -Name $Name -Signal 'SIGCONT' -Agent:$Agent + } + + # Check if the service resume command failed + if (!$serviceResumed) { + throw ($PodeLocale.serviceCommandFailedException -f 'Resume', $Name) + } + + # Handle async or wait for resume + if ($Async) { + Write-Verbose -Message "Async mode: Service resume command issued for '$Name'." + return $true + } + else { + Write-Verbose -Message "Waiting for service '$Name' to resume (timeout: $Timeout seconds)..." + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + catch { + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } +} + +<# +.SYNOPSIS + Unregisters a Pode-based service across different platforms (Windows, Linux, and macOS). + +.DESCRIPTION + The `Unregister-PodeService` function removes a Pode-based service by checking its status and unregistering it from the system. + The function can stop the service forcefully if it is running, and then remove the service from the service manager. + It works on Windows, Linux (systemd), and macOS (launchctl). + +.PARAMETER Force + A switch parameter that forces the service to stop before unregistering. If the service is running and this parameter is not specified, + the function will throw an error. + +.PARAMETER Name + The name of the service. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Unregister-PodeService -Force + + Unregisters the Pode-based service, forcefully stopping it if it is currently running. + +.EXAMPLE + Unregister-PodeService + + Unregisters the Pode-based service if it is not running. If the service is running, the function throws an error unless the `-Force` parameter is used. + +.NOTES + - The function retrieves the service name from the `srvsettings.json` file located in the script directory. + - On Windows, it uses `Get-Service`, `Stop-Service`, and `Remove-Service`. + - On Linux, it uses `systemctl` to stop, disable, and remove the service. + - On macOS, it uses `launchctl` to stop and unload the service. +#> +function Unregister-PodeService { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [switch] + $Force, + + [switch] + $Agent + ) + + # Ensure administrative/root privileges + Confirm-PodeAdminPrivilege + + # Get the service status + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + Write-Verbose -Message "Service '$Name' current state: $($service.Status)." + + # Handle service state + if ($service.Status -ne 'Stopped') { + if ($Force) { + Write-Verbose -Message "Service '$Name' is not stopped. Stopping the service due to -Force parameter." + if (!(Stop-PodeService -Name $Name)) { + Write-Verbose -Message "Service '$Name' is not stopped." + return $false + } + Write-Verbose -Message "Service '$Name' has been stopped." + } + else { + Write-Verbose -Message "Service '$Name' is not stopped. Use -Force to stop and unregister it." + return $false + } + } + + if (Test-PodeIsWindows) { + + # Remove the service + $null = Invoke-PodeWinElevatedCommand -Command 'sc.exe' -Arguments "delete '$Name'" + if (Get-PodeService -Name $Name -ErrorAction SilentlyContinue) { + Write-Verbose -Message "Service '$Name' unregistered failed." + throw ($PodeLocale.serviceUnRegistrationException -f $Name) + } + + Write-Verbose -Message "Service '$Name' unregistered successfully." + + # Remove the service configuration + if ($service.PathName -match '"([^"]+)" "([^"]+)"') { + $argument = $Matches[2] + if ( (Test-Path -Path ($argument) -PathType Leaf)) { + Remove-Item -Path ($argument) -ErrorAction SilentlyContinue + } + } + return $true + + } + + elseif ($IsLinux) { + if (! (Disable-PodeLinuxService -Name $Name)) { + Write-Verbose -Message "Service '$Name' unregistered failed." + throw ($PodeLocale.serviceUnRegistrationException -f $Name) + } + + Write-Verbose -Message "Service '$Name' unregistered successfully." + + # Read the content of the service file + if ((Test-path -path $service.PathName -PathType Leaf)) { + $serviceFileContent = & sudo cat $service.PathName + # Extract the SettingsFile from the ExecStart line using regex + $execStart = ($serviceFileContent | Select-String -Pattern 'ExecStart=.*\s+(.*)').ToString() + # Find the index of '/PodeMonitor ' in the string + $index = $execStart.IndexOf('/PodeMonitor ') + ('/PodeMonitor '.Length) + # Extract everything after '/PodeMonitor ' + $settingsFile = $execStart.Substring($index).trim('"') + + & sudo rm $settingsFile + Write-Verbose -Message "Settings file '$settingsFile' removed." + + & sudo rm $service.PathName + Write-Verbose -Message "Service file '$($service.PathName)' removed." + } + + # Reload systemd to apply changes + & sudo systemctl daemon-reload + Write-Verbose -Message 'Systemd daemon reloaded.' + return $true + } + + elseif ($IsMacOS) { + # Disable and unregister the service + if (!(Disable-PodeMacOsService -Name $Name -Agent:$Agent)) { + Write-Verbose -Message "Service '$Name' unregistered failed." + throw ($PodeLocale.serviceUnRegistrationException -f $Name) + } + + Write-Verbose -Message "Service '$Name' unregistered successfully." + + # Check if the plist file exists + if (Test-Path -Path $service.PathName) { + # Read the content of the plist file + $plistXml = [xml](Get-Content -Path $service.PathName -Raw) + if ($plistXml.plist.dict.array.string.Count -ge 2) { + # Extract the second string in the ProgramArguments array (the settings file path) + $settingsFile = $plistXml.plist.dict.array.string[1] + if ($service.Sudo) { + & sudo rm $settingsFile + Write-Verbose -Message "Settings file '$settingsFile' removed." + + & sudo rm $service.PathName + Write-Verbose -Message "Service file '$($service.PathName)' removed." + } + else { + Remove-Item -Path $settingsFile -ErrorAction SilentlyContinue + Write-Verbose -Message "Settings file '$settingsFile' removed." + + Remove-Item -Path $service.PathName -ErrorAction SilentlyContinue + Write-Verbose -Message "Service file '$($service.PathName)' removed." + } + } + } + return $true + } +} + + +<# +.SYNOPSIS + Retrieves the status of a Pode service across different platforms (Windows, Linux, and macOS). + +.DESCRIPTION + The `Get-PodeService` function checks if a Pode-based service is running or stopped on the host system. + It supports Windows (using `Get-Service`), Linux (using `systemctl`), and macOS (using `launchctl`). + The function returns a consistent result across all platforms by providing the service name and status in + a hashtable format. The status is mapped to common states like "Running," "Stopped," "Starting," and "Stopping." + +.PARAMETER Name + The name of the service. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.OUTPUTS + Hashtable + The function returns a hashtable containing the service name and its status. + For example: @{ Name = "MyService"; Status = "Running" } + +.EXAMPLE + Get-PodeService + + Retrieves the current status of the Pode service defined in the `srvsettings.json` configuration file. + +.EXAMPLE + Get-PodeService + + On Windows: + @{ Name = "MyService"; Status = "Running" } + + On Linux: + @{ Name = "MyService"; Status = "Stopped" } + + On macOS: + @{ Name = "MyService"; Status = "Unknown" } + +.NOTES + - The function reads the service name from the `srvsettings.json` file in the script's directory. + - For Windows, it uses the `Get-Service` cmdlet. + - For Linux, it uses `systemctl` to retrieve the service status. + - For macOS, it uses `launchctl` to check if the service is running. +#> +function Get-PodeService { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Agent + ) + # Ensure the script is running with the necessary administrative/root privileges. + # Exits the script if the current user lacks the required privileges. + Confirm-PodeAdminPrivilege + return Get-PodeServiceStatus -Name $Name -Agent:$Agent +} + +<# +.SYNOPSIS + Restart a Pode service on Windows, Linux, or macOS by sending the appropriate restart signal. + +.DESCRIPTION + The `Restart-PodeService` function handles the restart operation for a Pode service across multiple platforms: + - Windows: Sends a restart control signal (128) using `sc.exe control`. + - Linux/macOS: Sends the `SIGHUP` signal to the service's process ID. + +.PARAMETER Name + The name of the Pode service to restart. + +.PARAMETER Async + Indicates whether to return immediately after issuing the restart command. If not specified, the function waits until the service reaches the 'Running' state. + +.PARAMETER Timeout + The maximum time, in seconds, to wait for the service to reach the 'Running' state when not using `-Async`. Defaults to 10 seconds. + +.PARAMETER Agent + Specifies that only agent-type services should be returned. This parameter is applicable to macOS only. + +.EXAMPLE + Restart-PodeService -Name "MyPodeService" + + Attempts to restart the Pode service named "MyPodeService" on the current platform. + +.EXAMPLE + Restart-PodeService -Name "AnotherService" -Verbose + + Restarts the Pode service named "AnotherService" with detailed verbose output. + +.NOTES + - This function requires administrative/root privileges to execute. + - Platform-specific behavior: + - Windows: Uses `sc.exe control` with the signal `128` to restart the service. + - Linux/macOS: Sends the `SIGHUP` signal to the service process. + - If the service is not running or suspended, no restart signal is sent. + - If the service is not registered, an error is thrown. + - Errors and logs are captured for debugging purposes. + +#> +function Restart-PodeService { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'Async')] + [switch] + $Async, + + [Parameter(Mandatory = $false, ParameterSetName = 'Async')] + [int] + $Timeout = 10, + + [switch] + $Agent + ) + Write-Verbose -Message "Attempting to restart service '$Name' on platform $([System.Environment]::OSVersion.Platform)..." + + # Ensure the script is running with the necessary administrative/root privileges. + # Exits the script if the current user lacks the required privileges. + Confirm-PodeAdminPrivilege + + try { + + $service = Get-PodeServiceStatus -Name $Name -Agent:$Agent + if (!$service) { + # Service is not registered + throw ($PodeLocale.serviceIsNotRegisteredException -f $Name) + } + + if ('Running' -ne $service.Status ) { + Write-Verbose -Message "Service '$Name' is not Running." + return $false + } + if (Test-PodeIsWindows) { + + Write-Verbose -Message "Sending restart (128) signal to service '$Name'." + if ( Invoke-PodeWinElevatedCommand -Command 'sc control' -Arguments "'$Name' 128") { + if ($Async) { + return $true + } + else { + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + throw ($PodeLocale.serviceCommandFailedException -f 'sc.exe control {0} 128', $Name) + + } + elseif ($IsLinux) { + # Start the service + if (((Send-PodeServiceSignal -Name $Name -Signal 'SIGHUP'))) { + if ($Async) { + return $true + } + else { + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + # Service command '{0}' failed on service '{1}'. + throw ($PodeLocale.serviceCommandFailedException -f 'sudo systemctl start', $Name) + + } + elseif ($IsMacOS) { + # Start the service + if (((Send-PodeServiceSignal -Name $Name -Signal 'SIGHUP' -Agent:$Agent))) { + if ($Async) { + return $true + } + else { + return Wait-PodeServiceStatus -Name $Name -Status Running -Timeout $Timeout + } + } + # Service command '{0}' failed on service '{1}'. + throw ($PodeLocale.serviceCommandFailedException -f 'sudo systemctl start', $Name) + } + } + catch { + # Log and display the error + $_ | Write-PodeErrorLog + Write-Error -Exception $_.Exception + return $false + } + + Write-Verbose -Message "Service '$Name' restart operation completed successfully." + return $true +} + + diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index 893ffd464..e285c6bd0 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -831,29 +831,32 @@ function Out-PodeHost { <# .SYNOPSIS -Writes an object to the Host. + Writes an object to the Host. .DESCRIPTION -Writes an object to the Host. -It's advised to use this function, so that any output respects the -Quiet flag of the server. + Writes an object to the Host. + It's advised to use this function, so that any output respects the -Quiet flag of the server. .PARAMETER Object -The object to write. + The object to write. .PARAMETER ForegroundColor -An optional foreground colour. + An optional foreground colour. .PARAMETER NoNewLine -Whether or not to write a new line. + Whether or not to write a new line. .PARAMETER Explode -Show the object content + Show the object content .PARAMETER ShowType -Show the Object Type + Show the Object Type .PARAMETER Label -Show a label for the object + Show a label for the object + +.PARAMETER Force + Overrides the -Quiet flag of the server. .EXAMPLE 'Some output' | Write-PodeHost -ForegroundColor Cyan @@ -863,6 +866,7 @@ function Write-PodeHost { [CmdletBinding(DefaultParameterSetName = 'inbuilt')] param( [Parameter(Position = 0, ValueFromPipeline = $true)] + [Alias('Message')] [object] $Object, @@ -883,7 +887,10 @@ function Write-PodeHost { [Parameter( Mandatory = $false, ParameterSetName = 'object')] [string] - $Label + $Label, + + [switch] + $Force ) begin { # Initialize an array to hold piped-in values @@ -896,7 +903,7 @@ function Write-PodeHost { } end { - if ($PodeContext.Server.Quiet) { + if ($PodeContext.Server.Quiet -and !($Force.IsPresent)) { return } # Set Object to the array of values diff --git a/tests/integration/Service.Tests.ps1 b/tests/integration/Service.Tests.ps1 new file mode 100644 index 000000000..70804dcdd --- /dev/null +++ b/tests/integration/Service.Tests.ps1 @@ -0,0 +1,107 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] +param() + + + +Describe 'Service Lifecycle' { + + BeforeAll { + $isAgent=$false + if ($IsMacOS){ + $isAgent=$true + } + } + it 'register' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Register -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 10 + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + if ($IsMacOS) { + $status.Status | Should -Be 'Running' + $status.Pid | Should -BeGreaterThan 0 + } + else { + $status.Status | Should -Be 'Stopped' + $status.Pid | Should -Be 0 + } + + $status.Name | Should -Be 'Hello Service' + + } + + + it 'start' -Skip:( $IsMacOS) { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Start -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $webRequest = Invoke-WebRequest -uri http://localhost:8080 -ErrorAction SilentlyContinue + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + $webRequest.Content | Should -Be 'Hello, Service!' + } + + it 'pause' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Suspend -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + # $webRequest = Invoke-WebRequest -uri http://localhost:8080 -ErrorAction SilentlyContinue + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Suspended' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + # $webRequest | Should -BeNullOrEmpty + } + + it 'resume' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -resume -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $webRequest = Invoke-WebRequest -uri http://localhost:8080 -ErrorAction SilentlyContinue + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + $webRequest.Content | Should -Be 'Hello, Service!' + } + it 'stop' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Stop -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Stopped' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -Be 0 + + { Invoke-WebRequest -uri http://localhost:8080 } | Should -Throw + } + + it 're-start' { + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Start -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $webRequest = Invoke-WebRequest -uri http://localhost:8080 -ErrorAction SilentlyContinue + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $status.Name | Should -Be 'Hello Service' + $status.Pid | Should -BeGreaterThan 0 + $webRequest.Content | Should -Be 'Hello, Service!' + } + + + + it 'unregister' { + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status.Status | Should -Be 'Running' + $isAgent=$status.Type -eq 'Agent' + $success = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Unregister -Force -Agent:$isAgent + $success | Should -BeTrue + Start-Sleep 2 + $status = & "$($PSScriptRoot)\..\..\examples\HelloService\HelloService.ps1" -Query -Agent:$isAgent + $status | Should -BeNullOrEmpty + { Invoke-WebRequest -uri http://localhost:8080 } | Should -Throw + } + +} \ No newline at end of file diff --git a/tests/unit/Context.Tests.ps1 b/tests/unit/Context.Tests.ps1 index 6006e6428..6750f20e6 100644 --- a/tests/unit/Context.Tests.ps1 +++ b/tests/unit/Context.Tests.ps1 @@ -29,7 +29,7 @@ Describe 'Add-PodeEndpoint' { Context 'Valid parameters supplied' { BeforeAll { Mock Test-PodeIPAddress { return $true } - Mock Test-PodeIsAdminUser { return $true } + Mock Test-PodeAdminPrivilege { return $true } } It 'Set just a Hostname address - old' { $PodeContext.Server = @{ Endpoints = @{}; EndpointsMap = @{}; 'Type' = $null } @@ -371,7 +371,7 @@ Describe 'Add-PodeEndpoint' { } It 'Throws an error for not running as admin' { - Mock Test-PodeIsAdminUser { return $false } + Mock Test-PodeAdminPrivilege { return $false } $PodeContext.Server = @{ Endpoints = @{}; EndpointsMap = @{}; 'Type' = $null } { Add-PodeEndpoint -Address '127.0.0.2' -Protocol 'HTTP' } | Should -Throw -ExpectedMessage $PodeLocale.mustBeRunningWithAdminPrivilegesExceptionMessage #'*Must be running with admin*' } @@ -381,7 +381,7 @@ Describe 'Add-PodeEndpoint' { Describe 'Get-PodeEndpoint' { BeforeAll { Mock Test-PodeIPAddress { return $true } - Mock Test-PodeIsAdminUser { return $true } } + Mock Test-PodeAdminPrivilege { return $true } } It 'Returns no Endpoints' { $PodeContext.Server = @{ Endpoints = @{}; EndpointsMap = @{}; Type = $null } diff --git a/tests/unit/Routes.Tests.ps1 b/tests/unit/Routes.Tests.ps1 index b5f746448..118ef18ff 100644 --- a/tests/unit/Routes.Tests.ps1 +++ b/tests/unit/Routes.Tests.ps1 @@ -874,7 +874,7 @@ Describe 'Get-PodeRouteByUrl' { Describe 'Get-PodeRoute' { BeforeAll { Mock Test-PodeIPAddress { return $true } - Mock Test-PodeIsAdminUser { return $true } } + Mock Test-PodeAdminPrivilege { return $true } } BeforeEach { $PodeContext.Server = @{ 'Routes' = @{ 'GET' = @{}; 'POST' = @{}; }; 'FindEndpoints' = @{}; 'Endpoints' = @{}; 'EndpointsMap' = @{}; 'Type' = $null 'OpenAPI' = @{ diff --git a/tests/unit/Service.Tests.ps1 b/tests/unit/Service.Tests.ps1 new file mode 100644 index 000000000..a198585e4 --- /dev/null +++ b/tests/unit/Service.Tests.ps1 @@ -0,0 +1,237 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() + +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } +} + + +Describe 'Register-PodeService' { + BeforeAll { + Mock -CommandName Confirm-PodeAdminPrivilege + Mock -CommandName Register-PodeMonitorWindowsService { return $true } + Mock -CommandName Register-PodeLinuxService { return $true } + Mock -CommandName Register-PodeMacService { return $true } + Mock -CommandName Start-PodeService { return $true } + Mock -CommandName New-Item + Mock -CommandName ConvertTo-Json + Mock -CommandName Set-Content + Mock -CommandName Get-Process + Mock -CommandName Get-Module { return @{ModuleBase = $pwd } } + } + + + Context 'With valid parameters' { + + + It 'Registers a Windows service successfully' -Skip:(!$IsWindows) { + + # Arrange + $params = @{ + Name = 'TestService' + Description = 'Test Description' + DisplayName = 'Test Service Display Name' + StartupType = 'Automatic' + ParameterString = '-Verbose' + LogServicePodeHost = $true + Start = $true + } + # Mock -CommandName (Get-Process -Id $PID).Path -MockWith { 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' } + + # Act + Register-PodeService @params + + # Assert + Assert-MockCalled -CommandName Confirm-PodeAdminPrivilege -Exactly 1 + Assert-MockCalled -CommandName Register-PodeMonitorWindowsService -Exactly 1 + Assert-MockCalled -CommandName Start-PodeService -Exactly 1 + } + + It 'Registers a Linux service successfully' -Skip:(!$IsLinux) { + + $params = @{ + Name = 'LinuxTestService' + Description = 'Linux Test Service' + Start = $true + } + + # Act + Register-PodeService @params + + # Assert + Assert-MockCalled -CommandName Register-PodeLinuxService -Exactly 1 + Assert-MockCalled -CommandName Start-PodeService -Exactly 1 + } + + It 'Registers a macOS service successfully' -Skip:(!$IsMacOS) { + # Arrange + $params = @{ + Name = 'MacTestService' + Description = 'macOS Test Service' + Start = $true + } + + # Act + Register-PodeService @params + + # Assert + Assert-MockCalled -CommandName Register-PodeMacService -Exactly 1 + Assert-MockCalled -CommandName Start-PodeService -Exactly 1 + } + } + + Context 'With invalid parameters' { + It 'Throws an error if Name is missing' { + # Act & Assert + { Register-PodeService -Name $null -Description 'Missing Name' } | Should -Throw + } + + It 'Throws an error if Password is missing for a specified WindowsUser' -Skip:(!$IsWindows) { + # Arrange + $params = @{ + Name = 'TestService' + WindowsUser = 'TestUser' + } + + # Act & Assert + Register-PodeService @params -ErrorAction SilentlyContinue | Should -BeFalse + } + } + +} +Describe 'Start-PodeService' { + BeforeAll { + # Mock the required commands + Mock -CommandName Confirm-PodeAdminPrivilege + Mock -CommandName Invoke-PodeWinElevatedCommand + Mock -CommandName Test-PodeLinuxServiceIsRegistered + Mock -CommandName Test-PodeLinuxServiceIsActive + Mock -CommandName Start-PodeLinuxService + Mock -CommandName Test-PodeMacOsServiceIsRegistered + Mock -CommandName Test-PodeMacOsServiceIsActive + Mock -CommandName Start-PodeMacOsService + Mock -CommandName Write-PodeErrorLog + Mock -CommandName Write-Error + Mock -CommandName Get-PodeServiceStatus { return @{Status = '' } } + } + + Context 'On Windows platform' { + It 'Starts a stopped service successfully' -Skip:(!$IsWindows) { + # Mock a stopped service and simulate it starting + $script:status = 'none' + Mock -CommandName Get-PodeServiceStatus -MockWith { + if ($script:status -eq 'none') { + $script:status = 'Stopped' + } + else { + $script:status = 'Running' + } + [pscustomobject]@{ Name = 'TestService'; Status = $status } + } + Mock -CommandName Wait-PodeServiceStatus { $true } + Mock -CommandName Invoke-PodeWinElevatedCommand -MockWith { $true } + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Invoke-PodeWinElevatedCommand -Exactly 1 + } + + It 'Starts a started service ' -Skip:(!$IsWindows) { + Mock -CommandName Invoke-PodeWinElevatedCommand -MockWith { $null } + Mock -CommandName Get-PodeServiceStatus -MockWith { + [pscustomobject]@{ Name = 'TestService'; Status = 'Running' } + } + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Invoke-PodeWinElevatedCommand -Exactly 0 + } + + + It 'Throws an error if the service is not registered' -Skip:(!$IsWindows) { + + Start-PodeService -Name 'NonExistentService' -ErrorAction SilentlyContinue | Should -BeFalse + } + } + + Context 'On Linux platform' { + It 'Starts a stopped service successfully' -Skip:(!$IsLinux) { + $script:status = 'none' + Mock -CommandName Get-PodeServiceStatus -MockWith { + if ($script:status -eq 'none') { + $script:status = 'Stopped' + } + else { + $script:status = 'Running' + } + [pscustomobject]@{ Name = 'TestService'; Status = $status } + } + Mock -CommandName Wait-PodeServiceStatus { $true } + Mock -CommandName Test-PodeLinuxServiceIsRegistered -MockWith { $true } + Mock -CommandName Start-PodeLinuxService -MockWith { $true } + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Start-PodeLinuxService -Exactly 1 + } + + It 'Starts a started service ' -Skip:(!$IsLinux) { + + Mock -CommandName Start-PodeLinuxService -MockWith { $true } + Mock -CommandName Get-PodeServiceStatus -MockWith { + [pscustomobject]@{ Name = 'TestService'; Status = 'Running' } + } + + + # Act + Start-PodeService -Name 'TestService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Start-PodeLinuxService -Exactly 0 + } + + It 'Return false if the service is not registered' -Skip:(!$IsLinux) { + Mock -CommandName Test-PodeLinuxServiceIsRegistered -MockWith { $false } + Start-PodeService -Name 'NonExistentService' | Should -BeFalse + } + } + + Context 'On macOS platform' { + It 'Starts a stopped service successfully' -Skip:(!$IsMacOS) { + $script:status = 'none' + Mock -CommandName Get-PodeServiceStatus -MockWith { + if ($script:status -eq 'none') { + $script:status = 'Stopped' + } + else { + $script:status = 'Running' + } + [pscustomobject]@{ Name = 'TestService'; Status = $status } + } + Mock -CommandName Wait-PodeServiceStatus { $true } + Mock -CommandName Test-PodeMacOsServiceIsRegistered -MockWith { $true } + Mock -CommandName Start-PodeMacOsService -MockWith { $true } + + # Act + Start-PodeService -Name 'MacService' | Should -Be $true + + # Assert + Assert-MockCalled -CommandName Start-PodeMacOsService -Exactly 1 + } + + It 'Return false if the service is not registered' -Skip:(!$IsMacOS) { + Mock -CommandName Test-PodeMacOsServiceIsRegistered -MockWith { $false } + + Start-PodeService -Name 'NonExistentService' | Should -BeFalse + } + } +} +