Deploy Folding@home using PowerShell App Deployment Toolkit

The Folding@home client for Windows works well for per user installs on small numbers of machines, but it leaves something to be desired when used for mass deployments. After working recently to deploy the client at my university, I took the lessons learned in that effort and created a new deployment package using the PowerShell App Deployment Toolkit. If you are interested in running Folding@home in your environment, you can download the package from GitHub:

https://github.com/ladewig/psadt-foldingathome-client

The README.md should contain everything you need to get started, but if you’re interested in hearing more, keep reading.

The Toolkit

If you haven’t used the PowerShell App Deployment Toolkit (PSADT) before, it’s a framework you can use to build deployment packages for your applications. It was designed to work with Configuration Manager (ConfigMgr), but ConfigMgr isn’t a requirement. You can use it with other management tools or even on its own.

What’s great about the toolkit is it provides the plumbing needed to perform and manage application deployments, leaving you free to concentrate on what’s unique about installing, repairing, and uninstalling your application.

Assumptions

The package incorporates my assumptions about how the clients should run by default.

  1. The client should run silently in the background leaving the system available for someone to use.
  2. If the system has a GPU supported by the client, the client will run as a scheduled task which starts at boot.
  3. If the system does not have a supported GPU, the client will run as a service set to automatically start.
  4. Whether running as a service or as a scheduled task, it will run as LocalService for improved security. It has minimal privileges on the system and presents anonymous credentials on the network.
  5. The client data directory will be %ProgramData%\FAHClient, and LocalService will be granted Modify access to the folder and its contents.

Given those assumptions, the script does the following when installing the package.

  1. Create folder %ProgramData%\FAHClient
  2. Grant NT AUTHORITY\LocalService modify access to %ProgramData%\FAHClient
  3. Install the client with the silent option
    fah-installer_7.6.13_x86.exe /S
  4. Set the registry REG_SZ value DataDirectory at HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\FAHClient to the expanded value of %ProgramData%\FAHClient
  5. Create folder %ProgramData%\Microsoft\Start Menu\FAHClient
  6. Add firewall rule allowing access to program FAHClient.exe for Private and Domain profiles
  7. Create shortcuts for the FAH programs listed below under %ProgramData%\Microsoft\Start Menu\FAHClient with working directory set to %ProgramData%\FAHClient
    • About Folding@home
    • Data Directory
    • FAHControl
    • FAHViewer
    • Web Control
  8. Start the client

How to Use

You can get the complete package from GitHub, and step-by-step instructions are in README.md. Try it and let me know what you think.

https://github.com/ladewig/psadt-foldingathome-client

Peek Under the Covers

I thought I’d mention how the script is handling some parts of the installation. Continuing is not needed to use the package, but I think it’s interesting. The PowerShell code shown will be primarily from Deploy-Application.ps1. I’ll note if a section is from another file.

Creating config.xml

The first installer I worked on for the university doesn’t do much with config.xml. It drops a customized config.xml file with our custom settings and a placeholder for the user value, then replaces the placeholder with the username that is generated.

For this package, I wanted to make it more flexible and easier to specify options, so the package creates the config.xml file using the client’s built-in command capability. User options are specified in a plain text file (defaults.txt) using the format property=value with one property per line. The sample file in the package looks like this:

team=ReplaceWithYourTeamNumber
passkey=ReplaceWithYourPasskey
cause=COVID-19
password=ReplaceWithYourRemotePassword
allow=127.0.0.1,X.X.X.X/24
user=ReplaceWithYourUser

Additional properties can be specified as desired. We load the file into a hash table, then iterate over those values to create a string we can use to configure the client. We also check to see if ‘user’ has been set.

# Read custom settings for config.xml from text file into hash table
$FahDefaults = Get-Content -Raw -Path "$dirSupportFiles\$FahDefaultsFile" | ConvertFrom-StringData

# Build arguments for setting options from hash table
$FahOptions = $null
$IsUserSet = $false
$FahDefaults.GetEnumerator() | ForEach-Object {
    $FahOptions += "$($_.Name)=$($_.Value) "
    If ($_.Name -eq "User" -and $IsUserSet -eq $false) {
        $IsUserSet = $true
    }
}

If ‘user’ is not defined in options, we use a custom function to generate a username and add it to the string. The Get-FahUsername function in the package looks for a department code in the machine name and compares it to a list of departments to generate a name. That’s how we handled it at the University, but if you want to do something else, you can rewrite that function to meet your needs.

# If username was not specified in defaults.txt, get username for system using custom function
If ($IsUserSet -eq $false) {
    $FahUsername = Get-FahUsername
    $FahOptions += "user=$FahUsername"
}

With the sample data shown above, the $FAHOptions string looks like this:

team=ReplaceWithYourTeamNumber passkey=ReplaceWithYourPasskey cause=COVID-19 password=ReplacewithYourRemotePassword allow=127.0.0.1,x.x.x.x/24 user=ReplaceWithYourUser

We now have what we need to create the config.xml.

  1. Start the client in paused state. It needs to be available to process commands, but we don’t want it processing work units. Changing the directory ensures the config.xml file is created in the correct folder.
    FAHClient.exe --chdir "C:\ProgramData\FAHClient" --pause-on-start=true
  2. When the client starts, it sees that there is no config.xml file in the data directory folder, and it configures the client to use a default configuration.
  3. Send command to the paused client to set the specified options.
    FAHClient.exe --send-command options team=ReplaceWithYourTeamNumber passkey=ReplaceWithYourPasskey cause=COVID-19 password=ReplacewithYourRemotePassword allow=127.0.0.1,x.x.x.x/24 user=ReplaceWithYourUser
  4. Send command to the paused client to save the configuration (config.xml).
    FAHClient.exe --send-command save
  5. Send command to the paused client to shut down.
    FAHClient.exe --send-command shutdown

We now have a properly formatted config.xml file without ever having had to mess with XML using PowerShell (which is always more difficult than it should be).

Check for GPU

We need to know if a supported GPU is installed in the machine to decide whether to use a service or a scheduled task. Originally, I thought about rolling my own check against hardware IDs using the client’s GPUs.txt file (a combined blacklist/whitelist of GPUs), but I quickly realized this wasn’t needed.

When we create the config.xml file, the client starts with a default configuration. The default configuration contains a CPU slot, and if a supported GPU is present, a GPU slot. All we need to do is read the config.xml file and see if contains a GPU slot.

# Read config.xml and Check to see if GPU was detected by presence of a GPU slot
[xml]$FahConfig = Get-Content -Path "$FahDataDirectory\config.xml"

If ($FahConfig.config.slot.type -eq 'GPU') {
# A supported GPU was detected, run FAHClient using a scheduled task because a service doesn't have access to the GPU
...
} else {
    # No supported GPU detected, run FAHClient as a service
...
}

No GPU? Configure a service

If a supported GPU isn’t found, we run the client as a service. The client sets itself up as a service:

FAHClient.exe --install-service

The service is set to run as Local System by default. That will work, but the client doesn’t need that level of access. Instead we want to run as LocalService because it has limited rights on the system and only anonymous access on the network.

The first step is to give LocalService modify permission to the data directory folder.

$FahUserAccount = 'NT AUTHORITY\LocalService'
$FahUserPassword = '' # Password needs to be an empty string for Local Service when we modify the service later
$Rights = 'Modify'
$Inheritance = 'Containerinherit, ObjectInherit'
$Propagation = 'None'
$RuleType = 'Allow'

$Acl = Get-Acl $FahDataDirectory
$Perm = $FahUserAccount, $Rights, $Inheritance, $Propagation, $RuleType
$Rule = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList $Perm
$Acl.SetAccessRule($Rule)
$Acl | Set-Acl -Path $FahDataDirectory

Then we change the service account.

$Service = Get-CimInstance Win32_Service -Filter "Name='FAHClient'"
$null = Invoke-CimMethod -InputObject $Service -MethodName Change -Arguments @{StartName=$FahUserAccount;StartPassword=$FahUserPassword}

Found a GPU? Configure a scheduled task

If the system does have a supported GPU, we run the client as a scheduled task. This gets a bit more complicated, and we use a custom function to set that up.

First create the task action (what the task does), task trigger (when the task runs), and the task settings (how the task runs). We start with a trigger that starts the client at midnight every day.

# Create task aion
$TaskAction = New-ScheduledTaskAction -Execute $PathExe -WorkingDirectory $PathWorking
    
# Create task trigger to run daily at midnight
$TaskTrigger = New-ScheduledTaskTrigger -Daily -At 00:00

# Create default task settings set.
$TaskSettings = New-ScheduledTaskSettingsSet

Next, we create the task principal (the account the task runs as). We want to run the task as LocalService (same as we did for the service in the no-GPU scenario), but the package supports using different accounts. Because of that we need ot check to see if the specified account is a service account or not. For a service account we want to specify that the logontype is ‘ServiceAccount’.

If ($User -match '^(NT AUTHORITY\\(LocalService|NetworkService)|LocalSystem)') {
$TaskPrincipal = New-ScheduledTaskPrincipal -UserId $User -LogonType ServiceAccount
    $IsServiceAccount= $true
} Else {
    $TaskPrincipal = New-ScheduledTaskPrincipal -UserId $User -LogonType Password
    $IsServiceAccount= $false
}

With all those set, we create the scheduled task.

# Create the task and register it
$Task = New-ScheduledTask -Action $TaskAction -Principal $TaskPrincipal -Trigger $TaskTrigger -Settings $TaskSettings
If ($IsServiceAccount) {
    $null = Register-ScheduledTask -TaskName $Name -InputObject $Task
} else {
    $null = Register-ScheduledTask -TaskName $Name -InputObject $Task -Password $Password
}

We have a scheduled task! But we need to make some modifications that couldn’t be set with the commands used. Here comes the XML part of this exercise.

Start by exporting the task to XML and get the namespace for the task. We’ll need this later.

# Export task to XML so that we can add a second trigger
[xml]$TaskXml = Export-ScheduledTask -TaskName $Name

# Get the namespace to use for creating elements
$TaskXmlNs = $TaskXml.Task.NamespaceURI

The task was set to start every day at midnight, but we don’t want the client to sit idle all day if something happens to it, so we want the task to run every hour. Don’t worry, if the task is still running, it won’t try to start another instance.

We build the trigger element by element, starting with the Repetition element, then adding the Interval and Duration elements. Interval and Duration are specified by setting the InnerText property as ISO 8601 duration values. The values we use are a P1D (one day) and PT1H (one hour). Note that we need to specify the namespace ($TaskXmlNs) when we create the elements; that’s why we retrieve that earlier.

# Add hourly recurrence for the daily trigger to ensure that client restarts if it stops due to error or other reason
# Create Repetition element
$RepetitionXml = $TaskXml.CreateElement("Repetition", $TaskXmlNs)

# Create Interval element, set value to one hour, and append to Repetition
$IntervalXml = $TaskXml.CreateElement("Interval", $TaskXmlNs)
$IntervalXml.InnerText = "PT1H"
$null = $RepetitionXml.AppendChild($IntervalXml)

# Create duration element, set value to one day, and append to Repetition
$DurationXml = $TaskXml.CreateElement("Duration", $TaskXmlNs)
$DurationXml.InnerText = "P1D"
$null = $RepetitionXml.AppendChild($DurationXml)

# Add Repetition element to CalendarTrigger
$null = $TaskXml.Task.Triggers.CalendarTrigger.AppendChild($RepetitionXml)

We also want to start the client at boot time because we don’t want it sitting idle waiting for the first hourly trigger. Create the BootTrigger element, append it to Triggers, and we’re done.

# We want the task to start on boot, so create a BootTrigger element$BootTriggerXml = $TaskXml.CreateElement("BootTrigger", $TaskXmlNs)
$null = $TaskXML.Task.Triggers.AppendChild($BootTriggerXml)

The last step is to replace the original task with our changes.

# Remove the existing task, then register a new task using the updated definition
$null = Unregister-ScheduledTask -TaskName $Name -Confirm:$false
$null = Register-ScheduledTask -TaskName $Name -Xml $TaskXml.OuterXml

And we’re done! It would be so much easier if we could simply specify all our triggers upfront, but this works well enough once you figure out all the little nuances of XML. This part of the package was certainly the most time-consuming part.

Get the Package and Start Folding

That’s all I have. If you’re still here reading this, thanks. Now get the package and start deploying Folding@home in your organization.

2 Comments